Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,13 @@ backend/app/frontend/
/playwright-report/
/blob-report/
/playwright/.cache/

# IDE
.idea/

# Duplicate OpenSpec tool scaffolding
.agent/
.agents/
.commandcode/
.claude/commands/opsx/
.claude/skills/openspec-*/
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Engineering rules

## Stack

- **Backend**: FastAPI + SQLModel (Pydantic + SQLAlchemy) + Alembic + Postgres. Package/venv managed with `uv` from `backend/`.
- **Frontend**: React + TanStack Router/Query + generated OpenAPI client (`@hey-api/openapi-ts`) + shadcn/radix + Tailwind. Managed with `bun` from `frontend/` (or repo root via `bun run --filter frontend <script>`).
- **Local services**: `docker compose up` runs Postgres + Mailpit (see `compose.yml`). App servers run outside Docker in dev.

## Known gotchas

- `app.main` (`backend/app/main.py`) unconditionally mounts a static frontend build at `backend/app/frontend`. That directory only exists after a frontend build is copied in (normally a Docker build step). Running `uv run fastapi dev app/main.py` in a fresh dev checkout without that directory logs a `UserWarning` but still starts — if it ever hard-fails instead, generate the OpenAPI schema from `app.api.main.api_router` directly (see `scripts/generate-client.sh` for the shape) rather than importing `app.main`.
- `scripts/generate-client.sh` requires the backend importable and a running Postgres (`docker compose up`). Regenerate the client (`bun run --filter frontend generate-client`) any time backend Pydantic/SQLModel schemas change — the frontend has no fallback typing.
- **`uv run bash scripts/test.sh` wipes all `User` and `Item` rows** in whatever database `DATABASE_URL` points to. `tests/conftest.py`'s session-scoped `db` fixture unconditionally deletes both tables at teardown. Locally this is the *same* Postgres instance/database used for interactive dev testing — there's no separate test DB in this local setup. Never run the backend test suite against a database with data you want to keep; if you just did, re-seed with `uv run bash scripts/prestart.sh` (restores roles/permissions/first-admin, but any other manually-created accounts are gone for good).

## Authorization (RBAC)

This project uses role-based access control. The design lives in `openspec/changes/add-rbac/design.md`; the human-facing writeup (permission matrix, setup, seeding) lives in `docs/AUTHORIZATION.md`. The rules below are what keep it maintainable — read them before touching any permission check.

**Single source of truth for permission codes and role grants**: `backend/app/core/rbac.py`. Every permission code and every role→permission grant is defined there once, as constants — nowhere else. The frontend mirrors the same codes as a `PERMISSIONS` constant object in `frontend/src/utils.ts`. Never hardcode a permission string (`"users:list"`, `"metrics:view"`, etc.) anywhere else in either codebase — import the constant.

**Backend checks always go through `require_permission(code)`** (`backend/app/api/deps.py`), used as a route dependency. The one exception is item-ownership fallback (`owner_id == current_user.id`) in `app/api/routes/items.py`, which stays inline because it's data-scoped, not role-scoped — everything else goes through the dependency. Never write an inline `if current_user.role.slug == "admin"` check in a route; that's exactly the pattern this replaced.

**Frontend checks always go through `hasPermission(user, code)`** (`frontend/src/utils.ts`). Never branch on `user.role === "..."` in a component — role names are for display (badges, select options), not authorization decisions.

**Unauthorized direct navigation shows the shared `AccessDenied` page** (routed at `/forbidden`), not a silent redirect to `/` and not a raw error. Every route `beforeLoad` guard redirects there on a failed permission check.

**To add a new role**: add one row to the seed data (migration + `init_db`) and one entry to `ROLE_PERMISSIONS` in `rbac.py`. No route or component should need to change.

**To add a new permission**: add one constant in `rbac.py` (and the mirrored `PERMISSIONS` entry in `utils.ts`), add it to whichever roles should have it in `ROLE_PERMISSIONS`/seed data, and use it at exactly the route(s) or component(s) it protects via `require_permission`/`hasPermission`. That's the whole change — if you find yourself editing more than the seed data, `rbac.py`, `utils.ts`, and the specific protected site(s), stop and reconsider.

