tts-voice-creator-clone-and.../architecture/multi-tenant-and-identity.md
mARTin-B78 1a2ea421c4
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
fix(engines): resolve LiteLLM card's own key server-side instead of a stale global one
Connect and "Use as LLM" on the LiteLLM Gateway card now resolve that
card's own saved key (engine_api_keys[name]) server-side in
routes/docker.py, ahead of the old global llm_api_key fallback. The
browser can no longer reliably forward a saved key at all -- it's
masked to "__configured__" in every /api/settings response the moment
it's saved -- so relying on a frontend-forwarded header alone left
Connect silently using a stale global key after LiteLLM's own key was
rotated, surfacing a misleading /api/version 404 instead of the real
401.

"Use as LLM" now goes through a dedicated /use-as-llm endpoint that
saves the exact credential Connect just verified (llm_url normalized
to .../v1, llm_api_key copied from the card), rather than a
separately-typed value that could drift again.

Also documents per-user provider-key storage as an explicit item for
the deferred multi-user/identity planning pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:58:54 +02:00

10 KiB

Multi-tenant and identity — deferred, cross-app initiative

This is a deferred, cross-app initiative — see TODO.md's "Deferred — public multi-user readiness" section. Do not start implementation without a fresh planning pass: it depends on a shared identity service that does not exist yet in any app, and touches almost every data path in this repo.

Why this exists

The user asked what it would take to make this app ready for public, multi-user use. Three research passes over this codebase established that it is currently single-tenant and LAN-trusted end to end, with no accounts of any kind. The user then redirected the auth-backend decision specifically: rather than building a login system scoped to this app alone, they want one shared, interconnected login/user-profile system across all their self-hosted apps (this app, OurFlashcards, and others still to come), so a person's profile is one thing, not N per-app copies. That is a decision for the whole app portfolio, not this repo, so it is recorded here rather than implemented.

One policy decision was made and should carry forward unchanged into whatever implementation eventually happens: voice cloning must require a consent attestation with an audit trail once accounts exist (see below).

Current state (as of 2026-09-07)

1. No multi-tenancy exists anywhere

Everything hangs off one process-wide CONFIG_DIR (core/constants.py, default /home/app/.config/tts-voice-creator):

  • settings.json — one global settings dict, loaded/saved by core/config.py's _load_settings()/_save_settings(), cached in a single module-level variable. No per-user key anywhere in this path.
  • tts_creator.db — a real SQLite database (core/database.py, WAL mode, explicitly commented as "designed for easy migration to PostgreSQL/Supabase later"). Tables: characters, rehearsals, reader_scripts, app_state, schema_migrations, generation_history, background_jobs, voice_index. None have a user_id/owner/tenant column. characters is keyed by book, rehearsals by title — nothing scopes a row to a caller.
  • Voice library — one global voices_scan_dir setting (default /voices), read throughout routes/library.py; active/output voices resolved via core/voice.py's _hidden_voices_dir/_active_voices_dir.
  • Magazines/books — flat filesystem tree at CONFIG_DIR/reader_library/<id>/ (routes/reader.py, routes/magazine.py); id is a document id, not a user id.
  • Generated audio — CONFIG_DIR/line_audio_cache/, CONFIG_DIR/audiobook_exports/, CONFIG_DIR/background-artifacts/ — all global, unscoped.
  • background_jobs (core/database.py) has no owner column either — background_job_list() returns every job process-wide.
  • Provider API keys (llm_api_key, groq_api_key, elevenlabs_api_key, engine_api_keys etc.) live in the same global settings.json, one value per provider for the whole install — not per user. Today that's protected by gitignore + file mode 0600 and never sent back to the browser once saved (masked as "__configured__" in every /api/settings response, routes/settings.py). That's adequate for one trusted operator; it is not a multi-user secret store — there's no way for two accounts to each hold their own key for the same provider, and no per-key access control. When this gets planned: each user's provider keys should be scoped under their identity-service user id, not global settings keys, with the same "never echo a saved value to the browser" rule carried forward.

2. The browser UI has no authentication at all

ApiKeyGateMiddleware (server.py:273-330) is the only inbound auth mechanism, and it only applies to non-browser callers:

  • A request is treated as "browser, same-origin" (and let through with no key check whatsoever) if its Sec-Fetch-Site/Origin/Referer matches Host (server.py around line 304-308).
  • Everything else (scripts, external API callers, /mcp) must send X-API-Key or Authorization: Bearer <key> matching one single shared secret, external_api_key, lazily generated and stored in the same global settings blob (core/config.py, _ensure_external_api_key; regeneration via routes/settings.py).
  • There is no session, cookie, or login system anywhere. This is a "gate the robots, trust the browser" design that only makes sense when every browser on the network is already trusted — i.e. today's LAN deployment. It is the hard blocker for any public exposure, independent of every other gap below.

