Skip to content

ibmgeniuz/multitenant-project-api

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Multi-Tenant Project API

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.

Production vs. This Demo

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)

Quick Start

# 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/docs

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

Local development (without Docker)

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

Run tests

pip install -e ".[dev]"
pytest tests/ -v

Key Patterns (Decision Log)

1. Two-Tier Tenant Resolution

File: 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.

2. Declarative Workflow State Machine

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.

3. Provider Abstraction (Strategy Pattern)

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.

4. Repository-Layer Tenant Scoping

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.

5. Role-Based Access via Dependency Injection

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.

Layout

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

License

MIT

About

A multi-tenant project management API demonstrating tenant isolation, a declarative workflow state machine, pluggable notification providers, and role-based access control

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages