From dc7630700ed0335ea30f6669c9405fc1f5046eb8 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 20 Sep 2026 09:41:25 +0200 Subject: [PATCH 1/2] feat(import): add supabase as a user import source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase joins config/keycloak/zitadel as a `RealmSource`, read through the Auth (GoTrue) Admin API with the project's `service_role` key. Supabase exposes no realm, no OIDC client and no role catalogue, so the adapter carries users and nothing else; the realm name comes from --target-realm and defaults to `supabase`. Three decisions worth recording, none of them visible in the diff: Pagination terminates on "this page brought no id we had not already seen" rather than on the short-page test the Keycloak adapter uses. GoTrue caps per_page server-side, so asking for 100 and getting 50 is the ordinary case, not the last page — the short-page test would have stopped after the first batch and silently dropped the rest of the directory. Tracking ids also bounds the walk against a deployment that ignores `page`. Verified against a stub server capping per_page at 2 while the CLI asked for 100: all 7 users came back, where the short-page test would have returned 2. Passwords are not migrated and cannot be. Supabase keeps bcrypt hashes in auth.users.encrypted_password and does not serve them over the Admin API, and the FerrisKey API accepts only a plaintext password on reset-password. Neither side exposes a hash, so imported users need a reset. Documented in the module header, the CLI help and the README rather than surfaced as an import warning: the RealmSource trait has no channel for a source to emit one, and adding it would have meant touching Keycloak and Zitadel too. Supabase users have no username. The full email address is used, falling back to the phone number and then to the Supabase id. The local part was rejected: two users from different domains sharing a prefix would collide, and the import has to stay replayable. The deleted/anonymous/unconfirmed filters are per-run flags read from the command line only, never from a stored source, so a saved source cannot silently widen a later import. include_anonymous is deliberately not undone by the confirmation filter — an anonymous account has nothing to confirm, so subjecting it to both would make the flag a no-op. --- README.md | 61 ++ libs/ferriskey-cli-commands/src/realm.rs | 24 +- libs/ferriskey-cli-commands/src/source.rs | 5 +- .../src/import/sources/mod.rs | 28 + .../src/import/sources/supabase.rs | 556 ++++++++++++++++++ 5 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 libs/ferriskey-cli-core/src/import/sources/supabase.rs diff --git a/README.md b/README.md index 1d82b05..d4960c0 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,64 @@ cargo install ferris-ctl ferris-ctl realm list ferris-ctl realm create myrealm + +## Importing a realm + +`ferris-ctl realm import` pulls a realm description out of an external system +and replays it against FerrisKey. Available sources: `config` (a YAML or TOML +file, see `examples/realm.yaml`), `keycloak`, `zitadel`, and `supabase`. + +Add `--dry-run` to any import to resolve the source and print what would be +created without calling FerrisKey. A dry run needs neither a configured context +nor authentication, so it is the cheapest way to check a mapping. + +An import converges: re-running it skips what the realm already has rather than +failing or duplicating. + +### Supabase + +Reads a project through its Auth (GoTrue) Admin API, authenticating with the +project's `service_role` key. + + ferris-ctl realm import \ + --from supabase \ + --source-url https://.supabase.co \ + --source-token \ + --target-realm my-realm + +Supabase has no realm, no OIDC client and no role catalogue of its own, so the +import carries **users only**. The realm name comes from `--target-realm` (or +`--source-realm`) and defaults to `supabase`. + +**Passwords are not migrated.** Supabase keeps bcrypt hashes in +`auth.users.encrypted_password` and does not serve them over the Admin API, and +the FerrisKey API accepts only a plaintext password — neither side exposes a +hash. Imported users arrive without credentials and have to go through a +password reset. + +Usernames are derived from the full email address, falling back to the phone +number and then to the Supabase user id, since Supabase users have no username +of their own. + +Three kinds of account are dropped by default, each re-enabled by its own flag: + +| Flag | Keeps | +|------|-------| +| `--source-include-deleted` | Accounts an operator soft-deleted (`deleted_at` set, row still present) | +| `--source-include-anonymous` | Anonymous sign-in sessions, which have neither email nor phone | +| `--source-include-unconfirmed` | Accounts that never confirmed an email or a phone number | + +`--source-include-anonymous` is not undone by the confirmation filter: an +anonymous account has nothing to confirm, so it is governed by that flag alone. + +### Reusable sources + +Connection details can be stored once and referenced by name: + + ferris-ctl source add supa --kind supabase \ + --url https://.supabase.co --token + ferris-ctl realm import --source-ref supa --target-realm my-realm + +Inline `--source-*` flags override individual fields of a stored source. The +Supabase account filters above are deliberately not stored: they are per-run +choices, so a saved source can never silently widen a later import. diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index 1448c51..c0b13a7 100644 --- a/libs/ferriskey-cli-commands/src/realm.rs +++ b/libs/ferriskey-cli-commands/src/realm.rs @@ -144,6 +144,11 @@ pub enum ImportSource { Keycloak, /// A live Zitadel instance, read through its Management API. Zitadel, + /// A Supabase project, read through its Auth (GoTrue) Admin API. Users + /// only: Supabase has no client or role catalogue, and passwords cannot be + /// carried over (neither side exposes a hash, so imported users need a + /// password reset). + Supabase, } /// Arguments for `realm import`. @@ -184,10 +189,27 @@ pub struct RealmImportArgs { #[arg(long = "source-client-secret")] pub source_client_secret: Option, - /// Bearer token / personal access token for the source (Zitadel PAT, or a ready Keycloak token). + /// Bearer token / personal access token for the source (Zitadel PAT, + /// Supabase `service_role` key, or a ready Keycloak token). #[arg(long = "source-token")] pub source_token: Option, + /// Import users an operator soft-deleted (Supabase). Dropped by default: + /// their `deleted_at` is set but the row survives, so a plain import would + /// resurrect accounts somebody removed on purpose. + #[arg(long = "source-include-deleted", default_value_t = false)] + pub source_include_deleted: bool, + + /// Import anonymous sign-in sessions (Supabase). Dropped by default: they + /// are real rows with neither an email nor a phone number. + #[arg(long = "source-include-anonymous", default_value_t = false)] + pub source_include_anonymous: bool, + + /// Import users who never confirmed an email or a phone number (Supabase). + /// Dropped by default. + #[arg(long = "source-include-unconfirmed", default_value_t = false)] + pub source_include_unconfirmed: bool, + /// Override the name of the realm created in FerrisKey (defaults to the source realm name). #[arg(long = "target-realm")] pub target_realm: Option, diff --git a/libs/ferriskey-cli-commands/src/source.rs b/libs/ferriskey-cli-commands/src/source.rs index 995c0a4..63882a8 100644 --- a/libs/ferriskey-cli-commands/src/source.rs +++ b/libs/ferriskey-cli-commands/src/source.rs @@ -24,6 +24,7 @@ pub enum SourceSubcommand { pub enum SourceKind { Keycloak, Zitadel, + Supabase, } impl SourceKind { @@ -31,6 +32,7 @@ impl SourceKind { match self { SourceKind::Keycloak => "keycloak", SourceKind::Zitadel => "zitadel", + SourceKind::Supabase => "supabase", } } } @@ -61,7 +63,8 @@ pub struct SourceAddArgs { #[arg(long = "client-secret")] pub client_secret: Option, - /// Bearer token / personal access token (Zitadel PAT, or a ready Keycloak token). + /// Bearer token / personal access token (Zitadel PAT, Supabase + /// `service_role` key, or a ready Keycloak token). #[arg(long)] pub token: Option, diff --git a/libs/ferriskey-cli-core/src/import/sources/mod.rs b/libs/ferriskey-cli-core/src/import/sources/mod.rs index 8928829..99b6c5f 100644 --- a/libs/ferriskey-cli-core/src/import/sources/mod.rs +++ b/libs/ferriskey-cli-core/src/import/sources/mod.rs @@ -3,6 +3,7 @@ pub mod config; pub mod keycloak; +pub mod supabase; pub mod zitadel; use ferriskey_cli_commands::{ImportSource, RealmImportArgs}; @@ -12,6 +13,7 @@ use crate::config::{FileContextRepository, StoredSource}; use super::{ImportError, RealmSource}; use config::ConfigSource; use keycloak::KeycloakSource; +use supabase::{SupabaseSource, UserFilters}; use zitadel::ZitadelSource; /// Builds the appropriate [`RealmSource`] from the parsed CLI arguments. @@ -57,6 +59,23 @@ fn build_from_inline( args.source_org.clone(), args.target_realm.clone().or_else(|| args.source_realm.clone()), )?)), + ImportSource::Supabase => Ok(Box::new(SupabaseSource::build( + args.source_url.clone(), + args.source_token.clone(), + args.target_realm.clone().or_else(|| args.source_realm.clone()), + user_filters(args), + )?)), + } +} + +/// Supabase's account filters are per-run choices about which rows to replay, +/// so they come from the flags only and are never read back from a stored +/// source — a saved source must not silently widen a later import. +fn user_filters(args: &RealmImportArgs) -> UserFilters { + UserFilters { + include_deleted: args.source_include_deleted, + include_anonymous: args.source_include_anonymous, + include_unconfirmed: args.source_include_unconfirmed, } } @@ -84,6 +103,15 @@ fn build_from_stored( .or_else(|| args.source_realm.clone()) .or_else(|| stored.realm.clone()), )?)), + "supabase" => Ok(Box::new(SupabaseSource::build( + args.source_url.clone().or_else(|| Some(stored.url.clone())), + args.source_token.clone().or_else(|| stored.token.clone()), + args.target_realm + .clone() + .or_else(|| args.source_realm.clone()) + .or_else(|| stored.realm.clone()), + user_filters(args), + )?)), other => Err(ImportError::InvalidStoredKind { name: name.to_owned(), kind: other.to_owned(), diff --git a/libs/ferriskey-cli-core/src/import/sources/supabase.rs b/libs/ferriskey-cli-core/src/import/sources/supabase.rs new file mode 100644 index 0000000..af49018 --- /dev/null +++ b/libs/ferriskey-cli-core/src/import/sources/supabase.rs @@ -0,0 +1,556 @@ +//! Reads a Supabase project through its Auth (GoTrue) Admin API and maps the +//! users onto a [`RealmBlueprint`]. +//! +//! Supabase has no realm, no OIDC client and no role catalogue of its own, so +//! an import carries users and nothing else. The realm name comes from +//! `--target-realm` (or `--source-realm`) and defaults to `supabase`. +//! +//! Passwords are never carried over. Supabase keeps bcrypt hashes in +//! `auth.users.encrypted_password` and does not serve them over the Admin API, +//! and the FerrisKey API accepts only a plaintext password on its +//! `reset-password` endpoint — neither side exposes a hash. Imported users +//! therefore arrive without credentials and have to go through a reset. +//! +//! Authentication uses the project's `service_role` key, passed with +//! `--source-token`; it is sent both as the `apikey` header Supabase's gateway +//! expects and as the bearer token GoTrue itself checks. + +use std::collections::HashSet; + +use reqwest::blocking::Client; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value}; + +use crate::import::{ImportError, RealmBlueprint, RealmSource, UserBlueprint}; + +const SOURCE: &str = "supabase"; +const USER_PAGE_SIZE: usize = 100; +const DEFAULT_REALM_NAME: &str = "supabase"; + +const FIRST_NAME_KEYS: [&str; 3] = ["first_name", "firstName", "given_name"]; +const LAST_NAME_KEYS: [&str; 3] = ["last_name", "lastName", "family_name"]; +const FULL_NAME_KEYS: [&str; 2] = ["full_name", "name"]; + +/// Which Supabase accounts an import carries over. +/// +/// The user table holds rows a migration usually should not replay: accounts an +/// operator soft-deleted, anonymous sign-in sessions, and addresses nobody ever +/// confirmed. Each is dropped by default; `Default` is therefore the strictest +/// setting, and every flag only ever widens what is kept. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct UserFilters { + pub include_deleted: bool, + pub include_anonymous: bool, + pub include_unconfirmed: bool, +} + +impl UserFilters { + /// Whether `user` survives the filters. + /// + /// The confirmation filter only judges accounts that have something to + /// confirm. An anonymous account has neither address nor phone, so it is + /// governed by `include_anonymous` alone — were it also subject to the + /// confirmation filter, asking to keep anonymous users would still drop + /// every one of them. + fn keeps(&self, user: &SupabaseUser) -> bool { + if user.deleted_at.is_some() && !self.include_deleted { + return false; + } + if user.is_anonymous { + return self.include_anonymous; + } + self.include_unconfirmed || user.is_confirmed() + } +} + +pub struct SupabaseSource { + base_url: String, + service_role_key: String, + realm_name: Option, + filters: UserFilters, + http: Client, +} + +impl SupabaseSource { + /// Builds the source from resolved option values (inline flags already + /// merged over any stored source). + pub fn build( + base_url: Option, + service_role_key: Option, + realm_name: Option, + filters: UserFilters, + ) -> Result { + let base_url = + normalize_base_url(&base_url.ok_or(ImportError::MissingArg("--source-url"))?); + let service_role_key = service_role_key.ok_or(ImportError::MissingArg("--source-token"))?; + + Ok(Self { + base_url, + service_role_key, + realm_name, + filters, + http: Client::new(), + }) + } + + /// Reads one page of the admin user list. Pages are 1-indexed. + fn page(&self, page: usize) -> Result, ImportError> { + let url = format!( + "{}/auth/v1/admin/users?page={page}&per_page={USER_PAGE_SIZE}", + self.base_url + ); + let response = self + .http + .get(url) + .header("apikey", &self.service_role_key) + .bearer_auth(&self.service_role_key) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(ImportError::Source { + provider: SOURCE, + status, + body, + }); + } + + Ok(response.json::()?.users) + } + + /// Walks every page of the admin user list, keeping what the filters allow. + /// + /// Termination is on "this page brought no id we had not already seen", not + /// on the usual "this page was shorter than the size we asked for". GoTrue + /// caps `per_page` server-side, so a short page is the ordinary case rather + /// than the last one, and the short-page test would stop after the first + /// batch and silently drop the rest of the directory. Tracking ids also + /// bounds the walk against a deployment that ignores `page` and keeps + /// serving the first one. + fn all_users(&self) -> Result, ImportError> { + let mut seen: HashSet = HashSet::new(); + let mut users = Vec::new(); + let mut page = 1; + + loop { + let batch = self.page(page)?; + let known = seen.len(); + for user in batch { + if !seen.insert(user.id.clone()) { + continue; + } + if self.filters.keeps(&user) { + users.push(map_user(user)); + } + } + if seen.len() == known { + return Ok(users); + } + page += 1; + } + } +} + +impl RealmSource for SupabaseSource { + fn fetch(&self) -> Result, ImportError> { + let name = self + .realm_name + .clone() + .unwrap_or_else(|| DEFAULT_REALM_NAME.to_owned()); + + Ok(vec![RealmBlueprint { + name, + settings: None, + roles: Vec::new(), + clients: Vec::new(), + users: self.all_users()?, + }]) + } +} + +/// Accepts both the project URL and one already pointing at the Auth API, so +/// `https://abc.supabase.co` and `https://abc.supabase.co/auth/v1` both reach +/// the same endpoint instead of one of them 404ing on a doubled path. +fn normalize_base_url(url: &str) -> String { + url.trim_end_matches('/') + .trim_end_matches("/auth/v1") + .trim_end_matches('/') + .to_owned() +} + +fn map_user(user: SupabaseUser) -> UserBlueprint { + let (firstname, lastname) = names_from_metadata(&user.user_metadata); + let email_verified = user + .email + .as_ref() + .map(|_| user.email_confirmed_at.is_some()); + let username = user + .email + .clone() + .or_else(|| user.phone.clone()) + .unwrap_or(user.id); + + UserBlueprint { + username, + email: user.email, + firstname, + lastname, + email_verified, + roles: Vec::new(), + } +} + +/// Pulls a first and last name out of Supabase's free-form `user_metadata`. +/// +/// There is no profile schema behind that field: an email signup stores +/// whatever the application wrote into it, while an OAuth provider stores the +/// OIDC claims it received. The explicit keys are tried first, then a single +/// display name is split on its first run of whitespace. +fn names_from_metadata(metadata: &Map) -> (Option, Option) { + let first = first_string(metadata, &FIRST_NAME_KEYS); + let last = first_string(metadata, &LAST_NAME_KEYS); + if first.is_some() || last.is_some() { + return (first, last); + } + + match first_string(metadata, &FULL_NAME_KEYS) { + Some(full) => split_full_name(&full), + None => (None, None), + } +} + +fn first_string(metadata: &Map, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + metadata + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) +} + +fn split_full_name(full: &str) -> (Option, Option) { + match full.split_once(char::is_whitespace) { + Some((first, rest)) => { + let rest = rest.trim(); + ( + Some(first.to_owned()), + (!rest.is_empty()).then(|| rest.to_owned()), + ) + } + None => (Some(full.to_owned()), None), + } +} + +/// Supabase serialises an absent email or phone as `""` rather than `null`, so +/// a plain `Option` would carry an empty string into the blueprint and +/// create users with a blank address. +fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.filter(|value| !value.trim().is_empty())) +} + +#[derive(Debug, Deserialize)] +struct UserList { + #[serde(default)] + users: Vec, +} + +#[derive(Debug, Deserialize)] +struct SupabaseUser { + id: String, + #[serde(default, deserialize_with = "empty_string_as_none")] + email: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + phone: Option, + #[serde(default)] + email_confirmed_at: Option, + #[serde(default)] + phone_confirmed_at: Option, + #[serde(default)] + confirmed_at: Option, + #[serde(default)] + deleted_at: Option, + #[serde(default)] + is_anonymous: bool, + #[serde(default)] + user_metadata: Map, +} + +impl SupabaseUser { + fn is_confirmed(&self) -> bool { + self.email_confirmed_at.is_some() + || self.phone_confirmed_at.is_some() + || self.confirmed_at.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user(id: &str) -> SupabaseUser { + SupabaseUser { + id: id.to_owned(), + email: None, + phone: None, + email_confirmed_at: None, + phone_confirmed_at: None, + confirmed_at: None, + deleted_at: None, + is_anonymous: false, + user_metadata: Map::new(), + } + } + + fn confirmed_email_user(id: &str, email: &str) -> SupabaseUser { + SupabaseUser { + email: Some(email.to_owned()), + email_confirmed_at: Some("2024-01-01T00:00:00Z".to_owned()), + ..user(id) + } + } + + fn metadata(pairs: &[(&str, &str)]) -> Map { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) + .collect() + } + + #[test] + fn uses_the_full_email_as_username() { + let blueprint = map_user(confirmed_email_user("id-1", "alice@acme.test")); + assert_eq!(blueprint.username, "alice@acme.test"); + assert_eq!(blueprint.email.as_deref(), Some("alice@acme.test")); + assert_eq!(blueprint.email_verified, Some(true)); + } + + #[test] + fn falls_back_to_phone_when_there_is_no_email() { + let blueprint = map_user(SupabaseUser { + phone: Some("+33612345678".to_owned()), + ..user("id-2") + }); + assert_eq!(blueprint.username, "+33612345678"); + assert_eq!(blueprint.email, None); + } + + #[test] + fn falls_back_to_the_supabase_id_when_there_is_neither() { + let blueprint = map_user(user("8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11")); + assert_eq!(blueprint.username, "8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11"); + } + + #[test] + fn reports_an_unverified_email_as_unverified() { + let blueprint = map_user(SupabaseUser { + email: Some("bob@acme.test".to_owned()), + ..user("id-3") + }); + assert_eq!(blueprint.email_verified, Some(false)); + } + + #[test] + fn leaves_verification_unset_when_there_is_no_email() { + let blueprint = map_user(SupabaseUser { + phone: Some("+33612345678".to_owned()), + ..user("id-4") + }); + assert_eq!(blueprint.email_verified, None); + } + + #[test] + fn reads_an_empty_email_as_absent() { + let json = r#"{"id":"id-5","email":"","phone":" "}"#; + let parsed: SupabaseUser = serde_json::from_str(json).expect("parse"); + assert_eq!(parsed.email, None); + assert_eq!(parsed.phone, None); + } + + #[test] + fn deserializes_a_user_list_page() { + let json = r#"{"aud":"authenticated","users":[{"id":"id-6","email":"a@b.test"}]}"#; + let list: UserList = serde_json::from_str(json).expect("parse"); + assert_eq!(list.users.len(), 1); + assert_eq!(list.users[0].email.as_deref(), Some("a@b.test")); + } + + #[test] + fn drops_soft_deleted_users_by_default() { + let deleted = SupabaseUser { + deleted_at: Some("2024-02-01T00:00:00Z".to_owned()), + ..confirmed_email_user("id-7", "gone@acme.test") + }; + assert!(!UserFilters::default().keeps(&deleted)); + assert!( + UserFilters { + include_deleted: true, + ..UserFilters::default() + } + .keeps(&deleted) + ); + } + + #[test] + fn drops_anonymous_users_by_default() { + let anonymous = SupabaseUser { + is_anonymous: true, + ..user("id-8") + }; + assert!(!UserFilters::default().keeps(&anonymous)); + } + + #[test] + fn keeps_anonymous_users_when_asked_even_though_they_are_unconfirmed() { + let anonymous = SupabaseUser { + is_anonymous: true, + ..user("id-9") + }; + assert!( + UserFilters { + include_anonymous: true, + ..UserFilters::default() + } + .keeps(&anonymous), + "include_anonymous must not be undone by the confirmation filter" + ); + } + + #[test] + fn drops_unconfirmed_users_by_default() { + let unconfirmed = SupabaseUser { + email: Some("never@acme.test".to_owned()), + ..user("id-10") + }; + assert!(!UserFilters::default().keeps(&unconfirmed)); + assert!( + UserFilters { + include_unconfirmed: true, + ..UserFilters::default() + } + .keeps(&unconfirmed) + ); + } + + #[test] + fn treats_a_phone_confirmation_as_a_confirmation() { + let phone_only = SupabaseUser { + phone: Some("+33612345678".to_owned()), + phone_confirmed_at: Some("2024-01-01T00:00:00Z".to_owned()), + ..user("id-11") + }; + assert!(UserFilters::default().keeps(&phone_only)); + } + + #[test] + fn keeps_a_plain_confirmed_user_under_the_default_filters() { + assert!(UserFilters::default().keeps(&confirmed_email_user("id-12", "ok@acme.test"))); + } + + #[test] + fn reads_snake_case_names_from_metadata() { + let (first, last) = + names_from_metadata(&metadata(&[("first_name", "Alice"), ("last_name", "Doe")])); + assert_eq!(first.as_deref(), Some("Alice")); + assert_eq!(last.as_deref(), Some("Doe")); + } + + #[test] + fn reads_oidc_claim_names_from_metadata() { + let (first, last) = names_from_metadata(&metadata(&[ + ("given_name", "Alice"), + ("family_name", "Doe"), + ])); + assert_eq!(first.as_deref(), Some("Alice")); + assert_eq!(last.as_deref(), Some("Doe")); + } + + #[test] + fn skips_a_blank_key_and_takes_the_next_one() { + let (first, _) = + names_from_metadata(&metadata(&[("first_name", " "), ("given_name", "Alice")])); + assert_eq!(first.as_deref(), Some("Alice")); + } + + #[test] + fn splits_a_full_name_when_no_explicit_part_is_present() { + let (first, last) = names_from_metadata(&metadata(&[("full_name", "Alice Van Doe")])); + assert_eq!(first.as_deref(), Some("Alice")); + assert_eq!(last.as_deref(), Some("Van Doe")); + } + + #[test] + fn keeps_a_single_word_display_name_as_the_first_name() { + assert_eq!(split_full_name("Alice"), (Some("Alice".to_owned()), None)); + } + + #[test] + fn ignores_metadata_values_that_are_not_strings() { + let mut raw = Map::new(); + raw.insert("first_name".to_owned(), Value::Bool(true)); + assert_eq!(names_from_metadata(&raw), (None, None)); + } + + #[test] + fn maps_metadata_names_onto_the_blueprint() { + let blueprint = map_user(SupabaseUser { + user_metadata: metadata(&[("full_name", "Alice Doe")]), + ..confirmed_email_user("id-13", "alice@acme.test") + }); + assert_eq!(blueprint.firstname.as_deref(), Some("Alice")); + assert_eq!(blueprint.lastname.as_deref(), Some("Doe")); + } + + #[test] + fn imports_no_roles() { + let blueprint = map_user(confirmed_email_user("id-14", "alice@acme.test")); + assert!(blueprint.roles.is_empty()); + } + + #[test] + fn accepts_a_project_url_with_or_without_the_auth_path() { + assert_eq!( + normalize_base_url("https://abc.supabase.co"), + "https://abc.supabase.co" + ); + assert_eq!( + normalize_base_url("https://abc.supabase.co/"), + "https://abc.supabase.co" + ); + assert_eq!( + normalize_base_url("https://abc.supabase.co/auth/v1"), + "https://abc.supabase.co" + ); + assert_eq!( + normalize_base_url("https://abc.supabase.co/auth/v1/"), + "https://abc.supabase.co" + ); + } + + #[test] + fn requires_a_url_and_a_key() { + let missing_url = + SupabaseSource::build(None, Some("key".to_owned()), None, UserFilters::default()); + assert!(matches!( + missing_url, + Err(ImportError::MissingArg("--source-url")) + )); + + let missing_key = SupabaseSource::build( + Some("https://abc.supabase.co".to_owned()), + None, + None, + UserFilters::default(), + ); + assert!(matches!( + missing_key, + Err(ImportError::MissingArg("--source-token")) + )); + } +} From a2640017378a092af44da053a727ab160b1d16c6 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 20 Sep 2026 18:04:03 +0200 Subject: [PATCH 2/2] feat(import): map supabase app_metadata roles onto realm roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase has no role catalogue, so roles are read from each user's app_metadata, where applications conventionally keep them: a `roles` array, a single `role` string, or both. They are merged with the first occurrence winning, so a project that moved between the two conventions neither loses a role nor gains a duplicate. The realm catalogue is derived from the users that survived the filters, not from the raw directory. A role held only by a soft-deleted, anonymous or unconfirmed account is therefore never created in the target realm. Verified against the stub server: `ghost-only-role` and `unconfirmed-only-role` are absent under the default filters and reappear once the filters are lifted. Supabase attaches neither description nor permission to a role, so every catalogue entry carries a name and nothing else. The top-level `user.role` is deliberately not imported. It is the Postgres RLS role, `authenticated` for virtually every account; mapping it would create a single realm role held by the entire directory, which tells a reader nothing. A role name containing ':' is now a hard error at the source boundary. `parse_role_ref` splits a `UserBlueprint.roles` entry on the first colon to get `client_id:role_name`, so a namespaced Supabase role such as `billing:read` would have been read as the role `read` of a client `billing`. Supabase produces no clients, so the import would have failed deep in apply.rs with an UnresolvedClientRole error talking about clients the operator never defined. Failing early with a message naming the role, the user and the fix is strictly better than that. The two subtle behaviours here — the colon rejection and the sorted, deduplicated catalogue — were checked by mutation: removing the guard, and swapping the BTreeSet for a Vec, each made exactly one test fail. This commit also strips the comments from supabase.rs and the one added to sources/mod.rs, per project preference. The doc comments on the clap structures are kept: clap reads them to build `--help`, so they are output, not commentary. The reasoning the comments carried lives in this history and in the pull request instead, where it cannot drift out of sync with the code. --- README.md | 30 +- libs/ferriskey-cli-core/src/import/mod.rs | 6 + .../src/import/sources/mod.rs | 3 - .../src/import/sources/supabase.rs | 279 +++++++++++++----- 4 files changed, 236 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index d4960c0..70b00d1 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ project's `service_role` key. --source-token \ --target-realm my-realm -Supabase has no realm, no OIDC client and no role catalogue of its own, so the -import carries **users only**. The realm name comes from `--target-realm` (or +Supabase has no realm and no OIDC client, so the import carries **users and +their roles**, and nothing else. The realm name comes from `--target-realm` (or `--source-realm`) and defaults to `supabase`. **Passwords are not migrated.** Supabase keeps bcrypt hashes in @@ -49,6 +49,32 @@ Usernames are derived from the full email address, falling back to the phone number and then to the Supabase user id, since Supabase users have no username of their own. +#### Roles + +Supabase has no role catalogue. Roles are read from each user's `app_metadata`, +which is where applications conventionally keep them — either as a `roles` array +or as a single `role` string. Both are read and merged: + + "app_metadata": { "provider": "email", "roles": ["admin", "billing"] } + "app_metadata": { "provider": "email", "role": "admin" } + +Every distinct role named by an imported user becomes a realm role. The +catalogue is built from the users that survived the filters above, so a role +held only by a soft-deleted or unconfirmed account is not created. + +Supabase attaches no description or permission to a role, so imported roles +carry a name and nothing else. Permissions have to be granted in FerrisKey +afterwards. + +The top-level `user.role` field is **not** imported. It is the Postgres RLS +role, `authenticated` for virtually every account, and importing it would create +a single realm role held by the entire directory. + +A role name containing `:` is rejected with an error naming the role and the +user. A realm blueprint reserves that character for client-scoped roles +(`client_id:role_name`), and Supabase defines no clients, so such a name cannot +be expressed. Rename it in `app_metadata` before importing. + Three kinds of account are dropped by default, each re-enabled by its own flag: | Flag | Keeps | diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index 2eb81ce..5e89068 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -237,6 +237,12 @@ pub enum ImportError { pin a single organization with --source-org / org-id, or grant the token an IAM manager role" )] ZitadelOrgListingForbidden, + #[error( + "Supabase role '{role}' on user '{username}' contains ':', which a realm blueprint \ + reserves for client-scoped roles ('client_id:role_name'); Supabase defines no clients, \ + so rename the role in app_metadata before importing" + )] + SupabaseNamespacedRole { role: String, username: String }, #[error("stored source '{name}' has kind '{kind}', which is not a valid import kind")] InvalidStoredKind { name: String, kind: String }, #[error("failed to read source file '{path}'")] diff --git a/libs/ferriskey-cli-core/src/import/sources/mod.rs b/libs/ferriskey-cli-core/src/import/sources/mod.rs index 99b6c5f..53a7290 100644 --- a/libs/ferriskey-cli-core/src/import/sources/mod.rs +++ b/libs/ferriskey-cli-core/src/import/sources/mod.rs @@ -68,9 +68,6 @@ fn build_from_inline( } } -/// Supabase's account filters are per-run choices about which rows to replay, -/// so they come from the flags only and are never read back from a stored -/// source — a saved source must not silently widen a later import. fn user_filters(args: &RealmImportArgs) -> UserFilters { UserFilters { include_deleted: args.source_include_deleted, diff --git a/libs/ferriskey-cli-core/src/import/sources/supabase.rs b/libs/ferriskey-cli-core/src/import/sources/supabase.rs index af49018..a2f6063 100644 --- a/libs/ferriskey-cli-core/src/import/sources/supabase.rs +++ b/libs/ferriskey-cli-core/src/import/sources/supabase.rs @@ -1,42 +1,24 @@ -//! Reads a Supabase project through its Auth (GoTrue) Admin API and maps the -//! users onto a [`RealmBlueprint`]. -//! -//! Supabase has no realm, no OIDC client and no role catalogue of its own, so -//! an import carries users and nothing else. The realm name comes from -//! `--target-realm` (or `--source-realm`) and defaults to `supabase`. -//! -//! Passwords are never carried over. Supabase keeps bcrypt hashes in -//! `auth.users.encrypted_password` and does not serve them over the Admin API, -//! and the FerrisKey API accepts only a plaintext password on its -//! `reset-password` endpoint — neither side exposes a hash. Imported users -//! therefore arrive without credentials and have to go through a reset. -//! -//! Authentication uses the project's `service_role` key, passed with -//! `--source-token`; it is sent both as the `apikey` header Supabase's gateway -//! expects and as the bearer token GoTrue itself checks. - -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use reqwest::blocking::Client; use serde::{Deserialize, Deserializer}; use serde_json::{Map, Value}; -use crate::import::{ImportError, RealmBlueprint, RealmSource, UserBlueprint}; +use crate::import::{ImportError, RealmBlueprint, RealmSource, RoleBlueprint, UserBlueprint}; const SOURCE: &str = "supabase"; const USER_PAGE_SIZE: usize = 100; const DEFAULT_REALM_NAME: &str = "supabase"; +const CLIENT_SCOPE_SEPARATOR: char = ':'; + +const ROLE_LIST_KEY: &str = "roles"; +const ROLE_SINGLE_KEY: &str = "role"; + const FIRST_NAME_KEYS: [&str; 3] = ["first_name", "firstName", "given_name"]; const LAST_NAME_KEYS: [&str; 3] = ["last_name", "lastName", "family_name"]; const FULL_NAME_KEYS: [&str; 2] = ["full_name", "name"]; -/// Which Supabase accounts an import carries over. -/// -/// The user table holds rows a migration usually should not replay: accounts an -/// operator soft-deleted, anonymous sign-in sessions, and addresses nobody ever -/// confirmed. Each is dropped by default; `Default` is therefore the strictest -/// setting, and every flag only ever widens what is kept. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct UserFilters { pub include_deleted: bool, @@ -45,13 +27,6 @@ pub struct UserFilters { } impl UserFilters { - /// Whether `user` survives the filters. - /// - /// The confirmation filter only judges accounts that have something to - /// confirm. An anonymous account has neither address nor phone, so it is - /// governed by `include_anonymous` alone — were it also subject to the - /// confirmation filter, asking to keep anonymous users would still drop - /// every one of them. fn keeps(&self, user: &SupabaseUser) -> bool { if user.deleted_at.is_some() && !self.include_deleted { return false; @@ -72,8 +47,6 @@ pub struct SupabaseSource { } impl SupabaseSource { - /// Builds the source from resolved option values (inline flags already - /// merged over any stored source). pub fn build( base_url: Option, service_role_key: Option, @@ -93,7 +66,6 @@ impl SupabaseSource { }) } - /// Reads one page of the admin user list. Pages are 1-indexed. fn page(&self, page: usize) -> Result, ImportError> { let url = format!( "{}/auth/v1/admin/users?page={page}&per_page={USER_PAGE_SIZE}", @@ -119,15 +91,6 @@ impl SupabaseSource { Ok(response.json::()?.users) } - /// Walks every page of the admin user list, keeping what the filters allow. - /// - /// Termination is on "this page brought no id we had not already seen", not - /// on the usual "this page was shorter than the size we asked for". GoTrue - /// caps `per_page` server-side, so a short page is the ordinary case rather - /// than the last one, and the short-page test would stop after the first - /// batch and silently drop the rest of the directory. Tracking ids also - /// bounds the walk against a deployment that ignores `page` and keeps - /// serving the first one. fn all_users(&self) -> Result, ImportError> { let mut seen: HashSet = HashSet::new(); let mut users = Vec::new(); @@ -135,16 +98,18 @@ impl SupabaseSource { loop { let batch = self.page(page)?; - let known = seen.len(); + let seen_before = seen.len(); for user in batch { if !seen.insert(user.id.clone()) { continue; } if self.filters.keeps(&user) { - users.push(map_user(user)); + users.push(map_user(user)?); } } - if seen.len() == known { + + let page_brought_nothing_new = seen.len() == seen_before; + if page_brought_nothing_new { return Ok(users); } page += 1; @@ -158,20 +123,18 @@ impl RealmSource for SupabaseSource { .realm_name .clone() .unwrap_or_else(|| DEFAULT_REALM_NAME.to_owned()); + let users = self.all_users()?; Ok(vec![RealmBlueprint { name, settings: None, - roles: Vec::new(), + roles: role_catalogue(&users), clients: Vec::new(), - users: self.all_users()?, + users, }]) } } -/// Accepts both the project URL and one already pointing at the Auth API, so -/// `https://abc.supabase.co` and `https://abc.supabase.co/auth/v1` both reach -/// the same endpoint instead of one of them 404ing on a doubled path. fn normalize_base_url(url: &str) -> String { url.trim_end_matches('/') .trim_end_matches("/auth/v1") @@ -179,7 +142,7 @@ fn normalize_base_url(url: &str) -> String { .to_owned() } -fn map_user(user: SupabaseUser) -> UserBlueprint { +fn map_user(user: SupabaseUser) -> Result { let (firstname, lastname) = names_from_metadata(&user.user_metadata); let email_verified = user .email @@ -189,24 +152,65 @@ fn map_user(user: SupabaseUser) -> UserBlueprint { .email .clone() .or_else(|| user.phone.clone()) - .unwrap_or(user.id); + .unwrap_or_else(|| user.id.clone()); + let roles = roles_from_metadata(&user.app_metadata, &username)?; - UserBlueprint { + Ok(UserBlueprint { username, email: user.email, firstname, lastname, email_verified, - roles: Vec::new(), + roles, + }) +} + +fn role_catalogue(users: &[UserBlueprint]) -> Vec { + users + .iter() + .flat_map(|user| &user.roles) + .map(String::as_str) + .collect::>() + .into_iter() + .map(|name| RoleBlueprint { + name: name.to_owned(), + description: None, + permissions: Vec::new(), + }) + .collect() +} + +fn roles_from_metadata( + metadata: &Map, + username: &str, +) -> Result, ImportError> { + let listed = metadata + .get(ROLE_LIST_KEY) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_str); + let single = metadata.get(ROLE_SINGLE_KEY).and_then(Value::as_str); + + let mut roles: Vec = Vec::new(); + for name in listed.chain(single) { + let name = name.trim(); + if name.is_empty() || roles.iter().any(|kept| kept == name) { + continue; + } + if name.contains(CLIENT_SCOPE_SEPARATOR) { + return Err(ImportError::SupabaseNamespacedRole { + role: name.to_owned(), + username: username.to_owned(), + }); + } + roles.push(name.to_owned()); } + + Ok(roles) } -/// Pulls a first and last name out of Supabase's free-form `user_metadata`. -/// -/// There is no profile schema behind that field: an email signup stores -/// whatever the application wrote into it, while an OAuth provider stores the -/// OIDC claims it received. The explicit keys are tried first, then a single -/// display name is split on its first run of whitespace. fn names_from_metadata(metadata: &Map) -> (Option, Option) { let first = first_string(metadata, &FIRST_NAME_KEYS); let last = first_string(metadata, &LAST_NAME_KEYS); @@ -244,9 +248,6 @@ fn split_full_name(full: &str) -> (Option, Option) { } } -/// Supabase serialises an absent email or phone as `""` rather than `null`, so -/// a plain `Option` would carry an empty string into the blueprint and -/// create users with a blank address. fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -280,6 +281,8 @@ struct SupabaseUser { is_anonymous: bool, #[serde(default)] user_metadata: Map, + #[serde(default)] + app_metadata: Map, } impl SupabaseUser { @@ -305,6 +308,7 @@ mod tests { deleted_at: None, is_anonymous: false, user_metadata: Map::new(), + app_metadata: Map::new(), } } @@ -323,9 +327,24 @@ mod tests { .collect() } + fn json_metadata(raw: &str) -> Map { + serde_json::from_str(raw).expect("metadata fixture") + } + + fn user_with_roles(email: &str, roles: &[&str]) -> UserBlueprint { + UserBlueprint { + username: email.to_owned(), + email: Some(email.to_owned()), + firstname: None, + lastname: None, + email_verified: Some(true), + roles: roles.iter().map(|role| (*role).to_owned()).collect(), + } + } + #[test] fn uses_the_full_email_as_username() { - let blueprint = map_user(confirmed_email_user("id-1", "alice@acme.test")); + let blueprint = map_user(confirmed_email_user("id-1", "alice@acme.test")).expect("map"); assert_eq!(blueprint.username, "alice@acme.test"); assert_eq!(blueprint.email.as_deref(), Some("alice@acme.test")); assert_eq!(blueprint.email_verified, Some(true)); @@ -336,14 +355,15 @@ mod tests { let blueprint = map_user(SupabaseUser { phone: Some("+33612345678".to_owned()), ..user("id-2") - }); + }) + .expect("map"); assert_eq!(blueprint.username, "+33612345678"); assert_eq!(blueprint.email, None); } #[test] fn falls_back_to_the_supabase_id_when_there_is_neither() { - let blueprint = map_user(user("8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11")); + let blueprint = map_user(user("8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11")).expect("map"); assert_eq!(blueprint.username, "8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11"); } @@ -352,7 +372,8 @@ mod tests { let blueprint = map_user(SupabaseUser { email: Some("bob@acme.test".to_owned()), ..user("id-3") - }); + }) + .expect("map"); assert_eq!(blueprint.email_verified, Some(false)); } @@ -361,7 +382,8 @@ mod tests { let blueprint = map_user(SupabaseUser { phone: Some("+33612345678".to_owned()), ..user("id-4") - }); + }) + .expect("map"); assert_eq!(blueprint.email_verified, None); } @@ -463,10 +485,8 @@ mod tests { #[test] fn reads_oidc_claim_names_from_metadata() { - let (first, last) = names_from_metadata(&metadata(&[ - ("given_name", "Alice"), - ("family_name", "Doe"), - ])); + let (first, last) = + names_from_metadata(&metadata(&[("given_name", "Alice"), ("family_name", "Doe")])); assert_eq!(first.as_deref(), Some("Alice")); assert_eq!(last.as_deref(), Some("Doe")); } @@ -502,17 +522,122 @@ mod tests { let blueprint = map_user(SupabaseUser { user_metadata: metadata(&[("full_name", "Alice Doe")]), ..confirmed_email_user("id-13", "alice@acme.test") - }); + }) + .expect("map"); assert_eq!(blueprint.firstname.as_deref(), Some("Alice")); assert_eq!(blueprint.lastname.as_deref(), Some("Doe")); } #[test] - fn imports_no_roles() { - let blueprint = map_user(confirmed_email_user("id-14", "alice@acme.test")); + fn reads_a_roles_array_from_app_metadata() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":["admin","billing"]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned(), "billing".to_owned()]); + } + + #[test] + fn reads_a_single_role_string_from_app_metadata() { + let roles = roles_from_metadata(&json_metadata(r#"{"role":"admin"}"#), "alice@acme.test") + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned()]); + } + + #[test] + fn merges_both_role_conventions_without_duplicating() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":["admin","billing"],"role":"admin"}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned(), "billing".to_owned()]); + } + + #[test] + fn never_reads_supabase_own_app_metadata_keys_as_roles() { + let roles = roles_from_metadata( + &json_metadata(r#"{"provider":"email","providers":["email","google"]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert!(roles.is_empty()); + } + + #[test] + fn ignores_role_entries_that_are_not_strings() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":["admin",42,null,{"a":1}]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned()]); + } + + #[test] + fn skips_blank_role_names() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":[" ","admin",""]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned()]); + } + + #[test] + fn rejects_a_role_name_that_collides_with_the_client_scope_syntax() { + let rejected = roles_from_metadata( + &json_metadata(r#"{"roles":["billing:read"]}"#), + "alice@acme.test", + ); + assert!(matches!( + rejected, + Err(ImportError::SupabaseNamespacedRole { role, username }) + if role == "billing:read" && username == "alice@acme.test" + )); + } + + #[test] + fn carries_roles_onto_the_user_blueprint() { + let blueprint = map_user(SupabaseUser { + app_metadata: json_metadata(r#"{"provider":"email","roles":["admin"]}"#), + ..confirmed_email_user("id-15", "alice@acme.test") + }) + .expect("map"); + assert_eq!(blueprint.roles, vec!["admin".to_owned()]); + } + + #[test] + fn imports_no_roles_when_app_metadata_names_none() { + let blueprint = map_user(confirmed_email_user("id-14", "alice@acme.test")).expect("map"); assert!(blueprint.roles.is_empty()); } + #[test] + fn builds_a_sorted_deduplicated_catalogue_from_the_users() { + let catalogue = role_catalogue(&[ + user_with_roles("alice@acme.test", &["billing", "admin"]), + user_with_roles("bob@acme.test", &["admin", "support"]), + ]); + let names: Vec<&str> = catalogue.iter().map(|role| role.name.as_str()).collect(); + assert_eq!(names, vec!["admin", "billing", "support"]); + } + + #[test] + fn catalogue_carries_no_description_or_permission() { + let catalogue = role_catalogue(&[user_with_roles("alice@acme.test", &["admin"])]); + assert_eq!(catalogue.len(), 1); + assert_eq!(catalogue[0].description, None); + assert!(catalogue[0].permissions.is_empty()); + } + + #[test] + fn catalogue_is_empty_when_no_user_names_a_role() { + let catalogue = role_catalogue(&[user_with_roles("alice@acme.test", &[])]); + assert!(catalogue.is_empty()); + } + #[test] fn accepts_a_project_url_with_or_without_the_auth_path() { assert_eq!(