From f575cec73c89efa09de17fb6ffe1943f71151f43 Mon Sep 17 00:00:00 2001 From: Pavel Hegler Date: Tue, 1 Sep 2026 22:39:22 +0200 Subject: [PATCH] migrate/supabase: only emit the users module + JWT when auth.users is in the export; gap on dangling auth references (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The translator called authmap::build_auth unconditionally and pushed the users module as modules[0], so an export with a single `todos` table came back with a User entity, register/login and auth.model=jwt — "migrated 2 entities" for one source table, an auth surface the source never had. The users module is now emitted only when the export really carries auth.users: its dumped DDL, a foreign key referencing it (a `--schema public` dump keeps the reference), or its exported rows. The auth MODEL is gated separately — a source that restricts rows by the authenticated user (auth.uid(), membership) keeps jwt auth so its guards survive, with a blocking gap naming the missing identity surface; dropping the model there would have silently unguarded every restricted table. With no auth anywhere, the design has no auth block, no auth dependency and unguarded CRUD (as open as the source, stated as an advisory), and storage/realtime blocks that need an identity are gap-reported instead of emitted. Output is byte-identical for any export that carries auth.users. The migrate fixtures now declare auth.users, matching the export layout the docs prescribe. --- .../embedded/ai/19-migrate-supabase.md | 7 +- crates/jerrycan/src/platform/migrate/mod.rs | 244 ++++++++++++++++-- docs/ai/19-migrate-supabase.md | 7 +- 3 files changed, 241 insertions(+), 17 deletions(-) diff --git a/crates/jerrycan/embedded/ai/19-migrate-supabase.md b/crates/jerrycan/embedded/ai/19-migrate-supabase.md index 57348400..bf9b89c2 100644 --- a/crates/jerrycan/embedded/ai/19-migrate-supabase.md +++ b/crates/jerrycan/embedded/ai/19-migrate-supabase.md @@ -70,7 +70,12 @@ generated app; you rotate them and set fresh values. public read, authenticated). Anything else is a gap — never guessed. - **auth.users** → a `users` module + JWT auth + a user seed that preserves bcrypt hashes (migrated users log in unchanged; jerrycan upgrades the hash to - argon2 on the next login). + argon2 on the next login). It is a translation, not a default: an export with + no auth schema migrates to an app with no auth (and unguarded endpoints, like + its source). If the export restricts rows by the authenticated user + (`auth.uid()`, membership) but omits `auth.users`, the guards are kept and the + missing identity surface becomes a blocking gap — re-export with + `--schema public,auth,storage` and re-run. - **storage.buckets** → the `storage` block; **`supabase_realtime`** → the `realtime.changes` block; **pg_cron** → `jobs[]`. - **Not migrated:** the frontend (repoint it with the endpoint map in diff --git a/crates/jerrycan/src/platform/migrate/mod.rs b/crates/jerrycan/src/platform/migrate/mod.rs index 2cb0a037..e2ab62ac 100644 --- a/crates/jerrycan/src/platform/migrate/mod.rs +++ b/crates/jerrycan/src/platform/migrate/mod.rs @@ -154,6 +154,27 @@ pub(crate) struct FrontendInputs<'a> { pub seed: Option<&'a export::Export>, } +/// M3: does the export actually carry `auth.users`? The `users` module and the +/// JWT auth block are a TRANSLATION of that table, so they are emitted only when +/// it is really there: its dumped DDL (the documented +/// `--schema public,auth,storage` dump, and every `--live` read), a foreign key +/// that references it (a `--schema public` dump keeps the reference), or its +/// exported rows. `auth.uid()` in a policy names the identity but not the table — +/// that goes to the gap queue instead of conjuring a user surface nobody asked for. +fn export_has_auth_users(db: &pgmodel::PgDatabase, fe: &FrontendInputs) -> bool { + const AUTH_USERS: &str = "auth.users"; + db.tables.contains_key(AUTH_USERS) + || db + .tables + .values() + .any(|t| t.fks.iter().any(|fk| fk.ref_table == AUTH_USERS)) + || fe.seed.is_some_and(|e| { + e.data_files + .iter() + .any(|(schema, table, _)| schema == "auth" && table == "users") + }) +} + fn emit_from_db( db: pgmodel::PgDatabase, opts: &MigrateOptions, @@ -162,6 +183,22 @@ fn emit_from_db( let providers = fe.providers.clone(); let det = tenancy::detect(&db); let access_map = tenancy::table_access(&db, &det); + // M3: the `users` module (User entity + register/login) is a translation of + // `auth.users` — emitted only when the export actually carries that table. + let auth_users = export_has_auth_users(&db, &fe); + // …but the AUTH MODEL is a separate question. A source that restricts rows + // with RLS (`auth.uid()`, membership) authenticates its callers, so the + // migrated guards need a session even when the export forgot the auth schema. + // Dropping the model there would silently unguard every restricted table, so + // the design keeps jwt auth WITHOUT an identity surface (questions.rs allows + // auth with no `User` entity — the external-IdP shape) and the mismatch goes + // to the gap queue. Neither half is guessed: no source auth at all ⇒ no auth. + let auth_needed = !auth_users + && (det.tenant_table.is_some() + || access_map + .values() + .any(|a| !matches!(a, TableAccess::NoRls))); + let auth_model = auth_users || auth_needed; // Entities: every public table except the membership table (tenancy owns it). let mut exclude = BTreeSet::new(); @@ -170,13 +207,24 @@ fn emit_from_db( } let mut build = entities::build_entities_filtered(&db, &exclude); let mut gaps: Vec = build.gaps.clone(); + if auth_needed { + gaps.push(GapItem { + kind: GapKind::RlsPolicy, + source: "auth.users (missing from the export)".into(), + location: "schema.sql".into(), + reason: "the source restricts rows by the authenticated user (RLS/`auth.uid()`/membership) but the export carries no `auth.users` — the design keeps jwt auth so those guards survive, but declares NO identity entity and no register/login surface".into(), + original: String::new(), + suggested: "re-export including the auth schema (`supabase db dump --schema public,auth,storage`, plus data/auth.users.csv) and re-run, or wire the identity provider and its user surface by hand".into(), + severity: Severity::Blocking, + }); + } // The auth mapping reserves the `User` entity (generated `users` module). // A public table that would also produce it (public.users) can't be // modeled mechanically — merging its columns into the auth-derived User is // agent judgment. Blocking gap + skip, not a hard abort. build.entities.retain(|(k, e)| { - if e.name == "User" { + if auth_users && e.name == "User" { gaps.push(GapItem { kind: GapKind::UnmappedType, source: k.clone(), @@ -279,7 +327,14 @@ fn emit_from_db( kind: GapKind::RlsPolicy, source: format!("{tk} (no RLS)"), location: "schema.sql".into(), - reason: "the source table has row-level security disabled; the migrated endpoints require auth by default (stricter than the source)".into(), + // M3: "stricter" holds only while there IS an auth model to guard + // with. An export with no auth at all migrates to an auth-less app, + // so those endpoints are as open as the source — say which it is. + reason: if auth_model { + "the source table has row-level security disabled; the migrated endpoints require auth by default (stricter than the source)".into() + } else { + "the source table has row-level security disabled and the export declares no auth at all (no `auth.users`, no RLS anywhere), so the migrated endpoints are UNGUARDED — as open as the source".to_string() + }, original: String::new(), suggested: "mark reads public in the design if this data really is open".into(), severity: Severity::Advisory, @@ -293,11 +348,16 @@ fn emit_from_db( entities::entity_name(short) }); - // Auth. - let auth_out = authmap::build_auth(&det.member_roles, &providers); + // Auth (M3): the MODEL whenever the source authenticates at all, the `users` + // MODULE only when the export carries the `auth.users` it translates. + let auth_out = auth_model.then(|| authmap::build_auth(&det.member_roles, &providers)); - // Modules: users first, then FK-graph groups (hub = tenant table). - let mut modules: Vec = vec![auth_out.users_module.clone()]; + // Modules: users first (when there is one), then FK-graph groups (hub = tenant table). + let mut modules: Vec = auth_out + .iter() + .filter(|_| auth_users) + .map(|a| a.users_module.clone()) + .collect(); let entity_tables: Vec = build.entities.iter().map(|(k, _)| k.clone()).collect(); let mut edges = Vec::new(); for (k, _) in &build.entities { @@ -318,7 +378,7 @@ fn emit_from_db( let groups: Vec<(String, Vec)> = grouping::group_modules(&entity_tables, &edges, &hubs) .into_iter() .map(|(name, tables)| { - if name == "users" { + if auth_users && name == "users" { ("users-tables".to_string(), tables) } else { (name, tables) @@ -383,6 +443,16 @@ fn emit_from_db( }); } let mut eps = crud::endpoints_for(&entity.name, access, &covered); + // M3: an auth-less design has no session to require, and a guarded + // endpoint without an auth model fails validation. Only reachable when + // the source itself had no auth AND no RLS (`auth_needed` keeps the + // model otherwise), so this matches the source instead of widening it. + if !auth_model { + for e in &mut eps { + e.auth_required = false; + e.required_roles.clear(); + } + } // questions.rs forbids public on a tenant-owned entity: downgrade + advise. // #144: tenant ownership is TRANSITIVE (a grandchild is tenant-owned // through its parent chain), matching the framework's `Design::tenant_path` @@ -426,7 +496,10 @@ fn emit_from_db( } // Dependencies: auth (+oauth) + db (tenancy/realtime/storage all need it). - let mut dep_set: BTreeSet = auth_out.dependencies.iter().cloned().collect(); + let mut dep_set: BTreeSet = auth_out + .iter() + .flat_map(|a| a.dependencies.iter().cloned()) + .collect(); dep_set.insert("db".into()); let dependencies: Vec = dep_set.into_iter().collect(); @@ -452,7 +525,20 @@ fn emit_from_db( let realtime = if publications.contains_key("supabase_realtime") { let rt = realtimemap::build_realtime(&publications, &table_to_entity); gaps.extend(rt.gaps); - (!rt.changes.is_empty()).then_some(RealtimeDesign { + // M3: changes delivery is scope-filtered by the authenticated principal, + // which an auth-less design has none of — drop it, loudly. + if !auth_model && !rt.changes.is_empty() { + gaps.push(GapItem { + kind: GapKind::RealtimeChannel, + source: "supabase_realtime publication".into(), + location: "schema.sql".into(), + reason: "realtime change delivery is scope-filtered by the authenticated principal, but the export has no `auth.users` — the design declares no auth model, so no `realtime` block was emitted".into(), + original: String::new(), + suggested: "re-export including the auth schema (`--schema public,auth,storage`) and re-run".into(), + severity: Severity::Blocking, + }); + } + (auth_model && !rt.changes.is_empty()).then_some(RealtimeDesign { changes: rt.changes, broadcast: vec![], presence: vec![], @@ -461,12 +547,27 @@ fn emit_from_db( None }; - // Storage. + // Storage. M3: the bucket owner is the `User` identity entity, which only the + // `auth.users` translation declares — without it there is nothing to own the + // folder-per-user policies, so the block is gapped rather than owner-less. let storage = if let Some(json) = &fe.buckets_json { - let so = storagemap::build_storage(json, &db, "User")?; - let design = so.to_design(); - gaps.extend(so.gaps); - design + if auth_users { + let so = storagemap::build_storage(json, &db, "User")?; + let design = so.to_design(); + gaps.extend(so.gaps); + design + } else { + gaps.push(GapItem { + kind: GapKind::RlsPolicy, + source: "storage.buckets".into(), + location: "storage/buckets.json".into(), + reason: "storage buckets are owned by the `User` identity entity (uploads/deletes are always guarded, folder-per-user policies scope by it), but the export has no `auth.users` to declare it — no `storage` block was emitted".into(), + original: String::new(), + suggested: "re-export including the auth schema (`--schema public,auth,storage`) and re-run".into(), + severity: Severity::Blocking, + }); + None + } } else { None }; @@ -543,7 +644,7 @@ fn emit_from_db( // No CORS policy inferred from an import; add a `cors` block post-migration // if the migrated app serves a cross-origin SPA (issue #21). cors: None, - auth: Some(auth_out.auth.clone()), + auth: auth_out.as_ref().map(|a| a.auth.clone()), dependencies, tenancy, jobs, @@ -1090,6 +1191,7 @@ mod tests { std::fs::write( root.join("schema.sql"), r#" +create table auth.users (id uuid primary key, email text); create table public.workspaces (id uuid primary key, name text not null); create table public.workspace_members ( workspace_id uuid not null references public.workspaces(id) on delete cascade, @@ -1309,6 +1411,7 @@ create policy posts_delete on public.posts for delete using (user_id = auth.uid( std::fs::write( export_dir.join("schema.sql"), r#" +create table auth.users (id uuid primary key, email text); create table public.users (id uuid primary key, handle text not null); create table public.users_settings (id uuid primary key, theme text not null); "#, @@ -1561,6 +1664,7 @@ create table public.users_settings (id uuid primary key, theme text not null); std::fs::write( export_dir.join("schema.sql"), r#" +create table auth.users (id uuid primary key, email text); create table public.workspaces (id uuid primary key, name text not null); create table public.workspace_members ( workspace_id uuid not null references public.workspaces(id) on delete cascade, @@ -1745,6 +1849,115 @@ create policy own on public.todos using (user_id = auth.uid()); ); } + /// M3: the `users` module + jwt auth are a TRANSLATION of `auth.users`, not a + /// default. A source with no auth must not grow a User entity and a + /// register/login surface it never had (an unrequested auth surface is the + /// same "guess" the translator refuses everywhere else) — and the inverse must + /// not happen either: an export that RESTRICTS rows by the auth identity while + /// omitting `auth.users` keeps its guards and gets a blocking gap naming the + /// mismatch, never a silently unguarded API. + #[test] + fn the_users_module_follows_auth_users_and_a_dangling_auth_reference_is_a_blocking_gap() { + // (a) No auth anywhere in the source → an auth-less app. + let tmp = tempfile::tempdir().unwrap(); + let export_dir = tmp.path().join("export"); + std::fs::create_dir_all(&export_dir).unwrap(); + std::fs::write( + export_dir.join("schema.sql"), + "create table public.todos(id bigserial primary key, title text);\n", + ) + .unwrap(); + let out = run_migrate(&MigrateOptions { + export_dir, + out_dir: tmp.path().join("app"), + name: Some("todo-app".into()), + bulk_threshold: 5000, + }) + .expect("a table-only export migrates"); + assert!( + out.design.auth.is_none(), + "no auth schema in the export ⇒ no auth block, got {:?}", + out.design.auth + ); + assert!( + !out.design.dependencies.contains(&"auth".to_string()), + "no auth dependency: {:?}", + out.design.dependencies + ); + let entities: Vec<&str> = out + .design + .modules + .iter() + .flat_map(|m| m.entities.iter()) + .map(|e| e.name.as_str()) + .collect(); + assert_eq!( + entities, + vec!["Todo"], + "one source table ⇒ one entity — no invented User" + ); + assert!( + !out.design.modules.iter().any(|m| m.name == "users"), + "no register/login surface for a source that has none" + ); + assert!( + out.design + .modules + .iter() + .flat_map(|m| m.endpoints.iter()) + .all(|e| !e.is_guarded()), + "with no auth model there is no session to require" + ); + + // (c) The same table, now restricted by the auth identity, with `auth.users` + // still missing: the guards must survive (auth model kept, no identity + // surface invented) and the mismatch must be BLOCKING, not dropped. + let tmp = tempfile::tempdir().unwrap(); + let export_dir = tmp.path().join("export"); + std::fs::create_dir_all(&export_dir).unwrap(); + std::fs::write( + export_dir.join("schema.sql"), + r#" +create table public.todos(id bigserial primary key, user_id uuid, title text); +alter table public.todos enable row level security; +create policy own on public.todos using (user_id = auth.uid()); +"#, + ) + .unwrap(); + let out = run_migrate(&MigrateOptions { + export_dir, + out_dir: tmp.path().join("app"), + name: Some("todo-app".into()), + bulk_threshold: 5000, + }) + .expect("a dangling auth reference gaps, it does not abort"); + assert!( + !out.design.modules.iter().any(|m| m.name == "users"), + "still no users module — the export carries no auth.users to translate" + ); + assert!( + out.design.auth.is_some(), + "the source authenticates (auth.uid()), so the model stays — dropping it would unguard every restricted table" + ); + assert!( + out.design + .modules + .iter() + .flat_map(|m| m.endpoints.iter()) + .all(|e| e.auth_required && !e.public), + "the source's restriction survives as a guard" + ); + let gap = out + .gaps + .iter() + .find(|g| g.source.contains("auth.users") && g.severity == Severity::Blocking) + .expect("blocking gap naming the missing auth.users"); + assert!( + gap.reason.contains("identity entity") && gap.suggested.contains("re-export"), + "the gap says what is missing and how to fix it: {gap:?}" + ); + } + #[test] fn an_owner_filter_folded_into_tenant_scope_is_an_advisory_gap() { // R3(a): owner-scoped table carrying the tenant fk translates to tenant @@ -1756,6 +1969,7 @@ create policy own on public.todos using (user_id = auth.uid()); std::fs::write( export_dir.join("schema.sql"), r#" +create table auth.users (id uuid primary key, email text); create table public.workspaces (id uuid primary key, name text not null); create table public.workspace_members ( workspace_id uuid not null references public.workspaces(id) on delete cascade, diff --git a/docs/ai/19-migrate-supabase.md b/docs/ai/19-migrate-supabase.md index 57348400..bf9b89c2 100644 --- a/docs/ai/19-migrate-supabase.md +++ b/docs/ai/19-migrate-supabase.md @@ -70,7 +70,12 @@ generated app; you rotate them and set fresh values. public read, authenticated). Anything else is a gap — never guessed. - **auth.users** → a `users` module + JWT auth + a user seed that preserves bcrypt hashes (migrated users log in unchanged; jerrycan upgrades the hash to - argon2 on the next login). + argon2 on the next login). It is a translation, not a default: an export with + no auth schema migrates to an app with no auth (and unguarded endpoints, like + its source). If the export restricts rows by the authenticated user + (`auth.uid()`, membership) but omits `auth.users`, the guards are kept and the + missing identity surface becomes a blocking gap — re-export with + `--schema public,auth,storage` and re-run. - **storage.buckets** → the `storage` block; **`supabase_realtime`** → the `realtime.changes` block; **pg_cron** → `jobs[]`. - **Not migrated:** the frontend (repoint it with the endpoint map in