**Comments**: authorization code stays comment-free by default — the constants and dependency names should make the model obvious on their own. Add a comment only where the *why* genuinely isn't visible in the code (e.g., why several unrelated checks share the `system:admin` catch-all). Don't add a comment that restates what a function or constant name already says.

## General

- No comments unless the *why* is non-obvious (see above) — this applies repo-wide, not just to authorization code.
- Don't add abstractions, config flags, or generalized helpers for a single call site. Three similar lines beat a premature abstraction.
- Keep changes scoped to what's asked; don't drive-by refactor unrelated code in the same diff.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ General development docs: [development.md](./development.md).

This includes the local FastAPI and Vite workflow, Docker Compose services, `.env` configuration, and more.

## Authorization

Role-based access control (roles, permissions, and how the frontend learns a user's capabilities): [docs/AUTHORIZATION.md](./docs/AUTHORIZATION.md).

## Release Notes

Check the file [release-notes.md](./release-notes.md).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""add rbac roles and permissions

Revision ID: 2d1ae9889b29
Revises: fe56fa70289e
Create Date: 2026-08-20 15:22:27.932585

"""
import uuid

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


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


ADMIN_ROLE_ID = uuid.uuid4()
MANAGER_ROLE_ID = uuid.uuid4()
MEMBER_ROLE_ID = uuid.uuid4()

PERMISSION_CODES = [
"users:list",
"users:create",
"users:manage",
"metrics:view",
"system:admin",
]

# role slug -> permission codes granted to it
ROLE_PERMISSIONS = {
"admin": PERMISSION_CODES,
"manager": ["users:list", "metrics:view"],
"member": [],
}


def upgrade():
op.create_table(
'permission',
sa.Column('code', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_permission_code'), 'permission', ['code'], unique=True)
op.create_table(
'role',
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_role_slug'), 'role', ['slug'], unique=True)
op.create_table(
'role_permission',
sa.Column('role_id', sa.Uuid(), nullable=False),
sa.Column('permission_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('role_id', 'permission_id'),
)
op.add_column('user', sa.Column('role_id', sa.Uuid(), nullable=True))

bind = op.get_bind()

role_table = sa.table('role', sa.column('id', sa.Uuid()), sa.column('slug', sa.String()))
permission_table = sa.table(
'permission', sa.column('id', sa.Uuid()), sa.column('code', sa.String())
)
role_permission_table = sa.table(
'role_permission',
sa.column('role_id', sa.Uuid()),
sa.column('permission_id', sa.Uuid()),
)

role_ids = {
"admin": ADMIN_ROLE_ID,
"manager": MANAGER_ROLE_ID,
"member": MEMBER_ROLE_ID,
}
bind.execute(
role_table.insert(),
[{"id": role_id, "slug": slug} for slug, role_id in role_ids.items()],
)

permission_ids = {code: uuid.uuid4() for code in PERMISSION_CODES}
bind.execute(
permission_table.insert(),
[{"id": pid, "code": code} for code, pid in permission_ids.items()],
)

bind.execute(
role_permission_table.insert(),
[
{"role_id": role_ids[slug], "permission_id": permission_ids[code]}
for slug, codes in ROLE_PERMISSIONS.items()
for code in codes
],
)

bind.execute(
sa.text('UPDATE "user" SET role_id = :role_id WHERE is_superuser IS TRUE'),
{"role_id": ADMIN_ROLE_ID},
)
bind.execute(
sa.text(
'UPDATE "user" SET role_id = :role_id '
'WHERE is_superuser IS NOT TRUE'
),
{"role_id": MEMBER_ROLE_ID},
)

op.alter_column('user', 'role_id', nullable=False)
op.create_foreign_key(
'user_role_id_fkey', 'user', 'role', ['role_id'], ['id']
)
op.drop_column('user', 'is_superuser')


def downgrade():
op.add_column(
'user',
sa.Column(
'is_superuser',
sa.BOOLEAN(),
nullable=False,
server_default=sa.false(),
),
)

bind = op.get_bind()
bind.execute(
sa.text(
'UPDATE "user" SET is_superuser = true '
'WHERE role_id = (SELECT id FROM role WHERE slug = \'admin\')'
)
)
op.alter_column('user', 'is_superuser', server_default=None)

op.drop_constraint('user_role_id_fkey', 'user', type_='foreignkey')
op.drop_column('user', 'role_id')
op.drop_table('role_permission')
op.drop_index(op.f('ix_role_slug'), table_name='role')
op.drop_table('role')
op.drop_index(op.f('ix_permission_code'), table_name='permission')
op.drop_table('permission')
21 changes: 13 additions & 8 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections.abc import Generator
from collections.abc import Callable, Generator
from typing import Annotated

import jwt
Expand All @@ -8,7 +8,7 @@
from pydantic import ValidationError
from sqlmodel import Session

from app.core import security
from app.core import rbac, security
from app.core.config import settings
from app.core.db import engine
from app.models import TokenPayload, User
Expand Down Expand Up @@ -49,9 +49,14 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
CurrentUser = Annotated[User, Depends(get_current_user)]


def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user
def require_permission(code: str) -> Callable[[CurrentUser], User]:
# Factory, not a dependency itself: `Depends(require_permission("x"))` calls
# this to build the actual per-code dependency below.
def dependency(current_user: CurrentUser) -> User:
if not rbac.has_permission(current_user, code):
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user

return dependency
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.FASTAPI_ENV == "development":
Expand Down
15 changes: 11 additions & 4 deletions backend/app/api/routes/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlmodel import col, func, select

from app.api.deps import CurrentUser, SessionDep
from app.core import rbac
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message

router = APIRouter(prefix="/items", tags=["items"])
Expand All @@ -18,7 +19,7 @@ def read_items(
Retrieve items.
"""

if current_user.is_superuser:
if rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN):
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
statement = (
Expand Down Expand Up @@ -53,7 +54,9 @@ def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) ->
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
if not rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN) and (
item.owner_id != current_user.id
):
raise HTTPException(status_code=403, detail="Not enough permissions")
return item

