A multi-tenant project management API demonstrating tenant isolation, a declarative workflow state machine, pluggable notification providers, and role-based access control — built with FastAPI, PostgreSQL, and Docker.
This repository is the companion demo for the case study Engineering Tenant Isolation in a SaaS Backend. It showcases the architectural patterns described in the case study within a project management domain.
| Concern | Production | This demo |
|---|---|---|
| Database | Managed BaaS with integrated auth and storage | PostgreSQL 16 + SQLAlchemy 2 + Alembic |
| Authentication | BaaS JWT (JWKS / HS256, managed key rotation) | PyJWT with HS256 shared secret |
| Tenant resolution | Header-based with membership verification | Same pattern — X-Tenant-ID / X-Tenant-Slug headers |
| Workflow engine | Business-specific state machine with escrow semantics | Declarative transition table for project approval workflow |
| External integrations | Multiple provider adapters (strategy pattern) | Email / Slack / In-App notification providers (same pattern) |
| Role system | 5+ domain-specific roles | 4 roles: owner / manager / member / viewer |
| Observability | Prometheus metrics + structured logging | Standard Python logging |
| Deployment | Kubernetes (Deployment + ConfigMap) | Docker Compose (local) |
# Clone and start
git clone https://github.com/ibmgeniuz/multitenant-project-api.git
cd multitenant-project-api
cp .env.example .env
docker compose up -d
# Run migrations
docker compose exec api alembic upgrade head
# Seed a default tenant (one-time)
docker compose exec api python -c "
from app.core.database import SessionLocal
from app.models.tenant import Tenant
db = SessionLocal()
if not db.query(Tenant).filter(Tenant.slug == 'default').first():
db.add(Tenant(name='Default Org', slug='default', config={'default_notification_channel': 'in_app'}))
db.commit()
print('Default tenant created.')
else:
print('Default tenant already exists.')
db.close()
"
# API is now at http://localhost:8000
# Interactive docs at http://localhost:8000/docsIf alembic upgrade head reports No 'script_location' key found, the API image is missing Alembic files. Rebuild with docker compose build --no-cache api (the Dockerfile copies alembic.ini and migrations/ into the image).
If /v1/auth/register returns 409 with password cannot be longer than 72 bytes while using a short password, that was a passlib + modern bcrypt incompatibility in older images. Current code hashes passwords with the bcrypt package directly (app/core/password.py). Rebuild the API image after pulling.
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# Ensure PostgreSQL is running and DATABASE_URL is set in .env
alembic upgrade head
uvicorn app.main:app --reloadpip install -e ".[dev]"
pytest tests/ -vFile: app/core/dependencies.py
Public routes use get_tenant_id_from_request — resolves tenant from headers with no auth required, defaulting to a "default" slug. Authenticated routes use get_current_tenant_id — same resolution plus a membership check against tenant_members. This separation means catalog-style endpoints work without login while data-mutating endpoints enforce tenant boundaries.
Why this over a global middleware: FastAPI's dependency injection makes tenant context explicit in route signatures. A middleware would hide the tenant context and make it harder to audit which routes are tenant-scoped. The DI approach is more verbose but makes cross-tenant leaks structurally detectable in code review.
File: app/services/workflow.py
The approval workflow is defined as a transition table — a dictionary mapping (current_status, target_status) to the minimum UserRole required. The transition() function validates the state change and role in two lines. No if-else trees, no ORM callbacks.
Why a table over if-else: The transition table is auditable (one glance shows all valid paths), exhaustively testable (iterate the table), and explainable to non-engineers. Adding a new status or transition is a one-line change that's impossible to introduce without updating tests.
File: app/providers/base.py, app/services/notification_service.py
NotificationProvider is a Python Protocol with a single send() method. Three adapters implement it: EmailProvider (logs to console), SlackWebhookProvider (real HTTP call), InAppProvider (persists to DB). The NotificationService resolves the right provider at runtime from tenant config.
Why Protocol over ABC: Structural typing means test doubles don't need to inherit from anything — any object with a matching send() method works. This keeps test fixtures simple and avoids import cycles between the provider interface and its implementations.
File: app/repositories/project_repository.py
Every repository method that reads or writes tenant data requires an explicit tenant_id parameter. There is no way to call get_by_id() or list() without specifying which tenant's data you want. This is the real security boundary — even if a route guard has a bug, the repository can't return another tenant's data.
File: app/core/dependencies.py
require_role() is a factory that returns a FastAPI dependency. Routes declare their role requirement in the decorator: dependencies=[Depends(require_role(UserRole.OWNER))]. The dependency chain resolves the JWT, the tenant, the membership, and the role — all before the route handler runs.
multitenant-project-api/
├── app/
│ ├── api/v1/ # Route handlers (thin, delegate to services)
│ │ ├── admin.py # Owner-only: tenants, stats, activity logs
│ │ ├── auth.py # Register, login (JWT)
│ │ ├── comments.py # CRUD comments on tasks
│ │ ├── notifications.py # List, mark read
│ │ ├── projects.py # CRUD + workflow transition endpoint
│ │ ├── router.py # v1 router aggregator
│ │ └── tasks.py # CRUD tasks within projects
│ ├── core/ # Framework plumbing
│ │ ├── auth.py # JWT creation + validation
│ │ ├── config.py # Pydantic Settings
│ │ ├── database.py # SQLAlchemy engine + session
│ │ └── dependencies.py # Tenant resolution, role guards, DI factories
│ ├── models/ # SQLAlchemy ORM models
│ ├── providers/ # Notification channel adapters
│ │ ├── base.py # NotificationProvider Protocol
│ │ ├── email_provider.py
│ │ ├── in_app_provider.py
│ │ └── slack_provider.py
│ ├── repositories/ # Data access (tenant-scoped queries)
│ ├── schemas/ # Pydantic request/response models
│ └── services/ # Business logic + workflow engine
│ └── workflow.py # Declarative state machine
├── migrations/ # Alembic migrations
├── tests/ # Unit tests (workflow + tenant resolution)
├── Dockerfile # Multi-stage Python build
├── docker-compose.yml # App + PostgreSQL
├── pyproject.toml # PEP 621 project metadata
└── alembic.ini
MIT