Skip to content

feat(backend): optional per-user multitenancy (tenant-scoped storage + data control) - #36

Merged
upgradedev merged 1 commit into
mainfrom
feat/multitenancy-backend
Jul 28, 2026
Merged

feat(backend): optional per-user multitenancy (tenant-scoped storage + data control)#36
upgradedev merged 1 commit into
mainfrom
feat/multitenancy-backend

Conversation

@upgradedev

@upgradedev upgradedev commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds optional per-user multitenancy to the backend. A signed-in caller's uploads and reels are isolated to their own tenant, and they can list and delete their own data. Guests (no login) behave exactly as before — this is the security-sensitive part, so it is reviewed before merge.

  • get_tenant: a FastAPI dependency that resolves a verified Firebase ID token (Authorization: Bearer <token>) to a tenant id, or None for guest. A present-but-invalid/expired/malformed token is a hard 401 — never a silent downgrade to guest. FIREBASE_PROJECT_ID unset means multitenancy is off entirely: every request is guest, even one carrying a header.
  • _TenantScopedStorage: a prefixing wrapper over the existing StorageBackend — every key is prefixed with tenants/<uid>/ on write/read, and index returns only rows under that prefix (stripped). A cross-tenant or guest lookup 404s because the row is physically absent from the scanned index, not because a check rejected it.
  • Every reel route, the async job submit/poll store, and two new self-service routes (GET /me/library, DELETE /me/data) are wired through it.
  • StorageBackend.delete added additively to FakeStorage and B2Storage; a delete/merge-on-write interaction bug in B2Storage's index persistence (an instance's own delete could resurrect its row via the existing union-merge) is fixed with a removed= parameter on _persist_index.

Guest behavior is provably unchanged: every route computes the tenant-scoped object as _tenant_storage(tenant) / _tenant_pipeline(tenant), both of which return the exact pre-existing module-level _storage / _pipeline objects when tenant is None — not new instances, the same objects, so every existing guest code path (including tests that monkeypatch api._storage/api._pipeline directly) is byte-identical.

Test plan

  • Full existing suite passes unmodified — git diff --stat -- tests/ shows zero existing test files touched; all 4 new test files are additive.
  • ruff check src tests scripts — 0 issues (verified against ruff 0.16.0, matching CI's resolved version).
  • pytest tests/unit — 157 passed (was ~148 pre-PR; +9 new: test_fake_storage_delete.py, test_b2_storage_delete.py).
  • pytest tests/integration — 98 passed (+25 new: test_api_multitenancy.py).
  • pytest tests/e2e — 59 passed, unaffected.
  • pytest tests/security — 62 passed (+18 new: test_multitenancy_isolation.py).
  • Combined coverage gate (--cov-fail-under=90, matching CI's 3-stage accumulation) — 97.08% (up from ~97%, comfortably above the 90% floor; api.py itself at 98%).
  • python scripts/readiness.py --min 95 — 100.0% automatable, unaffected.
  • Adversarial proofs: cross-tenant reel leak (404, both directions), guest cannot see tenant data (404), DELETE /me/data cannot reach outside its own prefix (tenant B + guest reels survive), forged/garbage/expired/wrong-key tokens → 401 on all 10 wired routes (never guest), tenant cannot be spoofed via query param / X-Tenant header / body field, FIREBASE_PROJECT_ID unset treats a valid bearer header as guest (the additive-default proof).

…+ data control)

A signed-in caller's uploads/reels are isolated to their own tenants/<uid>/
storage prefix, enforced by construction via a prefix-filtering storage
wrapper rather than a per-lookup check. Guest behavior (no Authorization
header, or FIREBASE_PROJECT_ID unset) is unchanged.

- get_tenant: FastAPI dependency resolving a verified Firebase ID token to
  a tenant id, or None for guest; a present-but-invalid token is a hard 401,
  never a silent guest downgrade
- _TenantScopedStorage: composes over any StorageBackend, prefixing every
  key and exposing an index filtered+stripped to the caller's own prefix,
  so a cross-tenant lookup 404s because the row is physically absent
- every reel route, the async job store, and two new self-service routes
  (GET /me/library, DELETE /me/data) are wired through it
- adds StorageBackend.delete to FakeStorage and B2Storage (additive), and
  fixes a delete/merge-on-write interaction in B2Storage's index persistence
@upgradedev
upgradedev merged commit 33778a5 into main Jul 28, 2026
12 checks passed
@upgradedev
upgradedev deleted the feat/multitenancy-backend branch July 28, 2026 00:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant