Skip to content

branding: no table to add a mixin to — it's a single process-global settings singleton that must become per-tenant lookup #373

Description

@antosubash

Why

Branding has no DB table of its own (service.py:1-6): the app name, colors, logo/favicon file ids and footer live entirely in one app.state.branding.settings object hydrated once from settings's SYSTEM scope, and every Inertia page (including anonymous ones) and the two public asset routes read that single process-wide object — under tenancy, "per-tenant theme" (per the design doc) means this whole read path has to change from a boot-time singleton to a per-request, per-tenant lookup; there's no mixin to adopt because there's no row per branding config today.

Tables

  • None exist in this module. Adopt MultiTenantMixin: n/a — branding piggybacks on settings.Setting (already addressed in the settings issue). The actual change needed here is: branding must start writing/reading its fields at SCOPE_TENANT (with the active tenant as scope_id) instead of exclusively SCOPE_SYSTEM, and needs a resolved per-tenant BrandingSettings instead of the single app.state.branding.settings.

Code paths

  • Everything is a process singleton today: BrandingService.current()/apply() (service.py:43-69) reads/writes self.app.state.branding.settings directly — one object, shared by every tenant in the process. branding_shared_props() (shared_props.py:65-74) is registered on app.state.inertia_shared_providers and reads that same singleton for every Inertia page render, authenticated or guest (shared_props.py:1-6 docstring) — under multi-tenancy this must resolve the request's active tenant and read a per-tenant BrandingSettings, falling back to the system/default theme when no tenant is resolved (e.g. the platform's own marketing pages).
  • _swap_asset/_reap (service.py:71-124) — logo/favicon files are file_storage ids; once file_storage.StoredFile is tenant-scoped (see that issue), a tenant's branding upload must go through that tenant's file_storage context, and _reap's self.storage.delete(uuid.UUID(file_id)) (service.py:102) needs to run under the same tenant context as the upload it's replacing, not the acting admin's.
  • Anonymous asset routes are the concrete tenant-resolution gap the design doc calls out: endpoints/assets.py (serve_logo, serve_logo_dark, serve_favicon, registered via register_public_routes at module.py:56) are unauthenticated by design and read request.app.state.branding.settings (assets.py:39-41) with no tenant resolution at all — this is exactly the "public anonymous requests currently have NO tenant resolution" gap in the design doc. A guest hitting /branding/logo.png on a multi-tenant install with per-tenant branding needs a resolver (subdomain/custom-domain, same mechanism pagebuilder will need) before this can serve the right tenant's logo instead of always the system default.
  • register_settings/on_startup-style hydration (module.py:34) presumably follows the standard register_module_settings pattern — per the framework CLAUDE.md's "Settings are read twice per boot" note, this hydration happens at boot, which is fundamentally incompatible with per-tenant values that must be resolved per request; branding needs to move from "hydrate once into app.state.branding.settings" to "resolve per request from SettingService.get_resolved_value(..., tenant_id=<active>)" for any field that becomes tenant-overridable, likely with a short-TTL cache keyed by tenant id (there is currently no cache to fix — one needs to be added correctly, tenant-keyed from the start, rather than retrofitted).

Migration

  • No new table. If/when tenant-level branding overrides are wanted, they ride on settings.Setting at SCOPE_TENANT — no separate migration in this module, but see the settings module issue for the scope_id trust bug that must be fixed first (otherwise any tenant could set another tenant's logo).
  • Decide scope of this work: system-wide branding (current behavior, no tenant resolution needed) vs. true per-tenant branding (needs the anonymous-route resolver above). The design doc lists branding as a "candidate" for per-tenant theme, not a committed decision — flag this explicitly before implementing.

Tests

  • If per-tenant branding ships: two tenants can set different logos/app names and each sees only their own via branding_shared_props on an authenticated page.
  • Anonymous asset routes serve the correct tenant's logo/favicon once a resolver exists (subdomain/domain-based), and serve the system default when no tenant resolves (bare platform domain).
  • _reap deletes the replaced file under the correct tenant's file_storage context, not the acting admin's, when they differ (e.g. a platform admin editing a tenant's branding on their behalf).
  • Regression: with multi_tenant off (single-tenant install), branding behaves exactly as today — no per-request DB hit added to every page render as a side effect of this work.

Out of scope / open questions

  • Whether branding becomes per-tenant at all in v1, or only the platform-wide login/marketing chrome stays and tenants get a separate, more limited theming surface — this is a product decision the design doc leaves open ("candidate").
  • The anonymous tenant-resolution mechanism itself (subdomain/custom-domain) is not branding's to build; it's shared infrastructure also needed by pagebuilder (smpy_modules) and should be designed once, not per-module.

Context: part of the SaaS tenancy work in #370 (fail-closed isolation, tenant_context()/all_tenants(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions