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:
- Supabase Auth s magic link sign-in
- Multi-tenant DB schema s RLS izolací
- JWT custom claims (
personal_tenant_id,active_tenant_id) - Storage RLS scoping per tenant
- Backend FastAPI s JWT verification + Supabase clients (user + service)
- Frontend SSR auth flow (Next.js 16 + Supabase SSR)
- 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á
codelensschema, NemoReportnemoreport) - Cleaner permissions — žádný "automatic public schema" creep
- PostgREST exposure přes
supabase/config.tomlapi.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.
A4: Custom magic link template (token_hash query flow)¶
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.sql — 38 testů (původně 36 + 2 sequence checks z A.1):
- USAGE/SELECT/EXECUTE per role per object
- Sweep test:
service_rolemusí mít ALL na všechnemoreport.*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
_Targetpattern 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.czneboauth.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