|
| 1 | +# Role-Based Access Control (RBAC) Implementation |
| 2 | + |
| 3 | +This fork extends the original [Full Stack FastAPI Template](#full-stack-fastapi-template) with role-based access control. The original template README follows below. |
| 4 | + |
| 5 | +## Quick Start |
| 6 | + |
| 7 | +This solution runs **without Docker** against a local Postgres install. Due to insufficient free disk space on my machine, I spent ≈ 1 hour bringing the backend up natively (Postgres + uv + alembic + initial data seed) instead of using `docker compose`. The instructions below reflect this setup; the original Docker-based workflow from the upstream template still works if you have the disk space for it. |
| 8 | + |
| 9 | +### Prerequisites |
| 10 | + |
| 11 | +- Python 3.12 |
| 12 | +- [uv](https://docs.astral.sh/uv/) for dependency management |
| 13 | +- PostgreSQL 18 running on `localhost:5432` |
| 14 | +- Node.js 20+ and npm (for the frontend) |
| 15 | + |
| 16 | +### Backend Setup |
| 17 | + |
| 18 | +1. Create a database named `app` in your local Postgres: |
| 19 | + |
| 20 | +```sql |
| 21 | + CREATE DATABASE app; |
| 22 | +``` |
| 23 | + |
| 24 | +2. Copy `.env` into the backend folder so the app finds its config: |
| 25 | + |
| 26 | +```bash |
| 27 | + cd backend |
| 28 | + cp ../.env . |
| 29 | +``` |
| 30 | + |
| 31 | +Defaults in `.env` are tuned for local development. The Postgres password is `changethis`; adjust if your local Postgres uses a different one. |
| 32 | + |
| 33 | +3. Install dependencies and apply migrations: |
| 34 | + |
| 35 | +```bash |
| 36 | + uv sync |
| 37 | + uv run alembic upgrade head |
| 38 | +``` |
| 39 | + |
| 40 | +4. Seed test users (one per role): |
| 41 | + |
| 42 | +```bash |
| 43 | + uv run python -m app.initial_data |
| 44 | +``` |
| 45 | + |
| 46 | +This creates three users in the `local` environment: |
| 47 | + |
| 48 | +| Email | Password | Role | |
| 49 | +| ------------------- | ---------- | ------- | |
| 50 | +| admin@example.com | changethis | admin | |
| 51 | +| manager@example.com | changethis | manager | |
| 52 | +| member@example.com | changethis | member | |
| 53 | + |
| 54 | +5. Run the backend: |
| 55 | + |
| 56 | +```bash |
| 57 | + uv run fastapi run --reload app/main.py |
| 58 | +``` |
| 59 | + |
| 60 | +API and Swagger UI are at <http://localhost:8000/docs>. |
| 61 | + |
| 62 | +### Frontend Setup |
| 63 | + |
| 64 | +In a second terminal: |
| 65 | + |
| 66 | +```bash |
| 67 | +cd frontend |
| 68 | +npm install |
| 69 | +npm run dev |
| 70 | +``` |
| 71 | + |
| 72 | +The app opens at <http://localhost:5173>. Log in with any of the three seeded users above to see how the UI adapts to each role. |
| 73 | + |
| 74 | +> If you change the backend's OpenAPI schema (new endpoints, new fields), regenerate the typed client: |
| 75 | +> |
| 76 | +> ```bash |
| 77 | +> cd frontend |
| 78 | +> Invoke-WebRequest -Uri "http://localhost:8000/api/v1/openapi.json" -OutFile "openapi.json" # PowerShell |
| 79 | +> # or: curl http://localhost:8000/api/v1/openapi.json -o openapi.json # bash |
| 80 | +> npm run generate-client |
| 81 | +> ``` |
| 82 | +
|
| 83 | +### Running Tests |
| 84 | +
|
| 85 | +```bash |
| 86 | +cd backend |
| 87 | +uv run pytest |
| 88 | +``` |
| 89 | +
|
| 90 | +RBAC-specific tests live in `backend/app/tests/api/routes/test_rbac.py`. |
| 91 | + |
| 92 | +> **Note:** the test suite shares the development database and wipes user tables on teardown. After running tests, re-run `uv run python -m app.initial_data` to restore the three seed users. |
| 93 | +
|
| 94 | +## Permission Matrix |
| 95 | + |
| 96 | +| Action | admin | manager | member | |
| 97 | +| --------------------- | :---: | :-----: | :----: | |
| 98 | +| List all users | ✓ | ✓ | ✗ | |
| 99 | +| Create user | ✓ | ✗ | ✗ | |
| 100 | +| View metrics | ✓ | ✓ | ✗ | |
| 101 | +| View own profile | ✓ | ✓ | ✓ | |
| 102 | +| Update own profile | ✓ | ✓ | ✓ | |
| 103 | +| View any user profile | ✓ | ✗ | ✗ | |
| 104 | +| Update any user | ✓ | ✗ | ✗ | |
| 105 | +| Delete any user | ✓ | ✗ | ✗ | |
| 106 | +| Delete own account | ✗ | ✓ | ✓ | |
| 107 | + |
| 108 | +Admins cannot delete their own account to prevent locking out of the system. |
| 109 | + |
| 110 | +## Authorization Approach |
| 111 | + |
| 112 | +**Backend — where checks live.** Authorization is implemented as a FastAPI dependency factory, `require_role(*allowed_roles)`, defined in `backend/app/api/deps.py`. The factory returns a sub-dependency that consumes the already-authenticated `CurrentUser` and raises `HTTPException(403)` if the user's role is not in the allowed set. Each protected endpoint declares its policy in the route decorator, e.g. `dependencies=[Depends(require_role(UserRole.ADMIN, UserRole.MANAGER))]`. This keeps the policy declaration next to the route, makes it grep-able, and lets FastAPI handle the wiring. |
| 113 | + |
| 114 | +**Backend — how roles are stored.** Roles are a string-based Python `Enum` (`UserRole` in `backend/app/models.py`) persisted as a plain `VARCHAR` column on the `user` table. Storing as a string (rather than a Postgres ENUM type) keeps migrations simple — adding a new role requires no schema change, only Python and frontend updates. Pydantic validates incoming values against the enum at the API boundary. |
| 115 | + |
| 116 | +**Frontend — how capabilities are exposed.** The `/users/me` endpoint returns the full user object including `role` (it's part of `UserBase`, so it ships in every `UserPublic` response automatically). The frontend reads this on login via `useAuth` and caches it in React Query. A single permission module at `frontend/src/lib/auth/permissions.ts` defines the policy as a `Record<Action, UserRole[]>` and exposes one helper, `can(user, action)`. Every UI decision — sidebar items, action buttons, table columns — flows through `can()`, so the entire frontend policy lives in one file. Adding a new role or action is a one-line change in that record. |
| 117 | + |
| 118 | +**Frontend — defense in depth.** UI hiding alone isn't enough; users can still type unauthorized URLs. Three layers protect against this: (1) `beforeLoad` guards on protected routes (`/admin`, `/metrics`) call `/users/me` and redirect to `/forbidden` if the role doesn't fit; (2) a global React Query error handler in `main.tsx` catches any 403 returned from the API and redirects to `/forbidden` automatically — useful when backend policy is stricter than the frontend expects, or evolves over time; (3) the backend itself is the source of truth and rejects any unauthorized request regardless of how the request was made. Frontend checks exist for UX only. |
| 119 | + |
| 120 | +**Conditional logic.** A small number of endpoints have access rules that depend on the _target_ of the action (e.g. `GET /users/{user_id}` — your own profile is always visible, others are admin-only). For these, the check lives inline in the route body rather than in a dependency, since dependencies can't see path parameters cleanly. This is a deliberate trade-off; see `NOTES.md`. |
| 121 | + |
| 122 | +--- |
| 123 | + |
1 | 124 | # Full Stack FastAPI Template |
2 | 125 |
|
3 | 126 | <a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3A%22Test+Docker+Compose%22" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test%20Docker%20Compose/badge.svg" alt="Test Docker Compose"></a> |
|
0 commit comments