Expand Down Expand Up @@ -86,7 +89,9 @@ def update_item(
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
if not rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN) and (
item.owner_id != current_user.id
):
raise HTTPException(status_code=403, detail="Not enough permissions")
update_dict = item_in.model_dump(exclude_unset=True)
item.sqlmodel_update(update_dict)
Expand All @@ -106,7 +111,9 @@ def delete_item(
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
if not rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN) and (
item.owner_id != current_user.id
):
raise HTTPException(status_code=403, detail="Not enough permissions")
session.delete(item)
session.commit()
Expand Down
6 changes: 3 additions & 3 deletions backend/app/api/routes/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from fastapi.security import OAuth2PasswordRequestForm

from app import crud
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
from app.core import security
from app.api.deps import CurrentUser, SessionDep, require_permission
from app.core import rbac, security
from app.core.config import settings
from app.models import Message, NewPassword, Token, UserPublic, UserUpdate
from app.utils import (
Expand Down Expand Up @@ -99,7 +99,7 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message:

@router.post(
"/password-recovery-html-content/{email}",
dependencies=[Depends(get_current_active_superuser)],
dependencies=[Depends(require_permission(rbac.PERMISSION_SYSTEM_ADMIN))],
response_class=HTMLResponse,
)
def recover_password_html_content(email: str, session: SessionDep) -> Any:
Expand Down
22 changes: 22 additions & 0 deletions backend/app/api/routes/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from sqlmodel import func, select

from app.api.deps import SessionDep, require_permission
from app.core import rbac
from app.models import Item, MetricsPublic, User

router = APIRouter(
prefix="/metrics",
tags=["metrics"],
dependencies=[Depends(require_permission(rbac.PERMISSION_METRICS_VIEW))],
)


@router.get("/")
def read_metrics(session: SessionDep) -> MetricsPublic:
"""
Basic usage metrics. A stub — not a real analytics pipeline.
"""
user_count = session.exec(select(func.count()).select_from(User)).one()
item_count = session.exec(select(func.count()).select_from(Item)).one()
return MetricsPublic(user_count=user_count, item_count=item_count)
14 changes: 5 additions & 9 deletions backend/app/api/routes/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
from fastapi import APIRouter
from pydantic import BaseModel

from app import crud
from app.api.deps import SessionDep
from app.core.security import get_password_hash
from app.models import (
User,
UserCreate,
UserPublic,
)

Expand All @@ -26,13 +26,9 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
Create a new user.
"""

user = User(
user_create = UserCreate(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password),
password=user_in.password,
)

session.add(user)
session.commit()

return user
return crud.create_user(session=session, user_create=user_create)
Loading
Loading