Skip to content

feat(backend): per-tenant isolation, my-cases library and delete-all - #22

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

feat(backend): per-tenant isolation, my-cases library and delete-all#22
upgradedev merged 1 commit into
mainfrom
feat/multitenancy-backend

Conversation

@upgradedev

@upgradedev upgradedev commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

Ports Cinemory's reviewed multitenancy design (this repo's other MIT entry) onto ClaimScene's case routes.

  • A signed-in caller's cases are isolated to their own tenants/<uid>/ storage prefix. Isolation is structural: _TenantScopedStorage prefixes every put/get/exists/get_url/delete, and its index property returns only rows already starting with that prefix, with the prefix stripped — a cross-tenant or guest lookup 404s because the row is physically absent from the list being scanned, not because a comparison rejected it.
  • The tenant id comes only from the verified Firebase ID token's uid (claimscene.auth.uid_from_authorization) — never a query param, header, or body field.
  • Guest stays byte-identical: _tenant_storage(None) / _tenant_job_storage(None) return the exact module-level objects (no wrapper), and when FIREBASE_PROJECT_ID is unset (today's production default) get_tenant returns None without inspecting the header at all.
  • A present-but-invalid credential is a 401, never a silent downgrade to guest.
  • Wired through case creation/render (sync and the async job submit + poll), and case fetch / schematic / illustration / verify / receipt.
  • New: GET /me/library (an authed tenant's own cases) and DELETE /me/data (erase everything under their prefix) — both 401 for guest. DELETE /me/data is structurally incapable of touching guest or another tenant's data: it enumerates the already-prefix-filtered index and deletes through the wrapper, so every key it can construct is tenants/<verified-uid>/....

Prerequisite fix — B2Storage._persist_index(): previously overwrote the remote index.jsonl from the local in-memory snapshot, so a concurrent writer's rows (e.g. a background render-job thread vs. a request thread) could be silently lost. Ported Cinemory's merge-on-write behaviour: re-read the remote index, union by key, and (new) drop a removed=<key> from that remote snapshot before the union so a just-deleted row is not resurrected. Added delete() to both InMemoryStorage and B2Storage (no-op on an absent key, matching S3 semantics). A residual, documented limitation carries over honestly from Cinemory: a second already-constructed B2Storage instance holding a stale in-memory index can still resurrect a just-deleted row if it puts before ever calling reload_index() — distributed locking over B2 remains out of scope by design.

Test plan

  • ruff check src tests scripts — clean
  • pytest --cov=src --cov-report=term-missing --cov-fail-under=90 tests/415 passed, 1 skipped, exit 0, 97.07% coverage (up from 94.27% before this change)
  • pytest tests/security -v — 75 passed (includes the 18 new adversarial multitenancy tests)
  • python scripts/readiness.py --min 95100.0% automatable (unaffected; only pre-existing user-gated live-deploy items remain)
  • git diff --name-only --diff-filter=M -- tests/ — empty (every existing test file is untouched; all new coverage lives in 5 new files)
  • New tests: authed happy path; cross-tenant/guest 404 on fetch, schematic, illustration, verify, receipt; /me/library shape + scoping + unreadable-manifest degrade; /me/data scope proof (other tenant's and guest data survive) + idempotency; tenant-scoped async job submit/poll incl. a different tenant polling a known job id → 404; spoofing via query param / header / body field (both unauthenticated and authenticated-as-someone-else); forged/expired/wrong-key tokens → 401 across every wired route; direct unit tests of _TenantScopedStorage (index filtering, hostile-uid sanitisation) and the guest byte-identity (is, not ==) proof with multitenancy actually configured; B2Storage/InMemoryStorage delete() + merge-on-write unit tests (concurrent-writer union, corrupt-line self-heal, delete-resurrection regression).

Notes on adaptation (not a blind port)

  • Route names /me/library and /me/data are kept identical to Cinemory's for surface parity between the two MIT entries.
  • One Cinemory test is intentionally not ported: "two tenants use the identical name independently" — ClaimScene always appends a random UUID suffix to every case id (api._prepare_render), so it wouldn't actually exercise per-tenant namespacing here (documented in the new e2e file's module docstring).
  • test_malformed_bearer_scheme_is_401_not_guest explicitly sets FIREBASE_PROJECT_ID (Cinemory's version doesn't) so it genuinely exercises the malformed-scheme AuthError → 401 path rather than incidentally passing via /me/library's own guest-401.

Ports Cinemory's reviewed multitenancy design onto ClaimScene's case
routes: an optional Authorization: Bearer <Firebase ID token> resolves a
verified uid via claimscene.auth; a _TenantScopedStorage wrapper prefixes
every storage call with tenants/<uid>/ so isolation holds by construction,
not by a checkable-and-forgettable comparison. Guest (no header, or
FIREBASE_PROJECT_ID unset) is byte-identical to before this change.

Wires the tenant dependency through case creation/render (sync and the
async job submit + poll), and case fetch/schematic/illustration/verify/
receipt. Adds GET /me/library (list an authed tenant's own cases) and
DELETE /me/data (erase everything under their prefix), both 401 for guest.

Prerequisite fix: B2Storage._persist_index() blindly overwrote the remote
index from its local snapshot, so a concurrent writer's rows (a background
render-job thread vs a request thread) could be lost. Ported Cinemory's
merge-on-write behaviour (re-read remote, union by key, drop a removed=
key before the union so a delete cannot be resurrected) and added delete()
to both the fake and B2 storage adapters.
@upgradedev
upgradedev merged commit f0fbfe7 into main Jul 29, 2026
11 checks passed
@upgradedev
upgradedev deleted the feat/multitenancy-backend branch July 29, 2026 15: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