3. Concurrency is one global pool, not per-user

  • TTS: asyncio.Semaphore(2) (routes/tts.py:60, _TTS_GENERATION_SLOTS) — 2 concurrent TTS jobs for the entire service, 30s wait then HTTP 429.
  • STT: asyncio.Semaphore(1) (routes/stt.py:29) — 1 concurrent transcription, service-wide.
  • Image/portrait generation: threading.BoundedSemaphore(2) (routes/conversation.py:67).
  • No dedicated vision-OCR concurrency slot; OCR rides the general LLM/provider path.
  • MutationRateLimitMiddleware (server.py:96-140) is already keyed by (client_ip, is_destructive) — this is the one piece of infrastructure already shaped like a per-caller limiter and would be the natural place to swap the key to (user_id, is_destructive) once accounts exist.
  • No per-caller quota or fairness mechanism exists today. One user submitting many requests can exhaust the global slots and 429 every other user. TODO.md's existing P0 resource-limit entries describe this as "per user" in wording but the implementation is a single process-global semaphore — that gap needs closing for real multi-user hosting.
  • The only guardrail anywhere is a documentation sentence (docs/USER_MANUAL.md:284): "Always review licensing and consent before using a voice outside personal testing." Nothing in the app enforces, prompts for, or records this.
  • No consent-capture UI, no click-through attestation, no per-generation audit trail linking a clone/generation to who requested it.
  • generation_history (core/database.py) has columns id, kind, target_id, prompt, provider, status, result, created — no identity column — and deliberately discards the actual prompt (safe_prompt = "") and strips prompt/text-like keys from result for data minimization. Good for secret-leakage avoidance, but it means today's history can't answer "who generated this."

5. No edge security layer

No CORSMiddleware, no CSRF token, no CSP/HSTS/X-Frame-Options anywhere in server.py. SSRF protection (core/validation.py's _validate_http_url) exists but most call sites pass allow_private=True since they intentionally target LAN TTS/OCR backends — correct for today's trust model, would need tightening/scoping by caller for internet exposure. No TLS termination or reverse proxy exists in this repo; port 7890 is published directly.

Container-level hardening is solid and should be kept unchanged: the app runs as a non-root user, read_only: true, cap_drop: ALL, no-new-privileges, with a locked-down docker-socket-proxy sidecar (docker-compose.yml). None of that needs to change for multi-tenancy — it's orthogonal to the identity/data-isolation gap.

6. Prior art already exists — reuse it

The NUC (correspondence-nuc, 192.168.178.19) already runs a working, self-hosted Supabase-style identity stack for OurFlashcards: OurFlashcards-Auth (Supabase GoTrue), OurFlashcards-PostgresSQL, OurFlashcards-DataAPI (PostgREST), fronted by nginx gateways. core/database.py in this repo is already explicitly written to ease a future Postgres/Supabase migration. This is the natural foundation for a shared cross-app identity service — a separate GoTrue+Postgres instance (or a shared one, scoped correctly) that every self-hosted app, including this one, validates sessions/JWTs against — rather than inventing a new per-app auth system.

Target design sketch (not yet decided in detail — for the next planning pass)

Once a shared identity service exists across the app portfolio:

  • Data model: add user_id/owner_id columns to every table in core/database.py (characters, rehearsals, reader_scripts, generation_history, background_jobs, voice_index), with migrations that backfill existing rows to a single "legacy/admin" account so nothing is lost.
  • Storage layout: per-user subdirectories under voices/output/ reader_library/audio-cache instead of the single global paths in core/constants.py/core/voice.py.
  • Settings: a per-user settings row instead of one global blob in core/config.py; per-user API keys replacing the single shared external_api_key.
  • Concurrency: per-user fair-share admission control replacing the global semaphores in routes/tts.py, routes/stt.py, routes/conversation.py; extend MutationRateLimitMiddleware's existing (client_ip, is_destructive) key to include the account.
  • Voice-cloning consent (decided): a required attestation checkbox at voice-upload time ("I have the right to clone this voice"), stored with a timestamp, account id, and IP alongside the voice sample; add a user_id column to generation_history so a specific account's generations are traceable and the account can be suspended if misused. This is the one piece of policy the user has already settled — implement it as designed here, don't re-litigate it.
  • Edge layer: a reverse proxy (matching whatever pattern the NUC's nginx gateways already use) for TLS termination, HSTS, CSP, and real CORS/CSRF posture appropriate for cookie/JWT-based sessions.

Explicitly still open (not decided, don't assume an answer)

  • Which apps join the shared identity service and in what order.
  • Whether existing OurFlashcards accounts become the same accounts, or a separate but interconnected account store.
  • Reverse-proxy technology/domain/TLS certificate approach for public exposure.
  • Any billing, quota tiers, or admin/abuse-response tooling beyond the consent attestation and audit trail described above.