Skip to content
Closed

Main #2296

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Notes

Context and trade-offs for the RBAC implementation.

## Time Budget

The task is scoped to ≈ 1 hour. Roughly:

- ~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.
- The implementation work followed the task's own ranking: backend RBAC first, focused tests second, README and frontend RBAC together.

## Scope Cuts

Things I intentionally did **not** do, in order of how much they cost:

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.

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.

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.

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.

## Deliberate Trade-offs

These are decisions I'd make the same way again, but they deserve explicit mention:

- **`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'`.

- **`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.

- **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.

## What I'd Do With More Time

In rough order of value:

1. **Isolated test database** so the seed data survives test runs.
2. **One ADR** for the dependency-vs-policy-layer decision — it's the choice most likely to be revisited as the role matrix grows.
3. **Structured logging on denied attempts** (`logger.warning` inside `require_role`) so denials are observable in production.
4. **Audit `is_superuser` call-sites** and either fully replace them with `role == UserRole.ADMIN` or document the boundary explicitly.
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.
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.
123 changes: 123 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,126 @@
# Role-Based Access Control (RBAC) Implementation

This fork extends the original [Full Stack FastAPI Template](#full-stack-fastapi-template) with role-based access control. The original template README follows below.

## Quick Start

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.

### Prerequisites

- Python 3.12
- [uv](https://docs.astral.sh/uv/) for dependency management
- PostgreSQL 18 running on `localhost:5432`
- Node.js 22 and npm (for the frontend)

### Backend Setup

1. Create a database named `app` in your local Postgres:

```sql
CREATE DATABASE app;
```

2. Copy `.env` into the backend folder so the app finds its config:

```bash
cd backend
cp ../.env .
```

Defaults in `.env` are tuned for local development. The Postgres password is `changethis`; adjust if your local Postgres uses a different one.

3. Install dependencies and apply migrations:

```bash
uv sync
uv run alembic upgrade head
```

4. Seed test users (one per role):

```bash
uv run python -m app.initial_data
```

This creates three users in the `local` environment:

| Email | Password | Role |
| ------------------- | ---------- | ------- |
| admin@example.com | changethis | admin |
| manager@example.com | changethis | manager |
| member@example.com | changethis | member |

5. Run the backend:

```bash
uv run fastapi run --reload app/main.py
```

API and Swagger UI are at <http://localhost:8000/docs>.

### Frontend Setup

In a second terminal:

```bash
cd frontend
npm install
npm run dev
```

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.

> If you change the backend's OpenAPI schema (new endpoints, new fields), regenerate the typed client:
>
> ```bash
> cd frontend
> Invoke-WebRequest -Uri "http://localhost:8000/api/v1/openapi.json" -OutFile "openapi.json" # PowerShell
> # or: curl http://localhost:8000/api/v1/openapi.json -o openapi.json # bash
> npm run generate-client
> ```

### Running Tests

```bash
cd backend
uv run pytest
```

RBAC-specific tests live in `backend/app/tests/api/routes/test_rbac.py`.

> **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.

## Permission Matrix

| Action | admin | manager | member |
| --------------------- | :---: | :-----: | :----: |
| List all users | ✓ | ✓ | ✗ |
| Create user | ✓ | ✗ | ✗ |
| View metrics | ✓ | ✓ | ✗ |
| View own profile | ✓ | ✓ | ✓ |
| Update own profile | ✓ | ✓ | ✓ |
| View any user profile | ✓ | ✗ | ✗ |
| Update any user | ✓ | ✗ | ✗ |
| Delete any user | ✓ | ✗ | ✗ |
| Delete own account | ✗ | ✓ | ✓ |

Admins cannot delete their own account to prevent locking out of the system.

## Authorization Approach

**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.

**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.

**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.

**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.

**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`.

---

# Full Stack FastAPI Template

<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>
Expand Down
49 changes: 49 additions & 0 deletions backend/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Domain
# This would be set to the production domain with an env var on deployment
# used by Traefik to transmit traffic and aqcuire TLS certificates
DOMAIN=localhost
# To test the local Traefik config
# DOMAIN=localhost.tiangolo.com

# Used by the backend to generate links in emails to the frontend
FRONTEND_HOST=http://localhost:5173
# In staging and production, set this env var to the frontend host, e.g.
# FRONTEND_HOST=https://dashboard.example.com

# Environment: local, staging, production
ENVIRONMENT=local

PROJECT_NAME="Full Stack FastAPI Project"
STACK_NAME=full-stack-fastapi-project

# Backend
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
SECRET_KEY=changethis
FIRST_SUPERUSER=admin@example.com
FIRST_SUPERUSER_PASSWORD=changethis
TEST_MANAGER_EMAIL=manager@example.com
TEST_MANAGER_PASSWORD=changethis
TEST_MEMBER_EMAIL=member@example.com
TEST_MEMBER_PASSWORD=changethis

# Emails
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
EMAILS_FROM_EMAIL=info@example.com
SMTP_TLS=True
SMTP_SSL=False
SMTP_PORT=587

# Postgres
POSTGRES_SERVER=localhost
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changethis

SENTRY_DSN=

# Configure these with your own Docker registry images
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend
33 changes: 33 additions & 0 deletions backend/app/alembic/versions/291237274644_add_role_to_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""add_role_to_user

Revision ID: 291237274644
Revises: fe56fa70289e
Create Date: 2026-05-19 22:19:06.452770

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes


# revision identifiers, used by Alembic.
revision = '291237274644'
down_revision = 'fe56fa70289e'
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
'user',
sa.Column('role', sa.String(length=32), nullable=True),
)

op.execute("UPDATE \"user\" SET role = 'admin' WHERE is_superuser = true")
op.execute("UPDATE \"user\" SET role = 'member' WHERE role IS NULL")

op.alter_column('user', 'role', nullable=False)


def downgrade():
op.drop_column('user', 'role')
14 changes: 13 additions & 1 deletion backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.core import security
from app.core.config import settings
from app.core.db import engine
from app.models import TokenPayload, User
from app.models import TokenPayload, User, UserRole

reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
Expand Down Expand Up @@ -55,3 +55,15 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user


def require_role(*allowed_roles: UserRole):
def role_checker(current_user: CurrentUser) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=403,
detail="The user doesn't have enough privileges",
)
return current_user

return role_checker
3 changes: 2 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from fastapi import APIRouter

from app.api.routes import items, login, private, users, utils
from app.api.routes import items, login, metrics, private, users, utils
from app.core.config import settings

api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
api_router.include_router(metrics.router)


if settings.ENVIRONMENT == "local":
Expand Down
29 changes: 29 additions & 0 deletions backend/app/api/routes/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends
from sqlmodel import func, select

from app.api.deps import SessionDep, require_role
from app.models import Item, MetricsPublic, User, UserRole

router = APIRouter(prefix="/metrics", tags=["metrics"])


@router.get(
"/",
dependencies=[Depends(require_role(UserRole.ADMIN, UserRole.MANAGER))],
response_model=MetricsPublic,
)
def read_metrics(session: SessionDep) -> MetricsPublic:
"""
Return basic app metrics. Accessible by admins and managers.
"""
total_users = session.exec(select(func.count()).select_from(User)).one()
active_users = session.exec(
select(func.count()).select_from(User).where(User.is_active == True) # noqa: E712
).one()
total_items = session.exec(select(func.count()).select_from(Item)).one()

return MetricsPublic(
total_users=total_users,
active_users=active_users,
total_items=total_items,
)
Loading
Loading