Přeskočit obsah

Phase A — Auth + multi-tenant schéma

Stav: ✅ Dokončeno 27.4.2026 Trvání: cca 5 dní (23.4 → 27.4) Klíčový artefakt: 12 migrací (0001-0012), 80 pgTAP testů, JWT custom hook, magic link auth

Cíl fáze

Postavit základ multi-tenant SaaS:

  1. Supabase Auth s magic link sign-in
  2. Multi-tenant DB schema s RLS izolací
  3. JWT custom claims (personal_tenant_id, active_tenant_id)
  4. Storage RLS scoping per tenant
  5. Backend FastAPI s JWT verification + Supabase clients (user + service)
  6. Frontend SSR auth flow (Next.js 16 + Supabase SSR)
  7. Migration tooling pro v1 testery (claim flow)

Architektonické decisions

A1: Vlastní schema nemoreport (NE public)

Místo defaultního public schema používáme nemoreport. Důvody:

  • Explicitní isolation od jiných projektů ve sdíleném Supabase projektu (CodeLens používá codelens schema, NemoReport nemoreport)
  • Cleaner permissions — žádný "automatic public schema" creep
  • PostgREST exposure přes supabase/config.toml api.schemas = ["nemoreport", "public"]

Trade-off: každá migrace musí explicitly použít nemoreport. prefix; supabase-py client potřebuje schema('nemoreport') při kvérování.

A2: PyJWT[crypto] místo python-jose

Doporučované Supabase. Menší attack surface, aktivně udržované.

A3: Custom JWT hook pro tenant claims

Místo of-band volání pro tenant info, JWT už při emit obsahuje personal_tenant_id + active_tenant_id.

-- migrace 0007
CREATE FUNCTION private.custom_access_token_hook(event jsonb) RETURNS jsonb AS $$
DECLARE
  user_id uuid := (event->>'user_id')::uuid;
  profile record;
BEGIN
  SELECT personal_tenant_id INTO profile FROM nemoreport.user_profiles WHERE user_id = user_id;
  RETURN jsonb_set(
    event,
    '{claims, personal_tenant_id}',
    to_jsonb(profile.personal_tenant_id::text)
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Backend extrahuje claims, předává active_tenant_id při insertech. RLS pak enforce-uje.

Default Supabase magic link používá implicit flow (#access_token=... v hash fragmentu). Server route /auth/confirm ho nemůže přečíst (browser nikdy hash neposílá na server) → user landoval na /login a session se ne-zpracoval.

Fix: supabase/templates/magic_link.html s {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink query flow + český branded UI.

A5: Defense-in-depth grants

alter default privileges granty SELECT/INSERT/UPDATE/DELETE pro service_role automaticky na všechny budoucí tabulky. Plus explicit per-table grants pro authenticated jen kde je třeba.

Citlivé tabulky (llm_providers.api_key, handle_claims_preauth s emaily): REVOKE z authenticated.

A6: Rate limit Redis-capable

slowapi s ENV-driven storage URI: - memory:// pro dev (žádný Redis) - redis://nemoreport-redis.internal:6379/0 pro prod

Klíčové bugy / hotfixy

Phase A skončila s 4 hotfixy nalezenými během reálného end-to-end smoke testu:

Bug A.1 — CORS allowed_origins

Sliplane env měl pages.dev z původního CF Pages plánu, ale skutečný frontend je na workers.dev. Browser preflight pro /me fail s Disallowed CORS origin.

Fix: Update env přes Sliplane → workers.dev,localhost:3000, redeploy.

Bug A.2 — Migrace 0009 (private schema USAGE)

Custom JWT hook (private.custom_access_token_hook) měl jen EXECUTE grant na funkci, ale supabase_auth_admin neměl USAGE na schema private. Login flow vracel HTTP 500 permission denied for schema private (SQLSTATE 42501).

Lesson: SECURITY DEFINER řeší table-level permissions, ne schema-resolution permissions. Schema USAGE je separátní vrstva.

Fix: migrace 0009 — GRANT USAGE ON SCHEMA private TO supabase_auth_admin.

Bug A.3 — Migrace 0010 (service_role privileges)

Migrace 0001-0008 grantovaly přímo authenticated a anon, ale úplně zapomněly na service_role. Důsledek: každý ServiceDB endpoint hodil 500 (permission denied for schema nemoreport). Mimo /migrate/status, taky /admin/*, /chat při model override (čte llm_providers).

Browser to viděl jako TypeError: Failed to fetch protože FastAPI default 500 handler nepřidává CORS headers.

Fix: migrace 0010 — GRANT ALL ON SCHEMA nemoreport TO service_role + ALTER DEFAULT PRIVILEGES IN SCHEMA nemoreport GRANT ALL ON TABLES TO service_role + per-existing-table grants.

Bug A.4 — Migrace 0011 (defense-in-depth revoke)

pgTAP grant testy odhalily, že authenticated má SELECT na 5 admin-only tabulek včetně llm_providers.api_key! Aktuálně RLS to řeší (RLS enabled + no policy = deny vše), ale jakákoliv budoucí permissive policy by API klíče leakla.

Fix: migrace 0011 — REVOKE SELECT/INSERT/UPDATE/DELETE FROM authenticated ON handle_claims_preauth, import_manifest, llm_providers, migration_events, user_events.

pgTAP regression testy

tests/db/04_grants.sql38 testů (původně 36 + 2 sequence checks z A.1):

  • USAGE/SELECT/EXECUTE per role per object
  • Sweep test: service_role musí mít ALL na všech nemoreport.* tabulkách (zachytí budoucí migrace co zapomenou)
  • Sequence grants per všech ID sequences

Inline runner přes mcp__claude_ai_Supabase__execute_sql (no Docker), tests/db/run_remote.sh přes psql, supabase test db --linked přes Docker — 4 cesty popsané v tests/README.md.

Smoke test výsledek

Akce Výsledek
signInWithOtp → email doručen přes Resend ✅ (sandbox: jen owner email)
Magic link click → /auth/confirm?token_hash=...
verifyOtp server-side → cookies + redirect
getClaims() v root page → vidí session
JWT obsahuje personal_tenant_id + active_tenant_id
Backend /me 200 + správný user/tenant payload
Backend /reports 200 + RLS scoping
Backend /migrate/status 200
/admin/feedback s bogus hash → 403
/me bez JWT → 401
pgTAP grants: 38/38

Co Phase A zanechala pro Phase B

  • Multi-tenant DB schema připravený pro Phase B nové tabulky
  • _Target pattern později (Phase B post-deploy)
  • Auth flow + JWT verification jako stable foundation
  • Storage RLS pattern pro Phase B buckety
  • ENV configuration pattern (Settings fail-fast)
  • Sliplane Docker deploy pattern

Open user-action items po Phase A

  • Resend domain verification (auth.algaweb.cz nebo auth.nemoreport.cz) — sandbox limit owner-only blokuje reálné testery
  • Mistral API key (pro Phase B)
  • (vyřešeno v Phase B) Backend secrets reálnost ověřit