FastAPI + SQLAlchemy + Postgres starter for a multi-organization backend. It ships the generic foundation only, ready to build a real domain on top of:
- organizations (company / workspace boundary)
- global users with organization memberships
- organization-scoped RBAC with a global permission catalog and explicit role-permission / membership-role assignment lifecycle
- membership-aware auth with sessions and refresh-token rotation
- audit logs (organization-scoped or global)
Schema source of truth: database.dbml.
Python 3.13 · uv · FastAPI · SQLAlchemy 2.0 async · asyncpg · Alembic · Pydantic v2 · PyJWT · argon2-cffi · ruff · pytest
src/
├── api/routers.py
├── config.py database.py models.py
├── dependencies.py exceptions.py main.py registry.py
├── modules/
│ ├── auth/ login/logout/refresh/me, sessions, refresh tokens
│ ├── organizations/ organization records
│ ├── memberships/ organization_memberships
│ ├── users/ global user identity
│ ├── rbac/ roles, permissions, role_permissions, membership_roles,
│ └── audit_logs/ organization-scoped audit trail
├── pagination.py
└── query_filters.py
migrations/ foundation baseline migration
scripts/seed.py demo organization + owner role/membership seed
scripts/clear_database.py destructive local data reset helper
tests/ auth and foundation CRUD/RBAC/audit tests
src/registry.py imports every active model module so Base.metadata is complete for
the app, Alembic, tests, and seed.
uv sync
cp .env.example .env
# Set AUTH_JWT_SECRET in .env to a newly generated secret before starting the app.
createdb <your-db-name> # match DATABASE_URL in .env
make migrate
make seed
make runmake run serves the API on port 8003 with autoreload. GET /health (unversioned)
returns environment, app name, and version.
The seed script creates one organization, the full permission catalog, an Owner role, one owner user, and an active membership. Defaults (override via env vars):
ORGANIZATION_CODE=demo(also accepts legacyTENANT_CODE)ORGANIZATION_NAME="Demo Organization"(also accepts legacyTENANT_NAME)[email protected](aliases:USER_EMAIL)ADMIN_PASSWORD=ChangeMe123!(aliases:USER_PASSWORD)USER_NAME,USER_PHONEoptional for the seeded owner user
Log in with:
POST /api/v1/auth/login
{
"organization_code": "demo",
"identifier": "[email protected]",
"password": "ChangeMe123!"
}Auth routes: POST /api/v1/auth/login, POST /api/v1/auth/refresh, POST /api/v1/auth/logout,
GET|PATCH /api/v1/auth/me, and PATCH /api/v1/auth/me/change-password.
Use the returned bearer token for /api/v1/organizations, /api/v1/users,
/api/v1/memberships, /api/v1/roles, /api/v1/permissions, /api/v1/role-permissions,
/api/v1/membership-roles, and /api/v1/audit-logs.
Access tokens carry sub (user id), organization_id, and membership_id.
Refresh requests must also select an organization by organization_id or
organization_code; the service verifies an active membership before issuing a new
organization-scoped access token.
| Command | What it does |
|---|---|
make install |
install dependencies (uv sync) |
make run |
API with autoreload (port 8003) |
make migrate |
alembic upgrade head |
make makemigration m="msg" |
autogenerate a migration |
make downgrade |
revert the last migration |
make seed |
seed demo organization + owner user |
make clear-db confirm=yes |
truncate app tables, keep migrations |
make test |
run tests |
make lint / make format |
ruff check + format |
- UUID primary keys with server-side
gen_random_uuid(). - Organization-owned tables carry
organization_id; protected routes derive organization and membership context from the JWT rather than from client-supplied parameters. GET /organizationslists only the organization in the current JWT (not a global directory). There is noPOST /organizationsroute; usemake seedor direct DB setup for the first org. The seededorganizations.createpermission has no matching route yet.POST /usersprovisions a global user in the current organization (optionalrole_ids). There is no update or delete on/users; profile changes use/auth/me.- In production (
ENVIRONMENT=production),CORS_ORIGINScannot include*. - Incoming
x-request-idandx-trace-idheaders (plus client IP and user agent) feed audit log correlation when present. - Login requires
organization_codeororganization_id, plus an email-or-phone identifier and an active membership in that organization. - Enums are varchar-backed
StrEnumvalues (seedocs/adr/0001-varchar-backed-enums.md). - Lifecycle uses explicit state fields like
statusandis_active, plus optionaldeleted_attimestamps where the schema defines them — not a global ORM soft-delete filter (seedocs/adr/0002-automatic-soft-delete-filter.md). - Refresh tokens are opaque, stored hashed, rotated on use (with parent/reuse tracking), and revoked on logout or password change. Sessions are first-class rows, and refresh token lifetime never exceeds the parent session lifetime.
/usersis an organization-scoped management view. A user owns their global identity changes through/auth/me; organization administrators manage access through/membershipsinstead of mutating or deleting the global identity.- Audit logs are read-only through the API and enforced append-only by PostgreSQL triggers. Request correlation, IP address, user agent, actor snapshots, and assignment provenance are recorded where available.
- RBAC role permissions and membership roles can be managed either through full role / membership replacement payloads or explicit assignment endpoints. Assignment endpoints validate active memberships/roles, reject duplicates, and audit assign/revoke actions.
- List endpoints share one query contract for search, filters, and sorting (see
docs/adr/0006-list-query-contract.md).
This repo intentionally stops at the foundation layer. To build a real domain on top:
- Add a new package under
src/modules/<your_module>/following the existing shape (models.py,schemas.py,service.py,router.py,exceptions.py). - Register its models in
src/registry.pyand its router insrc/api/routers.py. - Add its permission module name to
FOUNDATION_PERMISSION_MODULES(or a new constant) insrc/modules/rbac/constants.pyif it should be permission-gated like the rest. - Write an Alembic migration for its tables (
make makemigration m="add <thing>"). - Extend
CONTEXT.mdwith the new domain vocabulary as you introduce it.
See docs/adr/ for the architectural decisions behind the current shape, and
FASTAPI_BEST_PRACTICES.md for FastAPI/SQLAlchemy patterns this codebase follows.