From 7c9809a2af5a402a66126e4716a3e798237040a7 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 11:19:48 +0200 Subject: [PATCH 1/4] feat(client): add the credentials/import endpoint to the sdk FerrisKey gained POST /realms/{realm}/users/{user_id}/credentials/import, which stores a password hash produced by another identity provider and re-encodes it as argon2id on the user's first successful login. The request type carries salt and temporary even though the realm-import caller sends neither: bcrypt and argon2 embed their salt in secret_data, and an imported password must stay usable rather than force a reset. This crate is published as the FerrisKey SDK, so it mirrors the endpoint rather than only the one shape the CLI happens to need today. --- libs/ferriskey-cli-client/src/lib.rs | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/libs/ferriskey-cli-client/src/lib.rs b/libs/ferriskey-cli-client/src/lib.rs index 48d6459..d575ef3 100644 --- a/libs/ferriskey-cli-client/src/lib.rs +++ b/libs/ferriskey-cli-client/src/lib.rs @@ -281,6 +281,16 @@ pub struct SetPasswordRequest { pub temporary: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct ImportPasswordCredentialRequest { + pub algorithm: String, + pub secret_data: String, + pub hash_iterations: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub salt: Option, + pub temporary: bool, +} + impl FerriskeyClient { pub fn new( base_url: impl Into, @@ -809,6 +819,30 @@ impl FerriskeyClient { Ok(()) } + pub fn import_password_credential( + &self, + realm: &str, + user_id: &str, + request: &ImportPasswordCredentialRequest, + ) -> Result<(), FerriskeyClientError> { + let response = self + .http + .post(self.endpoint(&format!( + "realms/{realm}/users/{user_id}/credentials/import" + ))) + .bearer_auth(&self.token) + .json(request) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(FerriskeyClientError::Api { status, body }); + } + + Ok(()) + } + /// RFC 8628 §3.1 — start a device authorization flow. pub fn device_authorization( &self, From 175315ae55bb950c65249c2d204ebb6213853f88 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 11:20:00 +0200 Subject: [PATCH 2/4] feat(import): carry supabase password hashes from a csv export `realm import --from supabase --source-passwords ` migrates passwords, instead of leaving every imported account needing a reset. The export is unavoidable. Supabase's Auth Admin API never serves encrypted_password, so the hash can only come out of the auth.users table. A direct Postgres connection is the planned second path; the CSV is the first because it needs no new credential, no dependency beyond a CSV parser, and works with whatever export route the operator already has. Rows join onto users by auth.users.id, never by email: email is nullable in Supabase, so it is not a key. A hash the server would refuse never enters a blueprint. The prefix window ($2a$, $2b$, $2y$), the 4..=14 cost range and the 53-character body are the server's own rules, restated here so --dry-run tells the truth and a bad row is skipped with a note naming the account instead of aborting a migration halfway through. hash_iterations is read out of the hash itself, because the server rejects any value that differs from the encoded cost. An empty encrypted_password is counted, not warned about: those accounts sign in through a federated provider and have no password to carry. Warning per row would bury the real problems under the normal case. A failed import is a warning rather than a hard stop, unlike every other entity in apply_blueprint. A password is secondary to the user that now exists and is recoverable with `user set-password`, so a single malformed hash must not throw away the whole report after three thousand users. --dry-run redacts every hash in its json and yaml output. Printing the directory's credentials into a terminal that gets scrolled, logged and pasted into tickets is not an acceptable default for a preview. The csv crate earns its place: a `select *` export carries raw_user_meta_data as JSON, full of commas and quotes that a hand-rolled split would tear apart. Header lookup strips a UTF-8 BOM. Supabase's SQL editor emits one on its CSV download, and without this the friendliest export route would fail with "has no 'id' column" on a perfectly good file. Argon2 and Firebase-scrypt hashes, which a project that itself imported users into Supabase may hold, are not carried over yet even though FerrisKey accepts argon2. They are rejected visibly rather than silently mishandled. --- Cargo.lock | 22 ++ README.md | 60 ++- libs/ferriskey-cli-commands/src/realm.rs | 16 +- libs/ferriskey-cli-core/Cargo.toml | 1 + libs/ferriskey-cli-core/src/import/apply.rs | 43 +++ libs/ferriskey-cli-core/src/import/mod.rs | 111 +++++- .../src/import/sources/keycloak.rs | 1 + .../src/import/sources/mod.rs | 153 ++++++-- .../src/import/sources/supabase.rs | 113 +++++- .../src/import/sources/supabase_passwords.rs | 342 ++++++++++++++++++ .../src/import/sources/zitadel.rs | 1 + libs/ferriskey-cli-core/src/realm.rs | 93 ++++- 12 files changed, 893 insertions(+), 63 deletions(-) create mode 100644 libs/ferriskey-cli-core/src/import/sources/supabase_passwords.rs diff --git a/Cargo.lock b/Cargo.lock index 8ee4aa7..5ff90c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -252,6 +273,7 @@ name = "ferriskey-cli-core" version = "0.3.0" dependencies = [ "base64", + "csv", "ctrlc", "ferriskey-cli-client", "ferriskey-cli-commands", diff --git a/README.md b/README.md index 70b00d1..83f0c7c 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,64 @@ 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 -`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. +#### Passwords + +Passwords are carried over when `--source-passwords` points at a CSV export of +the `auth.users` table: + + ferris-ctl realm import \ + --from supabase \ + --source-url https://.supabase.co \ + --source-token \ + --source-passwords ./auth_users.csv \ + --target-realm my-realm + +The export is needed because the Auth Admin API never serves password hashes: +they live only in `auth.users.encrypted_password`. Produce it once from the +Supabase SQL editor (then *Download CSV*), or with `psql`: + + select id, encrypted_password from auth.users; + +Only `id` and `encrypted_password` are read, and extra columns are ignored — a +plain `select *` export works as-is. Rows are joined onto users by +`auth.users.id`, never by email: an email is nullable in Supabase and is +therefore not a key. + +FerrisKey stores the bcrypt hash verbatim and re-encodes it as argon2id on the +user's first successful login, so the migration is invisible to the end user and +leaves nothing legacy behind. + +A hash FerrisKey would refuse never leaves the CLI. It is skipped with a note +naming the account, and the import carries on: + +| Skipped | Why | +|---------|-----| +| A prefix other than `$2a$`, `$2b$`, `$2y$` | FerrisKey accepts no other bcrypt variant, and `$2x$` is a known-broken one | +| A cost outside `4..=14` | Outside the window FerrisKey accepts on import | +| A hash body that is not 53 characters | Truncated in the export | +| An empty `encrypted_password` | Not an error: the account signs in through a federated provider and has no password | + +Argon2 and Firebase-scrypt hashes — which a project that itself imported users +into Supabase may hold — are **not** carried over yet, even though FerrisKey +accepts argon2. Those accounts need a password reset. + +A user who already has a password in FerrisKey keeps it: the import reports the +clash in `already present` rather than overwriting a credential somebody set +deliberately. + +`--dry-run` prints the password **count** and replaces every hash with +`` in its `-o json` / `-o yaml` output, so a preview can be pasted into +a ticket without leaking the directory's credentials. + +`--source-passwords` only applies to `--from supabase`; passing it to another +source is an error rather than a silently ignored flag. Like the account filters +below, it is never stored in a saved source — carrying passwords is a per-run +decision. + #### Roles Supabase has no role catalogue. Roles are read from each user's `app_metadata`, diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index c0b13a7..7a2ea59 100644 --- a/libs/ferriskey-cli-commands/src/realm.rs +++ b/libs/ferriskey-cli-commands/src/realm.rs @@ -145,14 +145,14 @@ pub enum ImportSource { /// 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). + /// only: Supabase has no client or role catalogue. Password hashes are + /// carried over when `--source-passwords` points at a CSV export of + /// `auth.users`; without it, imported users need a password reset. Supabase, } /// Arguments for `realm import`. -#[derive(Debug, Args)] +#[derive(Debug, Default, Args)] pub struct RealmImportArgs { /// Source kind to import from. Optional when `--source-ref` is given (the /// kind is then read from the stored source). @@ -210,6 +210,14 @@ pub struct RealmImportArgs { #[arg(long = "source-include-unconfirmed", default_value_t = false)] pub source_include_unconfirmed: bool, + /// Carry Supabase passwords over, read from a CSV export of the `auth.users` + /// table (`select id, encrypted_password from auth.users`). The Auth Admin + /// API never serves those hashes, so the export is the only way to get them. + /// Only bcrypt hashes FerrisKey accepts are imported; every other account + /// arrives without credentials and needs a password reset. + #[arg(long = "source-passwords", value_name = "FILE")] + pub source_passwords: Option, + /// 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-core/Cargo.toml b/libs/ferriskey-cli-core/Cargo.toml index 0db8155..dc52612 100644 --- a/libs/ferriskey-cli-core/Cargo.toml +++ b/libs/ferriskey-cli-core/Cargo.toml @@ -13,6 +13,7 @@ doctest = false [dependencies] base64 = "0.22" +csv = "1.3" ctrlc = "3.4" ferriskey-cli-client = { path = "../ferriskey-cli-client", version = "0.3.0" } ferriskey-cli-commands = { path = "../ferriskey-cli-commands", version = "0.3.0" } diff --git a/libs/ferriskey-cli-core/src/import/apply.rs b/libs/ferriskey-cli-core/src/import/apply.rs index 5103793..8a4c6e2 100644 --- a/libs/ferriskey-cli-core/src/import/apply.rs +++ b/libs/ferriskey-cli-core/src/import/apply.rs @@ -65,6 +65,11 @@ pub fn apply_blueprint( report.client_roles_created = blueprint.clients.iter().map(|c| c.roles.len()).sum(); report.users_created = blueprint.users.len(); report.role_assignments = blueprint.users.iter().map(|u| u.roles.len()).sum(); + report.passwords_imported = blueprint + .users + .iter() + .filter(|u| u.credential.is_some()) + .count(); return Ok(report); } @@ -449,6 +454,23 @@ pub fn apply_blueprint( let Some(user_id) = user_id else { continue }; + if let Some(credential) = &user.credential { + match client.import_password_credential(realm, &user_id, &credential.to_request()) { + Ok(()) => report.passwords_imported += 1, + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "user '{}' already has a password, keeping it", + user.username + )); + } + Err(e) => report.warnings.push(format!( + "could not import the password of user '{}': {e}", + user.username + )), + } + } + // A second assignment of a role the user already holds is a duplicate // write server-side, so an existing user's roles are read first. let assigned_roles: HashSet = if user_existed && !user.roles.is_empty() { @@ -737,6 +759,7 @@ mod tests { lastname: None, email_verified: None, roles: vec!["admin".to_owned()], + credential: None, }], } } @@ -762,6 +785,26 @@ mod tests { assert!(report.warnings.is_empty()); } + #[test] + fn dry_run_counts_the_passwords_it_would_import() { + let mut bp = sample_blueprint(); + bp.users[0].credential = Some(crate::import::PasswordCredentialBlueprint { + algorithm: "bcrypt".to_owned(), + secret_data: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy".to_owned(), + hash_iterations: 10, + }); + let client = FerriskeyClient::new("http://localhost:3333", "", "").unwrap(); + let report = apply_blueprint(&client, &bp, true).unwrap(); + assert_eq!(report.passwords_imported, 1); + } + + #[test] + fn dry_run_counts_no_password_when_the_blueprint_carries_none() { + let client = FerriskeyClient::new("http://localhost:3333", "", "").unwrap(); + let report = apply_blueprint(&client, &sample_blueprint(), true).unwrap(); + assert_eq!(report.passwords_imported, 0); + } + #[test] fn dry_run_empty_settings_not_counted() { let mut bp = sample_blueprint(); diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index 5e89068..7f1af46 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -9,7 +9,8 @@ pub mod apply; pub mod sources; use ferriskey_cli_client::{ - FerriskeyClientError, UpdateClientSettingsRequest, UpdateRealmSettingsRequest, + FerriskeyClientError, ImportPasswordCredentialRequest, UpdateClientSettingsRequest, + UpdateRealmSettingsRequest, }; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -160,8 +161,38 @@ pub struct UserBlueprint { /// `client_id:role_name` for a role scoped to that client. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub roles: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PasswordCredentialBlueprint { + pub algorithm: String, + pub secret_data: String, + pub hash_iterations: u32, +} + +impl PasswordCredentialBlueprint { + pub fn to_request(&self) -> ImportPasswordCredentialRequest { + ImportPasswordCredentialRequest { + algorithm: self.algorithm.clone(), + secret_data: self.secret_data.clone(), + hash_iterations: self.hash_iterations, + salt: None, + temporary: false, + } + } + + pub fn redacted(&self) -> Self { + Self { + secret_data: REDACTED_SECRET.to_owned(), + ..self.clone() + } + } } +const REDACTED_SECRET: &str = ""; + fn default_client_type() -> String { "public".to_owned() } @@ -197,6 +228,7 @@ pub struct ImportReport { pub client_roles_created: usize, pub users_created: usize, pub role_assignments: usize, + pub passwords_imported: usize, /// Entities skipped because they already existed — distinguishes a /// converging replay from a run that did nothing. pub already_present: usize, @@ -243,6 +275,21 @@ pub enum ImportError { so rename the role in app_metadata before importing" )] SupabaseNamespacedRole { role: String, username: String }, + #[error( + "--source-passwords only applies to '--from supabase'; the '{0}' source carries no password export" + )] + PasswordsUnsupportedBySource(&'static str), + #[error("failed to read the Supabase password export '{path}'")] + PasswordCsv { + path: String, + #[source] + source: csv::Error, + }, + #[error( + "the Supabase password export '{path}' has no '{column}' column — export it with \ + `select id, encrypted_password from auth.users`" + )] + PasswordCsvColumnMissing { path: String, column: &'static str }, #[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}'")] @@ -275,6 +322,63 @@ pub enum ImportError { mod tests { use super::*; + const BCRYPT_HASH: &str = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + + fn credential() -> PasswordCredentialBlueprint { + PasswordCredentialBlueprint { + algorithm: "bcrypt".to_owned(), + secret_data: BCRYPT_HASH.to_owned(), + hash_iterations: 10, + } + } + + #[test] + fn credential_request_sends_no_salt_and_is_never_temporary() { + let request = credential().to_request(); + assert_eq!(request.algorithm, "bcrypt"); + assert_eq!(request.secret_data, BCRYPT_HASH); + assert_eq!(request.hash_iterations, 10); + assert!( + request.salt.is_none(), + "bcrypt and argon2 embed their salt in secret_data" + ); + assert!( + !request.temporary, + "an imported password must stay usable, not force a reset" + ); + } + + #[test] + fn credential_request_omits_the_salt_from_the_wire() { + let json = serde_json::to_value(credential().to_request()).expect("serialize"); + assert!(json.get("salt").is_none()); + assert_eq!(json["hash_iterations"], 10); + } + + #[test] + fn redacting_a_credential_drops_the_hash_and_keeps_its_shape() { + let redacted = credential().redacted(); + assert_eq!(redacted.algorithm, "bcrypt"); + assert_eq!(redacted.hash_iterations, 10); + assert_ne!(redacted.secret_data, BCRYPT_HASH); + assert!(!redacted.secret_data.contains("$2a$")); + } + + #[test] + fn a_user_without_credential_serializes_without_the_field() { + let user = UserBlueprint { + username: "alice".to_owned(), + email: None, + firstname: None, + lastname: None, + email_verified: None, + roles: Vec::new(), + credential: None, + }; + let yaml = serde_yaml::to_string(&user).expect("serialize"); + assert!(!yaml.contains("credential")); + } + #[test] fn blueprint_yaml_round_trip() { let bp = RealmBlueprint { @@ -316,6 +420,11 @@ mod tests { lastname: None, email_verified: Some(true), roles: vec!["admin".to_owned()], + credential: Some(PasswordCredentialBlueprint { + algorithm: "bcrypt".to_owned(), + secret_data: BCRYPT_HASH.to_owned(), + hash_iterations: 10, + }), }], }; diff --git a/libs/ferriskey-cli-core/src/import/sources/keycloak.rs b/libs/ferriskey-cli-core/src/import/sources/keycloak.rs index 775ec12..bddd00e 100644 --- a/libs/ferriskey-cli-core/src/import/sources/keycloak.rs +++ b/libs/ferriskey-cli-core/src/import/sources/keycloak.rs @@ -219,6 +219,7 @@ fn map_user(user: KcUser) -> crate::import::UserBlueprint { lastname: user.last_name, email_verified: user.email_verified, roles: Vec::new(), + credential: None, } } diff --git a/libs/ferriskey-cli-core/src/import/sources/mod.rs b/libs/ferriskey-cli-core/src/import/sources/mod.rs index 53a7290..8b2a0cf 100644 --- a/libs/ferriskey-cli-core/src/import/sources/mod.rs +++ b/libs/ferriskey-cli-core/src/import/sources/mod.rs @@ -4,6 +4,7 @@ pub mod config; pub mod keycloak; pub mod supabase; +pub mod supabase_passwords; pub mod zitadel; use ferriskey_cli_commands::{ImportSource, RealmImportArgs}; @@ -43,31 +44,50 @@ fn build_from_inline( ) -> Result, ImportError> { match kind { ImportSource::Config => { + reject_passwords(args, "config")?; let path = args.file.clone().ok_or(ImportError::MissingArg("--file"))?; Ok(Box::new(ConfigSource::new(path))) } - ImportSource::Keycloak => Ok(Box::new(KeycloakSource::build( - args.source_url.clone(), - args.source_realm.clone(), - args.source_client_id.clone(), - args.source_client_secret.clone(), - args.source_token.clone(), - )?)), - ImportSource::Zitadel => Ok(Box::new(ZitadelSource::build( - args.source_url.clone(), - args.source_token.clone(), - args.source_org.clone(), - args.target_realm.clone().or_else(|| args.source_realm.clone()), - )?)), + ImportSource::Keycloak => { + reject_passwords(args, "keycloak")?; + Ok(Box::new(KeycloakSource::build( + args.source_url.clone(), + args.source_realm.clone(), + args.source_client_id.clone(), + args.source_client_secret.clone(), + args.source_token.clone(), + )?)) + } + ImportSource::Zitadel => { + reject_passwords(args, "zitadel")?; + Ok(Box::new(ZitadelSource::build( + args.source_url.clone(), + args.source_token.clone(), + 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()), + args.target_realm + .clone() + .or_else(|| args.source_realm.clone()), user_filters(args), + args.source_passwords.clone(), )?)), } } +fn reject_passwords(args: &RealmImportArgs, kind: &'static str) -> Result<(), ImportError> { + match args.source_passwords { + Some(_) => Err(ImportError::PasswordsUnsupportedBySource(kind)), + None => Ok(()), + } +} + fn user_filters(args: &RealmImportArgs) -> UserFilters { UserFilters { include_deleted: args.source_include_deleted, @@ -82,24 +102,32 @@ fn build_from_stored( args: &RealmImportArgs, ) -> Result, ImportError> { match stored.kind.as_str() { - "keycloak" => Ok(Box::new(KeycloakSource::build( - args.source_url.clone().or_else(|| Some(stored.url.clone())), - args.source_realm.clone().or_else(|| stored.realm.clone()), - args.source_client_id.clone().or_else(|| stored.client_id.clone()), - args.source_client_secret - .clone() - .or_else(|| stored.client_secret.clone()), - args.source_token.clone().or_else(|| stored.token.clone()), - )?)), - "zitadel" => Ok(Box::new(ZitadelSource::build( - args.source_url.clone().or_else(|| Some(stored.url.clone())), - args.source_token.clone().or_else(|| stored.token.clone()), - args.source_org.clone().or_else(|| stored.org_id.clone()), - args.target_realm - .clone() - .or_else(|| args.source_realm.clone()) - .or_else(|| stored.realm.clone()), - )?)), + "keycloak" => { + reject_passwords(args, "keycloak")?; + Ok(Box::new(KeycloakSource::build( + args.source_url.clone().or_else(|| Some(stored.url.clone())), + args.source_realm.clone().or_else(|| stored.realm.clone()), + args.source_client_id + .clone() + .or_else(|| stored.client_id.clone()), + args.source_client_secret + .clone() + .or_else(|| stored.client_secret.clone()), + args.source_token.clone().or_else(|| stored.token.clone()), + )?)) + } + "zitadel" => { + reject_passwords(args, "zitadel")?; + Ok(Box::new(ZitadelSource::build( + args.source_url.clone().or_else(|| Some(stored.url.clone())), + args.source_token.clone().or_else(|| stored.token.clone()), + args.source_org.clone().or_else(|| stored.org_id.clone()), + args.target_realm + .clone() + .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()), @@ -108,6 +136,7 @@ fn build_from_stored( .or_else(|| args.source_realm.clone()) .or_else(|| stored.realm.clone()), user_filters(args), + args.source_passwords.clone(), )?)), other => Err(ImportError::InvalidStoredKind { name: name.to_owned(), @@ -115,3 +144,63 @@ fn build_from_stored( }), } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn args_with_passwords() -> RealmImportArgs { + RealmImportArgs { + source_passwords: Some(PathBuf::from("auth_users.csv")), + source_url: Some("https://example.test".to_owned()), + source_token: Some("token".to_owned()), + file: Some(PathBuf::from("realm.yaml")), + ..Default::default() + } + } + + #[test] + fn rejects_a_password_export_on_a_source_that_has_none() { + for (kind, name) in [ + (ImportSource::Config, "config"), + (ImportSource::Keycloak, "keycloak"), + (ImportSource::Zitadel, "zitadel"), + ] { + let built = build_from_inline(&kind, &args_with_passwords()); + assert!( + matches!(built, Err(ImportError::PasswordsUnsupportedBySource(got)) if got == name), + "--source-passwords must not be silently ignored by '{name}'" + ); + } + } + + #[test] + fn rejects_a_password_export_on_a_stored_source_that_has_none() { + for kind in ["keycloak", "zitadel"] { + let stored = StoredSource { + kind: kind.to_owned(), + url: "https://example.test".to_owned(), + realm: None, + client_id: None, + client_secret: None, + token: Some("token".to_owned()), + org_id: None, + }; + let built = build_from_stored("stored", &stored, &args_with_passwords()); + assert!(matches!( + built, + Err(ImportError::PasswordsUnsupportedBySource(_)) + )); + } + } + + #[test] + fn a_missing_password_export_is_reported_against_its_path() { + let built = build_from_inline(&ImportSource::Supabase, &args_with_passwords()); + assert!(matches!( + built, + Err(ImportError::Io { ref path, .. }) if path == "auth_users.csv" + )); + } +} diff --git a/libs/ferriskey-cli-core/src/import/sources/supabase.rs b/libs/ferriskey-cli-core/src/import/sources/supabase.rs index a2f6063..b091e98 100644 --- a/libs/ferriskey-cli-core/src/import/sources/supabase.rs +++ b/libs/ferriskey-cli-core/src/import/sources/supabase.rs @@ -1,9 +1,11 @@ use std::collections::{BTreeSet, HashSet}; +use std::path::PathBuf; use reqwest::blocking::Client; use serde::{Deserialize, Deserializer}; use serde_json::{Map, Value}; +use crate::import::sources::supabase_passwords::PasswordCatalogue; use crate::import::{ImportError, RealmBlueprint, RealmSource, RoleBlueprint, UserBlueprint}; const SOURCE: &str = "supabase"; @@ -43,6 +45,7 @@ pub struct SupabaseSource { service_role_key: String, realm_name: Option, filters: UserFilters, + passwords: PasswordCatalogue, http: Client, } @@ -52,16 +55,22 @@ impl SupabaseSource { service_role_key: Option, realm_name: Option, filters: UserFilters, + passwords_export: Option, ) -> 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"))?; + let passwords = match passwords_export { + Some(path) => PasswordCatalogue::from_csv(&path)?, + None => PasswordCatalogue::default(), + }; Ok(Self { base_url, service_role_key, realm_name, filters, + passwords, http: Client::new(), }) } @@ -104,7 +113,7 @@ impl SupabaseSource { continue; } if self.filters.keeps(&user) { - users.push(map_user(user)?); + users.push(map_user(user, &self.passwords)?); } } @@ -125,6 +134,16 @@ impl RealmSource for SupabaseSource { .unwrap_or_else(|| DEFAULT_REALM_NAME.to_owned()); let users = self.all_users()?; + for warning in self.passwords.warnings() { + eprintln!("note: {warning}"); + } + if self.passwords.without_password() > 0 { + eprintln!( + "note: {} supabase accounts carry no password hash (federated sign-in) and arrive without credentials", + self.passwords.without_password() + ); + } + Ok(vec![RealmBlueprint { name, settings: None, @@ -142,7 +161,10 @@ fn normalize_base_url(url: &str) -> String { .to_owned() } -fn map_user(user: SupabaseUser) -> Result { +fn map_user( + user: SupabaseUser, + passwords: &PasswordCatalogue, +) -> Result { let (firstname, lastname) = names_from_metadata(&user.user_metadata); let email_verified = user .email @@ -154,6 +176,7 @@ fn map_user(user: SupabaseUser) -> Result { .or_else(|| user.phone.clone()) .unwrap_or_else(|| user.id.clone()); let roles = roles_from_metadata(&user.app_metadata, &username)?; + let credential = passwords.get(&user.id); Ok(UserBlueprint { username, @@ -162,6 +185,7 @@ fn map_user(user: SupabaseUser) -> Result { lastname, email_verified, roles, + credential, }) } @@ -339,12 +363,71 @@ mod tests { lastname: None, email_verified: Some(true), roles: roles.iter().map(|role| (*role).to_owned()).collect(), + credential: None, } } + fn mapped(user: SupabaseUser) -> Result { + map_user(user, &PasswordCatalogue::default()) + } + + const BCRYPT_HASH: &str = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + + fn catalogue_for(id: &str) -> PasswordCatalogue { + PasswordCatalogue::from_reader( + format!("id,encrypted_password\n{id},{BCRYPT_HASH}\n") + .into_bytes() + .as_slice(), + "users.csv", + ) + .expect("load") + } + + #[test] + fn joins_a_password_onto_the_user_by_supabase_id() { + let blueprint = map_user( + confirmed_email_user("id-20", "alice@acme.test"), + &catalogue_for("id-20"), + ) + .expect("map"); + let credential = blueprint.credential.expect("credential"); + assert_eq!(credential.algorithm, "bcrypt"); + assert_eq!(credential.secret_data, BCRYPT_HASH); + assert_eq!(credential.hash_iterations, 10); + } + + #[test] + fn joins_on_the_id_rather_than_the_email() { + let blueprint = map_user( + confirmed_email_user("id-21", "alice@acme.test"), + &catalogue_for("alice@acme.test"), + ) + .expect("map"); + assert!( + blueprint.credential.is_none(), + "the export is keyed by auth.users.id; an email is not a join key" + ); + } + + #[test] + fn leaves_a_user_without_credential_when_the_export_has_no_row_for_it() { + let blueprint = map_user( + confirmed_email_user("id-22", "bob@acme.test"), + &catalogue_for("id-20"), + ) + .expect("map"); + assert!(blueprint.credential.is_none()); + } + + #[test] + fn carries_no_credential_when_no_export_was_given() { + let blueprint = mapped(confirmed_email_user("id-23", "alice@acme.test")).expect("map"); + assert!(blueprint.credential.is_none()); + } + #[test] fn uses_the_full_email_as_username() { - let blueprint = map_user(confirmed_email_user("id-1", "alice@acme.test")).expect("map"); + let blueprint = mapped(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)); @@ -352,7 +435,7 @@ mod tests { #[test] fn falls_back_to_phone_when_there_is_no_email() { - let blueprint = map_user(SupabaseUser { + let blueprint = mapped(SupabaseUser { phone: Some("+33612345678".to_owned()), ..user("id-2") }) @@ -363,13 +446,13 @@ mod tests { #[test] fn falls_back_to_the_supabase_id_when_there_is_neither() { - let blueprint = map_user(user("8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11")).expect("map"); + let blueprint = mapped(user("8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11")).expect("map"); assert_eq!(blueprint.username, "8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11"); } #[test] fn reports_an_unverified_email_as_unverified() { - let blueprint = map_user(SupabaseUser { + let blueprint = mapped(SupabaseUser { email: Some("bob@acme.test".to_owned()), ..user("id-3") }) @@ -379,7 +462,7 @@ mod tests { #[test] fn leaves_verification_unset_when_there_is_no_email() { - let blueprint = map_user(SupabaseUser { + let blueprint = mapped(SupabaseUser { phone: Some("+33612345678".to_owned()), ..user("id-4") }) @@ -519,7 +602,7 @@ mod tests { #[test] fn maps_metadata_names_onto_the_blueprint() { - let blueprint = map_user(SupabaseUser { + let blueprint = mapped(SupabaseUser { user_metadata: metadata(&[("full_name", "Alice Doe")]), ..confirmed_email_user("id-13", "alice@acme.test") }) @@ -600,7 +683,7 @@ mod tests { #[test] fn carries_roles_onto_the_user_blueprint() { - let blueprint = map_user(SupabaseUser { + let blueprint = mapped(SupabaseUser { app_metadata: json_metadata(r#"{"provider":"email","roles":["admin"]}"#), ..confirmed_email_user("id-15", "alice@acme.test") }) @@ -610,7 +693,7 @@ mod tests { #[test] fn imports_no_roles_when_app_metadata_names_none() { - let blueprint = map_user(confirmed_email_user("id-14", "alice@acme.test")).expect("map"); + let blueprint = mapped(confirmed_email_user("id-14", "alice@acme.test")).expect("map"); assert!(blueprint.roles.is_empty()); } @@ -660,8 +743,13 @@ mod tests { #[test] fn requires_a_url_and_a_key() { - let missing_url = - SupabaseSource::build(None, Some("key".to_owned()), None, UserFilters::default()); + let missing_url = SupabaseSource::build( + None, + Some("key".to_owned()), + None, + UserFilters::default(), + None, + ); assert!(matches!( missing_url, Err(ImportError::MissingArg("--source-url")) @@ -672,6 +760,7 @@ mod tests { None, None, UserFilters::default(), + None, ); assert!(matches!( missing_key, diff --git a/libs/ferriskey-cli-core/src/import/sources/supabase_passwords.rs b/libs/ferriskey-cli-core/src/import/sources/supabase_passwords.rs new file mode 100644 index 0000000..76a84a0 --- /dev/null +++ b/libs/ferriskey-cli-core/src/import/sources/supabase_passwords.rs @@ -0,0 +1,342 @@ +use std::collections::HashMap; +use std::io::Read; +use std::path::Path; + +use thiserror::Error; + +use crate::import::{ImportError, PasswordCredentialBlueprint}; + +const BCRYPT_ALGORITHM: &str = "bcrypt"; +const BCRYPT_PREFIXES: [&str; 3] = ["2a", "2b", "2y"]; +const BCRYPT_COSTS: std::ops::RangeInclusive = 4..=14; +const BCRYPT_BODY_LEN: usize = 53; + +const ID_COLUMN: &str = "id"; +const HASH_COLUMN: &str = "encrypted_password"; +const BYTE_ORDER_MARK: char = '\u{feff}'; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum UnsupportedHash { + #[error("not a bcrypt hash (expected a $2a$, $2b$ or $2y$ prefix)")] + NotBcrypt, + #[error("the bcrypt cost is not a number")] + MalformedCost, + #[error( + "bcrypt cost {0} is outside the {floor}..={ceiling} FerrisKey accepts", + floor = BCRYPT_COSTS.start(), + ceiling = BCRYPT_COSTS.end() + )] + CostOutOfRange(u32), + #[error("the bcrypt hash body is not {BCRYPT_BODY_LEN} characters long")] + MalformedBody, +} + +#[derive(Debug, Default)] +pub struct PasswordCatalogue { + by_user_id: HashMap, + without_password: usize, + warnings: Vec, +} + +impl PasswordCatalogue { + pub fn from_csv(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(|source| ImportError::Io { + path: path.display().to_string(), + source, + })?; + Self::from_reader(file, &path.display().to_string()) + } + + pub fn from_reader(reader: impl Read, path: &str) -> Result { + let mut csv = csv::Reader::from_reader(reader); + let headers = csv.headers().map_err(|source| ImportError::PasswordCsv { + path: path.to_owned(), + source, + })?; + + let id_at = column_index(headers, ID_COLUMN, path)?; + let hash_at = column_index(headers, HASH_COLUMN, path)?; + + let mut catalogue = Self::default(); + for record in csv.records() { + let record = record.map_err(|source| ImportError::PasswordCsv { + path: path.to_owned(), + source, + })?; + catalogue.absorb( + record.get(id_at).unwrap_or_default(), + record.get(hash_at).unwrap_or_default(), + ); + } + + Ok(catalogue) + } + + fn absorb(&mut self, id: &str, hash: &str) { + let id = id.trim(); + let hash = hash.trim(); + + if hash.is_empty() { + self.without_password += 1; + return; + } + if id.is_empty() { + self.warnings + .push("a password export row carries a hash but no user id".to_owned()); + return; + } + + match parse_bcrypt_hash(hash) { + Ok(credential) => { + self.by_user_id.insert(id.to_owned(), credential); + } + Err(reason) => self.warnings.push(format!( + "skipping the password of supabase user '{id}': {reason}" + )), + } + } + + pub fn get(&self, user_id: &str) -> Option { + self.by_user_id.get(user_id).cloned() + } + + pub fn without_password(&self) -> usize { + self.without_password + } + + pub fn warnings(&self) -> &[String] { + &self.warnings + } +} + +fn column_index( + headers: &csv::StringRecord, + column: &'static str, + path: &str, +) -> Result { + headers + .iter() + .position(|header| header.trim_start_matches(BYTE_ORDER_MARK).trim() == column) + .ok_or_else(|| ImportError::PasswordCsvColumnMissing { + path: path.to_owned(), + column, + }) +} + +fn parse_bcrypt_hash(hash: &str) -> Result { + let mut parts = hash.split('$'); + if parts.next() != Some("") { + return Err(UnsupportedHash::NotBcrypt); + } + + let variant = parts.next().ok_or(UnsupportedHash::NotBcrypt)?; + if !BCRYPT_PREFIXES.contains(&variant) { + return Err(UnsupportedHash::NotBcrypt); + } + + let cost = parts.next().ok_or(UnsupportedHash::MalformedCost)?; + let cost: u32 = cost.parse().map_err(|_| UnsupportedHash::MalformedCost)?; + if !BCRYPT_COSTS.contains(&cost) { + return Err(UnsupportedHash::CostOutOfRange(cost)); + } + + let body = parts.next().ok_or(UnsupportedHash::MalformedBody)?; + if body.len() != BCRYPT_BODY_LEN || parts.next().is_some() { + return Err(UnsupportedHash::MalformedBody); + } + + Ok(PasswordCredentialBlueprint { + algorithm: BCRYPT_ALGORITHM.to_owned(), + secret_data: hash.to_owned(), + hash_iterations: cost, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BODY: &str = "N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + + fn hash(variant: &str, cost: &str) -> String { + format!("${variant}${cost}${BODY}") + } + + #[test] + fn reads_a_bcrypt_hash_into_a_credential() { + let raw = hash("2a", "10"); + let credential = parse_bcrypt_hash(&raw).expect("parse"); + assert_eq!(credential.algorithm, "bcrypt"); + assert_eq!(credential.secret_data, raw); + assert_eq!(credential.hash_iterations, 10); + } + + #[test] + fn carries_the_cost_encoded_in_the_hash_as_hash_iterations() { + for cost in 4..=14u32 { + let raw = hash("2a", &format!("{cost:02}")); + let credential = parse_bcrypt_hash(&raw).expect("parse"); + assert_eq!( + credential.hash_iterations, cost, + "the server rejects a hash_iterations that differs from the encoded cost" + ); + } + } + + #[test] + fn accepts_the_2b_and_2y_variants() { + assert!(parse_bcrypt_hash(&hash("2b", "10")).is_ok()); + assert!(parse_bcrypt_hash(&hash("2y", "10")).is_ok()); + } + + #[test] + fn rejects_the_2x_variant_the_server_refuses() { + assert_eq!( + parse_bcrypt_hash(&hash("2x", "10")), + Err(UnsupportedHash::NotBcrypt) + ); + } + + #[test] + fn rejects_an_argon2_hash() { + let argon2 = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG"; + assert_eq!(parse_bcrypt_hash(argon2), Err(UnsupportedHash::NotBcrypt)); + } + + #[test] + fn rejects_a_plaintext_value() { + assert_eq!( + parse_bcrypt_hash("hunter2"), + Err(UnsupportedHash::NotBcrypt) + ); + } + + #[test] + fn rejects_a_cost_below_the_server_floor() { + assert_eq!( + parse_bcrypt_hash(&hash("2a", "03")), + Err(UnsupportedHash::CostOutOfRange(3)) + ); + } + + #[test] + fn rejects_a_cost_above_the_server_ceiling() { + assert_eq!( + parse_bcrypt_hash(&hash("2a", "15")), + Err(UnsupportedHash::CostOutOfRange(15)) + ); + } + + #[test] + fn rejects_a_non_numeric_cost() { + assert_eq!( + parse_bcrypt_hash(&hash("2a", "ab")), + Err(UnsupportedHash::MalformedCost) + ); + } + + #[test] + fn rejects_a_truncated_hash_body() { + assert_eq!( + parse_bcrypt_hash("$2a$10$tooshort"), + Err(UnsupportedHash::MalformedBody) + ); + } + + fn catalogue(csv: &str) -> PasswordCatalogue { + PasswordCatalogue::from_reader(csv.as_bytes(), "users.csv").expect("load") + } + + #[test] + fn loads_a_minimal_export() { + let body = hash("2a", "10"); + let loaded = catalogue(&format!("id,encrypted_password\nuser-1,{body}\n")); + assert_eq!(loaded.get("user-1").expect("credential").secret_data, body); + assert!(loaded.warnings().is_empty()); + } + + #[test] + fn tolerates_the_extra_columns_of_a_select_star() { + let body = hash("2a", "10"); + let csv = format!( + "instance_id,id,aud,email,encrypted_password,raw_user_meta_data\n\ + 00000000,user-1,authenticated,a@b.test,{body},\"{{\"\"roles\"\":[\"\"admin\"\",\"\"billing\"\"]}}\"\n" + ); + let loaded = catalogue(&csv); + assert_eq!(loaded.get("user-1").expect("credential").secret_data, body); + assert!(loaded.warnings().is_empty()); + } + + #[test] + fn counts_a_federated_row_instead_of_warning_about_it() { + let loaded = catalogue("id,encrypted_password\nuser-1,\nuser-2,\n"); + assert_eq!(loaded.without_password(), 2); + assert!(loaded.get("user-1").is_none()); + assert!( + loaded.warnings().is_empty(), + "an oauth-only account has no password to carry; that is normal, not a warning" + ); + } + + #[test] + fn warns_and_skips_a_hash_the_server_would_reject() { + let loaded = catalogue(&format!( + "id,encrypted_password\nuser-1,{}\nuser-2,{}\n", + hash("2a", "10"), + hash("2a", "15") + )); + assert!(loaded.get("user-1").is_some()); + assert!(loaded.get("user-2").is_none()); + assert_eq!(loaded.warnings().len(), 1); + assert!(loaded.warnings()[0].contains("user-2")); + assert!(loaded.warnings()[0].contains("outside")); + } + + #[test] + fn reads_an_export_whose_header_starts_with_a_byte_order_mark() { + let body = hash("2a", "10"); + let loaded = catalogue(&format!("\u{feff}id,encrypted_password\nuser-1,{body}\n")); + assert_eq!(loaded.get("user-1").expect("credential").secret_data, body); + } + + #[test] + fn tolerates_whitespace_around_a_header_name() { + let body = hash("2a", "10"); + let loaded = catalogue(&format!("id , encrypted_password \nuser-1,{body}\n")); + assert!(loaded.get("user-1").is_some()); + } + + #[test] + fn fails_when_the_id_column_is_missing() { + let loaded = PasswordCatalogue::from_reader( + "email,encrypted_password\na@b.test,x\n".as_bytes(), + "users.csv", + ); + assert!(matches!( + loaded, + Err(ImportError::PasswordCsvColumnMissing { column: "id", .. }) + )); + } + + #[test] + fn fails_when_the_password_column_is_missing() { + let loaded = + PasswordCatalogue::from_reader("id,email\nuser-1,a@b.test\n".as_bytes(), "users.csv"); + assert!(matches!( + loaded, + Err(ImportError::PasswordCsvColumnMissing { + column: "encrypted_password", + .. + }) + )); + } + + #[test] + fn an_unknown_user_id_has_no_credential() { + let loaded = catalogue(&format!( + "id,encrypted_password\nuser-1,{}\n", + hash("2a", "10") + )); + assert!(loaded.get("user-404").is_none()); + } +} diff --git a/libs/ferriskey-cli-core/src/import/sources/zitadel.rs b/libs/ferriskey-cli-core/src/import/sources/zitadel.rs index 952340d..45fbefc 100644 --- a/libs/ferriskey-cli-core/src/import/sources/zitadel.rs +++ b/libs/ferriskey-cli-core/src/import/sources/zitadel.rs @@ -220,6 +220,7 @@ fn map_human(user_name: String, human: Human) -> UserBlueprint { lastname: profile.last_name, email_verified: email.is_email_verified, roles: Vec::new(), + credential: None, } } diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs index 6cca6ad..3131de3 100644 --- a/libs/ferriskey-cli-core/src/realm.rs +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -12,7 +12,9 @@ use thiserror::Error; use crate::confirm::{self, confirm}; use crate::config::{ConfigError, FileContextRepository, StoredContext}; -use crate::import::{self, ImportReport, RealmBlueprint}; +use crate::import::{ + self, ImportReport, PasswordCredentialBlueprint, RealmBlueprint, UserBlueprint, +}; use crate::session::{self, SessionError}; type Result = std::result::Result; @@ -376,25 +378,53 @@ fn render_blueprints(output_format: &str, blueprints: &[RealmBlueprint]) -> Resu if index > 0 { println!(); } - println!("realm: {}", blueprint.name); + println!("realm: {}", blueprint.name); println!( - "settings: {}", + "settings: {}", if blueprint.settings.is_some() { "yes" } else { "no" } ); - println!("roles: {}", blueprint.roles.len()); - println!("clients: {}", blueprint.clients.len()); - println!("users: {}", blueprint.users.len()); + println!("roles: {}", blueprint.roles.len()); + println!("clients: {}", blueprint.clients.len()); + println!("users: {}", blueprint.users.len()); + println!( + "passwords: {}", + blueprint + .users + .iter() + .filter(|user| user.credential.is_some()) + .count() + ); } Ok(()) } - "json" => render_json(blueprints), - "yaml" => render_yaml(blueprints), + "json" => render_json(&redact_secrets(blueprints)), + "yaml" => render_yaml(&redact_secrets(blueprints)), _ => Err(RealmCommandError::UnsupportedOutputFormat( output_format.to_owned(), )), } } +fn redact_secrets(blueprints: &[RealmBlueprint]) -> Vec { + blueprints + .iter() + .map(|blueprint| RealmBlueprint { + users: blueprint + .users + .iter() + .map(|user| UserBlueprint { + credential: user + .credential + .as_ref() + .map(PasswordCredentialBlueprint::redacted), + ..user.clone() + }) + .collect(), + ..blueprint.clone() + }) + .collect() +} + fn render_reports(output_format: &str, reports: &[ImportReport]) -> Result<()> { match output_format { "table" => { @@ -420,6 +450,7 @@ fn render_reports(output_format: &str, reports: &[ImportReport]) -> Result<()> { println!(" client roles created: {}", report.client_roles_created); println!(" users created: {}", report.users_created); println!(" role assignments: {}", report.role_assignments); + println!(" passwords imported: {}", report.passwords_imported); println!(" already present: {}", report.already_present); if !report.client_secrets.is_empty() { println!(" client secrets:"); @@ -637,6 +668,52 @@ mod tests { use crate::config::StoredContext; use ferriskey_cli_client::Realm; + const BCRYPT_HASH: &str = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + + fn blueprint_with_password() -> Vec { + vec![RealmBlueprint { + name: "acme".to_owned(), + users: vec![UserBlueprint { + username: "alice".to_owned(), + email: None, + firstname: None, + lastname: None, + email_verified: None, + roles: Vec::new(), + credential: Some(PasswordCredentialBlueprint { + algorithm: "bcrypt".to_owned(), + secret_data: BCRYPT_HASH.to_owned(), + hash_iterations: 10, + }), + }], + ..Default::default() + }] + } + + #[test] + fn serialized_dry_run_output_never_carries_a_password_hash() { + let rendered = + serde_yaml::to_string(&redact_secrets(&blueprint_with_password())).expect("serialize"); + assert!(!rendered.contains(BCRYPT_HASH)); + assert!(!rendered.contains("N9qo8uLOickgx2ZMRZoMye")); + assert!(rendered.contains("bcrypt")); + assert!(rendered.contains("hash_iterations")); + } + + #[test] + fn redacting_leaves_the_rest_of_the_blueprint_alone() { + let redacted = redact_secrets(&blueprint_with_password()); + assert_eq!(redacted[0].name, "acme"); + assert_eq!(redacted[0].users[0].username, "alice"); + } + + #[test] + fn redacting_keeps_a_user_without_credential_untouched() { + let mut blueprints = blueprint_with_password(); + blueprints[0].users[0].credential = None; + assert!(redact_secrets(&blueprints)[0].users[0].credential.is_none()); + } + fn make_context(realm: Option<&str>) -> StoredContext { StoredContext { url: "http://localhost:3333".to_owned(), From 326ced98ab49ccefd814b75fe8e4ff4fdacbbd6a Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 11:29:44 +0200 Subject: [PATCH 3/4] docs(import): spell out how to produce the supabase password export The flag told operators a CSV of auth.users was needed but not how to get one, which is the part nobody can guess: the query has to run against a schema the dashboard does not browse, and the obvious `COPY ... TO` fails on a managed instance because it writes server-side. `\copy` is the psql command that writes locally, and the backslash is the whole difference. The connection string advice is there for the same reason. The direct connection Supabase shows first is IPv6-only without the paid IPv4 add-on, so an operator on an IPv4 network hits a timeout with no hint that the session pooler is the answer. The flag help carries it as well as the README because `--help` is where someone mid-migration actually looks. verbatim_doc_comment keeps clap from re-wrapping the command into something that no longer runs when pasted. Both also now say the file holds the whole directory's hashes and should be deleted after the import. Nothing in the tool can enforce that, so the only place it can live is the documentation. --- README.md | 42 ++++++++++++++++++++---- libs/ferriskey-cli-commands/src/realm.rs | 25 +++++++++++--- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 83f0c7c..d038ad0 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,45 @@ the `auth.users` table: --target-realm my-realm The export is needed because the Auth Admin API never serves password hashes: -they live only in `auth.users.encrypted_password`. Produce it once from the -Supabase SQL editor (then *Download CSV*), or with `psql`: +they live only in `auth.users.encrypted_password`. + +##### Producing the export + +From the dashboard **SQL editor**, run the query and use the download button +above the results: select id, encrypted_password from auth.users; -Only `id` and `encrypted_password` are read, and extra columns are ignored — a -plain `select *` export works as-is. Rows are joined onto users by -`auth.users.id`, never by email: an email is nullable in Supabase and is -therefore not a key. +Or with `psql`, which is the better option on a large directory: + + psql "" -c \ + "\copy (select id, encrypted_password from auth.users) to 'auth_users.csv' with (format csv, header)" + +The connection string sits behind the project's **Connect** button. Pick the +**Session pooler** one if your machine has no IPv6: the direct connection +(`db..supabase.co:5432`) is IPv6-only unless the project has the +IPv4 add-on, while the pooler is IPv4 on every plan. + +The backslash in `\copy` is not cosmetic. `\copy` is a psql command and writes +the file on *your* machine; a plain `COPY … TO` is a server-side statement that +a managed Supabase instance will not let you run. + +If neither route is open to you — permissions, or a directory too large to pull +yourself — Supabase support can produce the `auth.users` export on request. + +##### What the CLI reads from it + +Only `id` and `encrypted_password`, and extra columns are ignored — a plain +`select *` export works as-is, so an export you already have need not be redone. +A UTF-8 BOM on the header line is tolerated, which the SQL editor's download +emits. + +Rows are joined onto users by `auth.users.id`, never by email: an email is +nullable in Supabase and is therefore not a key. + +**The file holds every password hash in the directory.** bcrypt is slow to +attack, but this is still authentication material: keep the file to yourself, +and delete it once the import has run. FerrisKey stores the bcrypt hash verbatim and re-encodes it as argon2id on the user's first successful login, so the migration is invisible to the end user and diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index 7a2ea59..d860dde 100644 --- a/libs/ferriskey-cli-commands/src/realm.rs +++ b/libs/ferriskey-cli-commands/src/realm.rs @@ -210,12 +210,29 @@ pub struct RealmImportArgs { #[arg(long = "source-include-unconfirmed", default_value_t = false)] pub source_include_unconfirmed: bool, - /// Carry Supabase passwords over, read from a CSV export of the `auth.users` - /// table (`select id, encrypted_password from auth.users`). The Auth Admin - /// API never serves those hashes, so the export is the only way to get them. + /// Carry Supabase passwords over, read from a CSV export of `auth.users`. + /// + /// The Auth (GoTrue) Admin API never serves password hashes, so an export of + /// the table is the only way to get them. Produce it with psql — the + /// connection string is behind the project's "Connect" button, and the + /// backslash matters, since a plain COPY TO would write on the server: + /// + /// psql "" -c \ + /// "\copy (select id, encrypted_password from auth.users) to 'auth_users.csv' with (format csv, header)" + /// + /// Or run `select id, encrypted_password from auth.users;` in the dashboard + /// SQL editor and use the download button above the results. + /// + /// Only `id` and `encrypted_password` are read and extra columns are ignored, + /// so a plain `select *` export works too. Rows join onto users by + /// `auth.users.id`, never by email. + /// + /// The file holds every password hash in the directory: keep it to yourself + /// and delete it once the import has run. + /// /// Only bcrypt hashes FerrisKey accepts are imported; every other account /// arrives without credentials and needs a password reset. - #[arg(long = "source-passwords", value_name = "FILE")] + #[arg(long = "source-passwords", value_name = "FILE", verbatim_doc_comment)] pub source_passwords: Option, /// Override the name of the realm created in FerrisKey (defaults to the source realm name). From 31957ec10c9ff6f5d20fbef38c4dc75cb5934878 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 11:48:34 +0200 Subject: [PATCH 4/4] fix(import): report a server without the import endpoint once, not per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real migration against a FerrisKey binary predating the credentials/import route produced forty-four identical "405 Method Not Allowed" warnings and still announced the import as done, with zero passwords carried over and an exit code of 0. Forty-four warnings read as forty-four problems; the actual problem was one stale server, and the summary line that would have said so did not exist. The 405 is not arbitrary. Without the import route, the POST falls through to `credentials/{credential_id}` with credential_id="import", which is registered for DELETE only, so axum answers 405 with `allow: DELETE`. A server built before the route existed at all answers 404. Both mean the same thing, so both now end the attempt: the verdict cannot change for the users that follow, and re-posting the same doomed request once per account only slows the run down. passwords_failed is new and always printed. A count of successes alone cannot distinguish "this directory had no passwords to carry" from "every single one was refused", and those deserve very different reactions from whoever reads the report. Per-user warnings are kept for per-user causes — a hash the server refuses names the account, because there the account is the thing to go and look at. Verified against a live server: three bcrypt hashes at costs 8, 10 and 12, in all three prefix spellings, imported and counted. The 404/405 collapse itself is covered by unit tests only; the server under test was rebuilt with the endpoint before that path could be exercised end to end again. --- libs/ferriskey-cli-core/src/import/apply.rs | 70 +++++++++++++++++---- libs/ferriskey-cli-core/src/import/mod.rs | 1 + libs/ferriskey-cli-core/src/realm.rs | 1 + 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/libs/ferriskey-cli-core/src/import/apply.rs b/libs/ferriskey-cli-core/src/import/apply.rs index 8a4c6e2..5d983b6 100644 --- a/libs/ferriskey-cli-core/src/import/apply.rs +++ b/libs/ferriskey-cli-core/src/import/apply.rs @@ -414,6 +414,7 @@ pub fn apply_blueprint( } // 5. Users, with realm-role assignments. + let mut password_import_unsupported = false; for user in &blueprint.users { let existing_user = match find_existing_user(client, realm, &user.username) { Ok(found) => found, @@ -455,19 +456,37 @@ pub fn apply_blueprint( let Some(user_id) = user_id else { continue }; if let Some(credential) = &user.credential { - match client.import_password_credential(realm, &user_id, &credential.to_request()) { - Ok(()) => report.passwords_imported += 1, - Err(e) if is_conflict(&e) => { - report.already_present += 1; - report.warnings.push(format!( - "user '{}' already has a password, keeping it", - user.username - )); + if password_import_unsupported { + report.passwords_failed += 1; + } else { + match client.import_password_credential(realm, &user_id, &credential.to_request()) { + Ok(()) => report.passwords_imported += 1, + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "user '{}' already has a password, keeping it", + user.username + )); + } + Err(e) if is_endpoint_absent(&e) => { + password_import_unsupported = true; + report.passwords_failed += 1; + report.warnings.push( + "this FerrisKey server has no POST \ + realms/{realm}/users/{id}/credentials/import endpoint, so no password \ + was carried over — upgrade the server, or set passwords with \ + `ferris-ctl user set-password`" + .to_owned(), + ); + } + Err(e) => { + report.passwords_failed += 1; + report.warnings.push(format!( + "could not import the password of user '{}': {e}", + user.username + )); + } } - Err(e) => report.warnings.push(format!( - "could not import the password of user '{}': {e}", - user.username - )), } } @@ -692,6 +711,14 @@ fn user_request(user: &super::UserBlueprint) -> CreateUserRequest { } } +fn is_endpoint_absent(error: &FerriskeyClientError) -> bool { + matches!( + error, + FerriskeyClientError::Api { status, .. } + if *status == StatusCode::NOT_FOUND || *status == StatusCode::METHOD_NOT_ALLOWED + ) +} + /// Whether an API error means "this entity already exists" — treated as a skip. /// /// Some already-deployed servers surface a duplicate-key unique-constraint @@ -821,6 +848,25 @@ mod tests { } } + #[test] + fn a_server_without_the_import_route_is_recognized_from_404_and_405() { + assert!(is_endpoint_absent(&api_error(StatusCode::NOT_FOUND, ""))); + assert!(is_endpoint_absent(&api_error( + StatusCode::METHOD_NOT_ALLOWED, + "" + ))); + } + + #[test] + fn a_rejected_hash_is_not_mistaken_for_a_missing_endpoint() { + assert!(!is_endpoint_absent(&api_error( + StatusCode::UNPROCESSABLE_ENTITY, + "bcrypt cost 3 is outside 4..=14" + ))); + assert!(!is_endpoint_absent(&api_error(StatusCode::FORBIDDEN, ""))); + assert!(!is_endpoint_absent(&api_error(StatusCode::CONFLICT, ""))); + } + #[test] fn is_conflict_recognizes_409() { assert!(is_conflict(&api_error(StatusCode::CONFLICT, ""))); diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index 7f1af46..0249b65 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -229,6 +229,7 @@ pub struct ImportReport { pub users_created: usize, pub role_assignments: usize, pub passwords_imported: usize, + pub passwords_failed: usize, /// Entities skipped because they already existed — distinguishes a /// converging replay from a run that did nothing. pub already_present: usize, diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs index 3131de3..ecba057 100644 --- a/libs/ferriskey-cli-core/src/realm.rs +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -451,6 +451,7 @@ fn render_reports(output_format: &str, reports: &[ImportReport]) -> Result<()> { println!(" users created: {}", report.users_created); println!(" role assignments: {}", report.role_assignments); println!(" passwords imported: {}", report.passwords_imported); + println!(" passwords failed: {}", report.passwords_failed); println!(" already present: {}", report.already_present); if !report.client_secrets.is_empty() { println!(" client secrets:");