Skip to content

Commit f8c5340

Browse files
first commit
1 parent 32ebacf commit f8c5340

29 files changed

Lines changed: 7533 additions & 156 deletions

NOTES.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Notes
2+
3+
Context and trade-offs for the RBAC implementation.
4+
5+
## Time Budget
6+
7+
The task is scoped to ≈ 1 hour. Roughly:
8+
9+
- ~1 hour up front was spent setting up the backend natively (Postgres, uv, alembic, seed data) because my machine ran out of disk space and Docker wasn't a workable option. That time wasn't part of the implementation work itself, but it shaped the order in which things were done.
10+
- The implementation work followed the task's own ranking: backend RBAC first, focused tests second, README and frontend RBAC together.
11+
12+
## Scope Cuts
13+
14+
Things I intentionally did **not** do, in order of how much they cost:
15+
16+
1. **Separate test database.** Tests run against the development DB and wipe `User` / `Item` tables on teardown. This means the three seed users (`admin@`, `manager@`, `member@`) are removed after pytest finishes and need to be re-seeded with `uv run python -m app.initial_data`. A proper test DB (`pytest-postgresql` or a `TESTING=1` toggle in `core/db.py`) is the right fix.
17+
18+
2. **Architecture Decision Records (ADRs).** Listed as optional/bonus. The README's "Authorization Approach" section covers the same ground in compact prose; a formal ADR would add structure but not new information for this scope. The decision most worth an ADR is dependency factory vs. policy layer — see "Deliberate Trade-offs" below.
19+
20+
3. **Diagram of auth flow.** Also optional/bonus. The request flow is short enough (JWT decode → `get_current_user``require_role` → route handler, then `/forbidden` on 403) that prose explains it without ambiguity.
21+
22+
4. **Structured logging of denied attempts.** A one-liner inside `require_role` would emit a `logger.warning` on every 403, which is useful for production observability. Skipped because there's no logging convention in the template yet and adding one halfway is worse than not having it.
23+
24+
## Deliberate Trade-offs
25+
26+
These are decisions I'd make the same way again, but they deserve explicit mention:
27+
28+
- **`is_superuser` left in place.** The existing template uses `is_superuser` in a few places (e.g. self-delete protection on the backend, a couple of UI checks before this work). RBAC checks are now in `role`; `is_superuser` is functionally redundant but harmless, and ripping it out would touch unrelated code. Backfill in the migration ensures `is_superuser=True` users got `role='admin'`.
29+
30+
- **`GET /users/{user_id}` keeps inline conditional logic.** Its rule ("own profile always allowed, others admin-only") depends on a path parameter, which dependency-based checks can't see cleanly. The check lives in the route body. A policy layer would normalize this; see above.
31+
32+
- **Permission module duplicates role names between backend and frontend.** `UserRole` is declared in both `backend/app/models.py` and `frontend/src/lib/auth/permissions.ts`. The frontend's `Action`-to-roles `POLICY` table also independently mirrors the backend's `require_role(...)` calls. This is a deliberate duplication: a single source of truth would require either generating the frontend module from OpenAPI metadata or a shared package, both of which are heavier than the problem warrants for a 3-role matrix. The trade-off is a code review checklist: when a role or rule changes, two files must be updated.
33+
34+
- **Route guards (`beforeLoad`) re-fetch `/users/me`.** Each protected route makes its own call to `/users/me` rather than reading from a shared cache. React Query would normally dedupe this, but `beforeLoad` runs outside the React tree. The cost is a few extra GET requests on navigation; the alternative (a shared cached client) was more plumbing than the time budget allowed.
35+
36+
## What I'd Do With More Time
37+
38+
In rough order of value:
39+
40+
1. **Isolated test database** so the seed data survives test runs.
41+
2. **One ADR** for the dependency-vs-policy-layer decision — it's the choice most likely to be revisited as the role matrix grows.
42+
3. **Structured logging on denied attempts** (`logger.warning` inside `require_role`) so denials are observable in production.
43+
4. **Audit `is_superuser` call-sites** and either fully replace them with `role == UserRole.ADMIN` or document the boundary explicitly.
44+
5. **Single source of truth for the permission matrix** — either auto-generate `frontend/src/lib/auth/permissions.ts` from a manifest or move the role check into something shared.
45+
6. **Polish the Forbidden UX** — instead of a full redirect on 403, show a toast for in-page actions (e.g. "you don't have permission to delete this user") while keeping the redirect for full-page navigation.

README.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,126 @@
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+
1124
# Full Stack FastAPI Template
2125

3126
<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>

