This repository contains two deliverables:
- A role-aware access control implementation on top of the FastAPI full-stack template.
- A tunnel-only Ghost deployment script for Hetzner Cloud in
infra/ghost/.
The application keeps the original FastAPI / SQLModel / PostgreSQL backend and React / TypeScript frontend, but adds explicit roles, backend authorization dependencies, role-aware frontend navigation, a metrics page, seed data, tests, and documentation.
Roles:
admin: full access to user management, role changes, and metrics.manager: can list users and view metrics, but cannot create, edit, or delete users.member: can access their own profile and normal app features only.
Permission matrix:
| Action | admin | manager | member |
|---|---|---|---|
| List all users | Yes | Yes | No |
| Create user | Yes | No | No |
| View metrics | Yes | Yes | No |
| View/update own profile | Yes | Yes | Yes |
| View another profile | Yes | Yes | No |
| Update any profile | Yes | No | No |
| Delete users | Yes | No | No |
Protected surfaces:
GET /api/v1/users/is available to admins and managers.POST /api/v1/users/,PATCH /api/v1/users/{user_id}, andDELETE /api/v1/users/{user_id}are admin-only.GET /api/v1/metrics/is available to admins and managers./adminis visible to admins and managers; write actions are hidden for managers./metricsis visible to admins and managers.- Unauthorized direct navigation shows an access denied page instead of silently redirecting.
Roles are stored on User.role as one of admin, manager, or member. The existing is_superuser field is preserved for compatibility with the base template, but admin users are normalized so role="admin" and is_superuser=true stay aligned. A database migration adds the role column and backfills existing superusers to admin.
Backend authorization lives in FastAPI dependencies in backend/app/api/deps.py. The central helper is require_roles(...), which keeps route-level permission checks declarative and easy to extend. User management routes use that dependency instead of scattering role checks through every handler. The only object-specific exception is profile access, where users can still read themselves and admins/managers can read other profiles.
The frontend learns capabilities from UsersService.readUserMe(), which now includes role. Shared permission helpers in frontend/src/permissions.ts drive sidebar visibility, page access, and whether user-management actions render. These frontend checks are UX-only; the backend remains the source of truth for enforcement.
Prerequisites:
- Docker Desktop with Docker Compose.
Start the full stack from the repository root:
docker compose watchOpen:
- Frontend:
http://localhost:5173 - Backend API docs:
http://localhost:8000/docs - Adminer:
http://localhost:8080
If you prefer running only the frontend locally, install Bun and run:
cd frontend
bun install
bun run devThe frontend still expects the backend to be running at http://localhost:8000.
The application seeds one admin from .env plus one manager and one member during backend startup.
| Role | Password | |
|---|---|---|
| admin | admin@example.com |
changethis |
| manager | manager@example.com |
changethis |
| member | member@example.com |
changethis |
The admin credentials come from:
FIRST_SUPERUSER=admin@example.com
FIRST_SUPERUSER_PASSWORD=changethisRun the backend test suite in Docker:
docker compose up -d --wait backend
docker compose exec backend bash scripts/tests-start.shRun the focused RBAC tests:
docker compose exec backend bash scripts/tests-start.sh tests/api/routes/test_users.py tests/api/routes/test_metrics.pyRun frontend checks:
cd frontend
bun install
bun run build
bun run lintRun Playwright tests:
docker compose up -d --wait backend
cd frontend
bunx playwright testRBAC adds a role column to the user table.
Migration file:
backend/app/alembic/versions/b3a8e8a9d5f2_add_user_role.py
Docker startup runs the existing prestart flow, which applies Alembic migrations automatically. To run migrations manually inside the backend container:
docker compose exec backend alembic upgrade headImportant implementation files:
backend/app/models.py:UserRoleenum andUser.role.backend/app/api/deps.py: reusable role authorization dependency.backend/app/api/routes/users.py: protected user-management routes.backend/app/api/routes/metrics.py: metrics endpoint for admins/managers.backend/app/core/db.py: seed admin, manager, and member users.frontend/src/permissions.ts: frontend capability helpers.frontend/src/routes/_layout/admin.tsx: role-aware user page.frontend/src/routes/_layout/metrics.tsx: metrics page.frontend/src/components/Common/Forbidden.tsx: friendly forbidden state.
The frontend client files under frontend/src/client/ were updated to include the new role field and metrics service.
Admin user management view with seeded admin, manager, and member users:
Backend OpenAPI docs showing the protected user endpoints:
Adminer database view showing the application tables:
The infrastructure deliverable is available in both GHOST.md and infra/ghost/. It provides a one-click script to deploy Ghost to a Hetzner Cloud VPS without public SSH access. The server is provisioned with cloud-init, runs Ghost and MySQL with Docker Compose, and exposes Ghost only through an outbound Cloudflare Tunnel.
- Hetzner Cloud VPS running Ubuntu.
- Hetzner Cloud Firewall attached with no inbound allow rules.
- SSH disabled during cloud-init bootstrap.
- Docker Compose stack:
ghost:5-alpinemysql:8.4cloudflare/cloudflared
- Cloudflare Tunnel routes the public hostname to
http://ghost:2368.
Install the Hetzner Cloud CLI:
brew install hcloudCreate:
- A Hetzner Cloud API token with read/write access.
- A Cloudflare Tunnel token from Cloudflare Zero Trust.
- A Cloudflare public hostname for the tunnel. Configure it to point to:
http://ghost:2368
The ghost hostname works because cloudflared and ghost run in the same Docker Compose network on the VPS.
From the repository root:
export HCLOUD_TOKEN="your-hetzner-token"
export CF_TUNNEL_TOKEN="your-cloudflare-tunnel-token"
export GHOST_HOSTNAME="blog.example.com"
./infra/ghost/deploy-ghost-hetzner.shOptional overrides:
export SERVER_NAME="ghost-blog"
export HCLOUD_LOCATION="fsn1"
export HCLOUD_SERVER_TYPE="cax11"
export HCLOUD_IMAGE="ubuntu-24.04"
export FIREWALL_NAME="ghost-tunnel-only"Bootstrap usually takes 3-5 minutes after Hetzner creates the server. Then open:
https://blog.example.com/ghost
Ghost will show the first-run admin setup screen.
Public SSH is intentionally unavailable:
- The Hetzner firewall has no inbound allow rules.
ssh/sshdis disabled by cloud-init.- Ghost is not published on a public port.
- Cloudflare Tunnel is the only public path to the application.
For emergency diagnostics, use the Hetzner web console. On the machine, the stack lives in:
/opt/ghost
The bootstrap marker is written to:
/var/log/ghost-bootstrap.log
Delete the server:
hcloud server delete ghost-blogDelete the firewall if it is no longer used:
hcloud firewall delete ghost-tunnel-onlyRemove or disable the Cloudflare Tunnel/public hostname in Cloudflare Zero Trust.
The RBAC implementation is intentionally small and explicit. It favors readable dependencies and shared permission helpers over a larger policy framework. Observability for denied attempts and a formal ADR were left out to keep the implementation focused on the required authorization behavior, tests, setup, and documentation.


