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..d038ad0 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,94 @@ 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`. + +##### 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; + +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 +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-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, diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index c0b13a7..d860dde 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,31 @@ 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 `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", verbatim_doc_comment)] + 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..5d983b6 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); } @@ -409,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, @@ -449,6 +455,41 @@ pub fn apply_blueprint( let Some(user_id) = user_id else { continue }; + if let Some(credential) = &user.credential { + 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 + )); + } + } + } + } + // 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() { @@ -670,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 @@ -737,6 +786,7 @@ mod tests { lastname: None, email_verified: None, roles: vec!["admin".to_owned()], + credential: None, }], } } @@ -762,6 +812,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(); @@ -778,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 5e89068..0249b65 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,8 @@ pub struct ImportReport { pub client_roles_created: usize, 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, @@ -243,6 +276,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 +323,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 +421,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..ecba057 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,8 @@ 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!(" passwords failed: {}", report.passwords_failed); println!(" already present: {}", report.already_present); if !report.client_secrets.is_empty() { println!(" client secrets:"); @@ -637,6 +669,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(),