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.
No published tickets in this project yet.
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.
Clone it
$git clone https://github.com/nishant1821/FlaskDevTraining.gitHow to run it locally, step by step.
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).
DATABASE_URLADMIN_DATABASE_URLTEST_DATABASE_URLTEST_ADMIN_DATABASE_URLREDIS_URLdocker/init-db.sqlsaas_adminBYPASSRLSsaas