Skip to content

feat(file_storage): adopt MultiTenantMixin on StoredFile (#383) [stack 9/11] - #393

Merged
antosubash merged 9 commits into
tenancy/08-background-tasksfrom
tenancy/09-file-storage
Oct 1, 2026
Merged

antosubash merged 9 commits into
tenancy/08-background-tasksfrom
tenancy/09-file-storage

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Closes #383 (which replaces #361). Stack 9/11 of the tenancy-adoption series (base: #392). This is the first core module to adopt MultiTenantMixin.

What

  • StoredFile adopts MultiTenantMixin. The unique index is now (tenant_id, key) (SM024 is clean).
  • Migration c7f2d9a41e83: adds the column as nullable, backfills it with DEFAULT_TENANT_ID, makes it NOT NULL, swaps the unique index and adds a tenant_id index. The downgrade reverses all of it. Both directions are tested against a real Alembic run on SQLite with existing rows.
  • New storage keys look like {tenant_id}/YYYY/MM/DD/<uuid><ext>. Existing rows keep their keys, which hold the full object path. The owning tenant is decided before the bytes are written, so a strict-mode upload with no tenant fails without leaving an orphaned object.
  • Reads: get is now a tenant-filtered select instead of db.get, which avoids the identity-map caveat. Cross-tenant get, download and delete return 404, and bulk delete skips ids the caller can't see.
  • Aggregate cache: one slot per tenant. A write drops only its own tenant's slot.
  • Platform files (file_storage/scope.py, platform=True): owned by the default tenant. Branding uses them, which keeps anonymous logo, dark-logo and favicon fetches working. Only rows whose owner is the platform are readable this way, and the file id comes from SYSTEM-scope settings, never from the request. This is a stopgap until branding goes per-tenant in layer 11.
  • Audit-log label resolver: reads across tenants deliberately (all_tenants=True), because that screen is a platform screen.

⚠️ Behaviour change

Permissions. Tenant member gets upload and download; tenant admin and owner also get delete. The platform user role loses file_storage.delete, because every account holds user and keeping it would hand delete to every tenant member. This affects single-tenant installs too: there, only admins can delete.

Tests

  • Coverage:
    • cross-tenant 404s
    • the same key in two tenants
    • per-tenant aggregates and cache isolation
    • role grants
    • single-tenant uploads stamped default
    • strict mode with no tenant raises before any write to the storage backend
    • migration upgrade and downgrade
    • branding platform files
  • Results:
    • file_storage, branding, audit_log and tenants suites: 442 passed
    • make test-js: 463 passed
    • make lint and make doctor: clean
  • Postgres (make test-py-pg) has not been run yet.

Follow-ups

  • Re-keying objects written before this change needs a separate data job.
  • users_access_token.expires_at has SQLite drift. It predates this change and is unrelated.

https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV

StoredFile is now tenant-scoped: get/download/delete of another tenant's id
404s, listings, facets and bucket totals count only the active tenant, and the
unique key index widens to (tenant_id, key) (SM024 clean). New storage keys are
prefixed with {tenant_id}/; existing rows keep their stored key. The owning
tenant is settled before any bytes reach the backend, so a strict install with
no tenant fails closed without orphaning an object.

The browse-screen AggregateCache is keyed by tenant; a commit drops only the
slots of the tenants it wrote, plus the unscoped slot.

Permissions: upload/download on tenant member/admin/owner, delete on tenant
admin/owner only. The platform `user` role keeps upload/download but no longer
carries delete (every account holds it, which would hand delete back to every
member). The Files menu entry is visible to tenant roles.

Platform files: branding uploads, serves and reaps its images with
platform=True — owned by PLATFORM_TENANT_ID (= DEFAULT_TENANT_ID), looked up
under all_tenants() but restricted to that owner. Anonymous logo/favicon routes
keep working with no tenant bound, and a branding setting pointed at a
tenant's file id 404s instead of publishing it. The audit-log label resolver
names files across tenants deliberately (platform screen; entries already
record the filename).

Migration c7f2d9a41e83 (extends mainline b5d3f08a6e17): add tenant_id
nullable, backfill DEFAULT_TENANT_ID, NOT NULL, swap unique index, add
tenant_id index; downgrade reverses.

Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3e0e4d1
Status: ✅  Deploy successful!
Preview URL: https://d2e08c46.simple-module-python.pages.dev
Branch Preview URL: https://tenancy-09-file-storage.simple-module-python.pages.dev

View logs

…m scope narrow (review of #383)

(a) PLATFORM_TENANT_ID = "platform" in simple_module_db, distinct from
    DEFAULT_TENANT_ID. is_valid_tenant_id refuses it, so nothing can bind
    it; HostSettings.default_tenant and tenants (slug, derived slug, Tenant.id)
    refuse it too. Migration c7f2d9a41e83 is edited in place - it is
    unreleased, no deploy has run it - to back-fill only the files the
    SYSTEM-scope branding settings reference into the platform owner and
    everything else into DEFAULT_TENANT_ID.
(b) platform_scope flushes the session's pending writes before entering
    all_tenants() and disables autoflush inside, so unrelated writes are
    still stamped/guarded with the bound tenant. delete() runs read+write
    in one scope. owning_tenant falls back to the install's fallback
    tenant like the flush guard.

Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
…stalls (ship review)

With multi_tenant off the install is the only tenant, so on_startup maps
file_storage.delete onto the platform user role (as dashboard does). With it
on the grant stays on the organisation-admin roles.

Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
# Conflicts:
#	modules/tenants/tenants/models.py
@antosubash
antosubash added this pull request to stack #397 October 1, 2026 16:14
@antosubash
antosubash marked this pull request as ready for review October 1, 2026 16:16
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-10-01T16:21:13.617412Z 893b65d Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@antosubash
antosubash merged commit a4ad830 into main Oct 1, 2026
13 of 25 checks passed
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.

file_storage: adopt MultiTenantMixin on StoredFile (tenant-prefixed keys, per-tenant aggregates)

1 participant