A Flask multi-tenant SaaS reference app where Postgres Row-Level Security -- not an app-level `WHERE tenant_id = ...` -- is the actual isolation boundary, plus subdomain tenant resolution, per-tenant RBAC, an append-only audit trail, and idempotent Stripe subscription billing.
Nobody has started this yet — be first.
Tenant cache entries live ~1000x longer than configured
`TENANT_CACHE_TTL_SECONDS` exists to bound how long a resolved tenant/settings lookup can be stale before Redis forgets it and the next request falls back to Postgres for the truth. Support and on-call treat that number as a real upper bound -- "worst case, a change takes effect within ~30 seconds." Right now every cache write actually sets a Redis expiry about 1000x longer than configured (default `30` becomes `30000` seconds -- roughly 8.3 hours). Nothing currently depends on that bound being tight, since settings writes explicitly invalidate the cache -- but the TTL is the only backstop if that invalidation is ever missed, skipped by a crash between commit and cache-clear, or bypassed by a future direct-database operator action. If that ever happens, whoever is debugging "why is this tenant showing stale data" will be told by the code and the docs that the answer is "wait 30 seconds" -- and it will actually be most of a business day.
About this project
A ~3k LOC Flask reference implementation of the parts of multi-tenant SaaS that are easy to get subtly wrong. Every tenant-scoped table (, , , ) has Postgres Row-Level Security enabled, with policies that compare against a session-local GUC () set at the start of every request -- so isolation holds even if a handler forgets a clause entirely, which is exactly what the project's own signature test () proves by issuing a bare primary-key lookup against a row that genuinely belongs to a different tenant. Two Postgres roles enforce this in practice: (no , what every ordinary request handles as) and (, used only for schema migrations and one narrow cross-tenant Stripe-object lookup inside the webhook handler, where there is no subdomain to bind a tenant from). A tenant is resolved from the header's subdomain ( -> ) before any tenant-scoped query runs, with an override honored only when (dev/test only -- production trusts only the Host header the reverse proxy forwards). RBAC sits on top as three roles (owner/admin/member) mapped to permissions in a single table, never scattered checks; every sensitive mutation writes exactly one row to an append-only audit log in the same transaction as the change it describes; and Stripe billing is wired through webhooks that verify the signature against the untouched raw request bytes (never a re-serialized JSON dict, which would break the signature) and de-duplicate via a unique column, so a webhook Stripe legitimately delivers twice -- at-least-once delivery is a documented guarantee, not an edge case -- still only takes effect once.
/health/db reports healthy without ever touching the database
Orchestrators (Kubernetes readiness probes, a load balancer's health check, Docker Compose's `depends_on: condition: service_healthy` for downstream services) need to know "can this instance actually serve traffic right now," not just "is the Python process alive." `/health` already answers the second question. `/health/db` exists specifically to answer the first -- but right now it always returns `200 {"status": "ok"}` regardless of whether Postgres is reachable. If the database is down, degraded, or the connection pool is exhausted, every instance keeps reporting healthy, traffic keeps getting routed to it, and every request fails with a 500 instead of the orchestrator quietly routing around the problem.
tenant_settingsmembershipsaudit_logssubscriptionstenant_idcurrent_setting('app.current_tenant_id', true).filter_by(tenant_id=...)test_cross_tenant_row_invisible_even_with_no_app_level_filterdb.session.get()saasBYPASSRLSsaas_adminBYPASSRLSHostacme.yoursaas.comacmeX-Tenant-SlugTRUST_TENANT_HEADER=trueif role ==stripe_event_idFifteen practice tickets are injected into this otherwise-working codebase: nine are real bugs -- a unit-mismatch cache TTL, an inverted RBAC permission, swapped comparison operators in a date filter, a stub handler ignoring its own path parameter, a JSON-re-serialization bug that rejects every real Stripe signature, a client-supplied field trusted where only the resolved tenant should be, two missing "at least one owner must remain" invariants reachable through two different routes, a single dropped NULLIF(...) in the RLS policy SQL that turns "nobody logged in yet" into a crash at the front door, and a check-then-act race in Stripe webhook idempotency that turns a benign duplicate delivery into an unhandled 500 -- and six are half-built enhancements: a gauge nothing sets, an endpoint that always 501s, a query result nobody reads, a permission field that exists but is never enforced, an API field wired to a hardcoded constant with no backing column at all, and an authorization decorator that already exists in the codebase but has never been reached for. Ordered easy to hard, each ticket has its own currently-failing pytest test that goes green when (and only when) that ticket is fixed; no solutions are given anywhere in the ticket docs. Several of the injected bugs are real enough that they also break tests in the project's own main suite (its own RBAC, tenant-spoofing, and webhook tests among them) -- expected, not a second problem to chase separately, since fixing the ticket fixes both. The two hardest tickets are a genuine Postgres session-GUC gotcha (TICKET-12, reproduced by literally deleting the NULLIF the project's own README spends a whole section explaining) and a genuine, thread-forced concurrency race in Stripe webhook processing (TICKET-13).
500NULLIF*.localhost127.0.0.1/etc/hostscurl -s -X POST http://acme.localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
--cookie-jar /tmp/acme.cookies --cookie /tmp/acme.cookies \
-d '{"email": "owner@acme.test", "password": "owner-acme"}'
curl -s http://acme.localhost:5000/api/tenant/settings --cookie /tmp/acme.cookies
For tooling that cannot fake subdomains (some HTTP clients, CI), send X-Tenant-Slug instead -- honored only when TRUST_TENANT_HEADER=true (the Docker dev setup enables it; production does not):
curl -s http://localhost:5000/api/tenant/settings -H "X-Tenant-Slug: acme"
You do not strictly need Docker to work the tickets, but -- unlike a project that can fake its backing services -- this one genuinely needs a real Postgres and Redis, because the whole point is proving isolation against the real database, not a mock. Without Docker: cp .env.example .env, point DATABASE_URL/ADMIN_DATABASE_URL/TEST_DATABASE_URL/TEST_ADMIN_DATABASE_URL/REDIS_URL at a local Postgres/Redis (docker/init-db.sql documents the exact roles/databases the app expects -- notably the saas_admin/BYPASSRLS role migrations need and the plain saas role every request uses), then:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/flask db upgrade
.venv/bin/pytest practice_tickets/tests/test_ticket_07_webhook_signature_raw_body.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
./practice_tickets/run_tickets.sh 07 12 13 # just a few, by number
All 15 tests fail at the start; as you fix each ticket in app/ (or, for TICKET-12, in migrations/versions/0002_row_level_security.py, plus re-applying it), its test -- and only its test -- should flip to passing. make test runs the project's own main suite; several of its tests (RBAC, tenant-spoofing, and billing-webhook tests among them) fail at the start for the same underlying reasons as specific tickets -- fixing those tickets fixes those failures too, not a separate task.
How to run it locally, step by step.