backend/.env

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Domain
2+
# This would be set to the production domain with an env var on deployment
3+
# used by Traefik to transmit traffic and aqcuire TLS certificates
4+
DOMAIN=localhost
5+
# To test the local Traefik config
6+
# DOMAIN=localhost.tiangolo.com
7+
8+
# Used by the backend to generate links in emails to the frontend
9+
FRONTEND_HOST=http://localhost:5173
10+
# In staging and production, set this env var to the frontend host, e.g.
11+
# FRONTEND_HOST=https://dashboard.example.com
12+
13+
# Environment: local, staging, production
14+
ENVIRONMENT=local
15+
16+
PROJECT_NAME="Full Stack FastAPI Project"
17+
STACK_NAME=full-stack-fastapi-project
18+
19+
# Backend
20+
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
21+
SECRET_KEY=changethis
22+
FIRST_SUPERUSER=admin@example.com
23+
FIRST_SUPERUSER_PASSWORD=changethis
24+
TEST_MANAGER_EMAIL=manager@example.com
25+
TEST_MANAGER_PASSWORD=changethis
26+
TEST_MEMBER_EMAIL=member@example.com
27+
TEST_MEMBER_PASSWORD=changethis
28+
29+
# Emails
30+
SMTP_HOST=
31+
SMTP_USER=
32+
SMTP_PASSWORD=
33+
EMAILS_FROM_EMAIL=info@example.com
34+
SMTP_TLS=True
35+
SMTP_SSL=False
36+
SMTP_PORT=587
37+
38+
# Postgres
39+
POSTGRES_SERVER=localhost
40+
POSTGRES_PORT=5432
41+
POSTGRES_DB=app
42+
POSTGRES_USER=postgres
43+
POSTGRES_PASSWORD=changethis
44+
45+
SENTRY_DSN=
46+
47+
# Configure these with your own Docker registry images
48+
DOCKER_IMAGE_BACKEND=backend
49+
DOCKER_IMAGE_FRONTEND=frontend
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""add_role_to_user
2+
3+
Revision ID: 291237274644
4+
Revises: fe56fa70289e
5+
Create Date: 2026-05-19 22:19:06.452770
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '291237274644'
15+
down_revision = 'fe56fa70289e'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.add_column(
22+
'user',
23+
sa.Column('role', sa.String(length=32), nullable=True),
24+
)
25+
26+
op.execute("UPDATE \"user\" SET role = 'admin' WHERE is_superuser = true")
27+
op.execute("UPDATE \"user\" SET role = 'member' WHERE role IS NULL")
28+
29+
op.alter_column('user', 'role', nullable=False)
30+
31+
32+
def downgrade():
33+
op.drop_column('user', 'role')

backend/app/api/deps.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from app.core import security
1212
from app.core.config import settings
1313
from app.core.db import engine
14-
from app.models import TokenPayload, User
14+
from app.models import TokenPayload, User, UserRole
1515

1616
reusable_oauth2 = OAuth2PasswordBearer(
1717
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
@@ -55,3 +55,14 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
5555
status_code=403, detail="The user doesn't have enough privileges"
5656
)
5757
return current_user
58+
59+
def require_role(*allowed_roles: UserRole):
60+
def role_checker(current_user: CurrentUser) -> User:
61+
if current_user.role not in allowed_roles:
62+
raise HTTPException(
63+
status_code=403,
64+
detail="The user doesn't have enough privileges",
65+
)
66+
return current_user
67+
68+
return role_checker

backend/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import items, login, private, users, utils
3+
from app.api.routes import items, login, private, users, utils, metrics
44
from app.core.config import settings
55

66
api_router = APIRouter()
77
api_router.include_router(login.router)
88
api_router.include_router(users.router)
99
api_router.include_router(utils.router)
1010
api_router.include_router(items.router)
11+
api_router.include_router(metrics.router)
1112

1213

1314
if settings.ENVIRONMENT == "local":

backend/app/api/routes/metrics.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from fastapi import APIRouter, Depends
2+
from sqlmodel import func, select
3+
4+
from app.api.deps import SessionDep, require_role
5+
from app.models import Item, MetricsPublic, User, UserRole
6+
7+
router = APIRouter(prefix="/metrics", tags=["metrics"])
8+
9+
10+
@router.get(
11+
"/",
12+
dependencies=[Depends(require_role(UserRole.ADMIN, UserRole.MANAGER))],
13+
response_model=MetricsPublic,
14+
)
15+
def read_metrics(session: SessionDep) -> MetricsPublic:
16+
"""
17+
Return basic app metrics. Accessible by admins and managers.
18+
"""
19+
total_users = session.exec(select(func.count()).select_from(User)).one()
20+
active_users = session.exec(
21+
select(func.count()).select_from(User).where(User.is_active == True) # noqa: E712
22+
).one()
23+
total_items = session.exec(select(func.count()).select_from(Item)).one()
24+
25+
return MetricsPublic(
26+
total_users=total_users,
27+
active_users=active_users,
28+
total_items=total_items,
29+
)

0 commit comments

Comments
 (0)