From eb7215573e2b1927f4f32cc909f5618fbd5e105d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 19:50:12 +0200 Subject: [PATCH 1/4] feat(import): preserve supabase user ids behind --source-preserve-ids --- libs/ferriskey-cli-client/src/lib.rs | 2 + libs/ferriskey-cli-commands/src/realm.rs | 17 ++++ libs/ferriskey-cli-core/src/import/apply.rs | 77 +++++++++++++++++++ libs/ferriskey-cli-core/src/import/mod.rs | 57 ++++++++++++++ .../src/import/sources/keycloak.rs | 1 + .../src/import/sources/mod.rs | 45 ++++++++--- .../src/import/sources/supabase.rs | 53 ++++++++++++- .../src/import/sources/zitadel.rs | 1 + libs/ferriskey-cli-core/src/realm.rs | 1 + libs/ferriskey-cli-core/src/user.rs | 1 + 10 files changed, 244 insertions(+), 11 deletions(-) diff --git a/libs/ferriskey-cli-client/src/lib.rs b/libs/ferriskey-cli-client/src/lib.rs index d575ef3..1fe9871 100644 --- a/libs/ferriskey-cli-client/src/lib.rs +++ b/libs/ferriskey-cli-client/src/lib.rs @@ -61,6 +61,8 @@ pub struct UserRepresentation { #[derive(Debug, Clone, Serialize)] pub struct CreateUserRequest { pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, pub firstname: Option, pub lastname: Option, pub email: Option, diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index d860dde..5705441 100644 --- a/libs/ferriskey-cli-commands/src/realm.rs +++ b/libs/ferriskey-cli-commands/src/realm.rs @@ -235,6 +235,23 @@ pub struct RealmImportArgs { #[arg(long = "source-passwords", value_name = "FILE", verbatim_doc_comment)] pub source_passwords: Option, + /// Create every user with the id it already has in Supabase, instead of + /// letting FerrisKey mint a new one. + /// + /// The id becomes the `sub` claim of every token FerrisKey issues, so a + /// business database keyed on `auth.users.id` keeps working after the + /// migration. Without this flag those keys point at nothing. + /// + /// A `sub` is never reassigned, so this only applies to accounts the import + /// creates. Users that already exist in the target realm keep the id they + /// were given, and the import says so. + /// + /// Requires a FerrisKey server that accepts a supplied id. An older one + /// ignores it and mints its own, which the import detects and refuses to + /// continue past rather than migrate the whole directory onto new subjects. + #[arg(long = "source-preserve-ids", default_value_t = false)] + pub source_preserve_ids: bool, + /// Override the name of the realm created in FerrisKey (defaults to the source realm name). #[arg(long = "target-realm")] pub target_realm: Option, diff --git a/libs/ferriskey-cli-core/src/import/apply.rs b/libs/ferriskey-cli-core/src/import/apply.rs index 5d983b6..cf68e7b 100644 --- a/libs/ferriskey-cli-core/src/import/apply.rs +++ b/libs/ferriskey-cli-core/src/import/apply.rs @@ -436,10 +436,27 @@ pub fn apply_blueprint( } None => match client.create_user(realm, &user_request(user)) { Ok(created) => { + if let Some(requested) = &user.id + && !created.id.eq_ignore_ascii_case(requested) + { + return Err(ImportError::UserIdNotPreserved { + username: user.username.clone(), + requested: requested.clone(), + actual: created.id, + }); + } report.users_created += 1; (Some(created.id), false) } Err(e) if is_conflict(&e) => { + if is_user_id_conflict(&e) + && let Some(id) = user.id.clone() + { + return Err(ImportError::UserIdAlreadyTaken { + username: user.username.clone(), + id, + }); + } report.already_present += 1; report .warnings @@ -704,6 +721,7 @@ fn client_request(client_bp: &ClientBlueprint) -> CreateClientRequest { fn user_request(user: &super::UserBlueprint) -> CreateUserRequest { CreateUserRequest { username: user.username.clone(), + id: user.id.clone(), firstname: user.firstname.clone(), lastname: user.lastname.clone(), email: user.email.clone(), @@ -719,6 +737,16 @@ fn is_endpoint_absent(error: &FerriskeyClientError) -> bool { ) } +const USER_ID_TAKEN_REASON: &str = "user_id_already_exists"; + +fn is_user_id_conflict(error: &FerriskeyClientError) -> bool { + matches!( + error, + FerriskeyClientError::Api { status, body } + if *status == StatusCode::CONFLICT && body.contains(USER_ID_TAKEN_REASON) + ) +} + /// Whether an API error means "this entity already exists" — treated as a skip. /// /// Some already-deployed servers surface a duplicate-key unique-constraint @@ -781,6 +809,7 @@ mod tests { }], users: vec![UserBlueprint { username: "alice".to_owned(), + id: None, email: None, firstname: None, lastname: None, @@ -848,6 +877,19 @@ mod tests { } } + fn user_blueprint() -> super::super::UserBlueprint { + super::super::UserBlueprint { + username: "alice".to_owned(), + id: None, + email: None, + firstname: None, + lastname: None, + email_verified: None, + roles: Vec::new(), + credential: None, + } + } + #[test] fn a_server_without_the_import_route_is_recognized_from_404_and_405() { assert!(is_endpoint_absent(&api_error(StatusCode::NOT_FOUND, ""))); @@ -867,6 +909,41 @@ mod tests { assert!(!is_endpoint_absent(&api_error(StatusCode::CONFLICT, ""))); } + #[test] + fn a_blueprint_id_reaches_the_create_request() { + let mut user = user_blueprint(); + user.id = Some("2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11".to_owned()); + let request = user_request(&user); + assert_eq!( + request.id.as_deref(), + Some("2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11") + ); + } + + #[test] + fn a_user_without_an_id_sends_no_id_field() { + let json = serde_json::to_value(user_request(&user_blueprint())).expect("serialize"); + assert!( + json.get("id").is_none(), + "the server denies unknown fields; a null id would have to be tolerated too" + ); + } + + #[test] + fn a_taken_id_is_told_apart_from_a_taken_username() { + let taken_id = api_error( + StatusCode::CONFLICT, + r#"{"reason":"user_id_already_exists","message":"A user already exists with this id"}"#, + ); + let taken_username = api_error(StatusCode::CONFLICT, r#"{"reason":"user_already_exists"}"#); + assert!(is_user_id_conflict(&taken_id)); + assert!(!is_user_id_conflict(&taken_username)); + assert!( + is_conflict(&taken_id), + "a taken id is still a conflict, so the existing arm keeps catching it" + ); + } + #[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 0249b65..d940ad7 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -150,6 +150,8 @@ impl ClientBlueprint { pub struct UserBlueprint { pub username: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub email: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub firstname: Option, @@ -280,6 +282,26 @@ pub enum ImportError { "--source-passwords only applies to '--from supabase'; the '{0}' source carries no password export" )] PasswordsUnsupportedBySource(&'static str), + #[error( + "--source-preserve-ids only applies to '--from supabase'; the '{0}' source exposes no \ + identifier FerrisKey could reuse" + )] + PreserveIdsUnsupportedBySource(&'static str), + #[error( + "user '{username}' was created as {actual} instead of the requested {requested}: this \ + FerrisKey server accepts no supplied id, so every subject would change. Upgrade the \ + server, or drop --source-preserve-ids" + )] + UserIdNotPreserved { + username: String, + requested: String, + actual: String, + }, + #[error( + "id {id} of user '{username}' is already taken in this FerrisKey instance, possibly by a \ + realm this token cannot see. Nothing was written for that account" + )] + UserIdAlreadyTaken { username: String, id: String }, #[error("failed to read the Supabase password export '{path}'")] PasswordCsv { path: String, @@ -369,6 +391,7 @@ mod tests { fn a_user_without_credential_serializes_without_the_field() { let user = UserBlueprint { username: "alice".to_owned(), + id: None, email: None, firstname: None, lastname: None, @@ -380,6 +403,39 @@ mod tests { assert!(!yaml.contains("credential")); } + #[test] + fn a_user_without_an_id_serializes_without_the_field() { + let user = UserBlueprint { + username: "alice".to_owned(), + id: None, + email: None, + firstname: None, + lastname: None, + email_verified: None, + roles: Vec::new(), + credential: None, + }; + let json = serde_json::to_value(&user).expect("serialize"); + assert!(json.get("id").is_none()); + } + + #[test] + fn a_user_id_survives_a_yaml_round_trip() { + let user = UserBlueprint { + username: "alice".to_owned(), + id: Some("2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11".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"); + let parsed: UserBlueprint = serde_yaml::from_str(&yaml).expect("deserialize"); + assert_eq!(parsed.id, user.id); + } + #[test] fn blueprint_yaml_round_trip() { let bp = RealmBlueprint { @@ -416,6 +472,7 @@ mod tests { }], users: vec![UserBlueprint { username: "alice".to_owned(), + id: Some("2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11".to_owned()), email: Some("alice@acme.test".to_owned()), firstname: Some("Alice".to_owned()), lastname: None, diff --git a/libs/ferriskey-cli-core/src/import/sources/keycloak.rs b/libs/ferriskey-cli-core/src/import/sources/keycloak.rs index bddd00e..035c140 100644 --- a/libs/ferriskey-cli-core/src/import/sources/keycloak.rs +++ b/libs/ferriskey-cli-core/src/import/sources/keycloak.rs @@ -214,6 +214,7 @@ fn map_client(client: KcClient, roles: Vec) -> ClientBlueprint { fn map_user(user: KcUser) -> crate::import::UserBlueprint { crate::import::UserBlueprint { username: user.username, + id: None, email: user.email, firstname: user.first_name, lastname: user.last_name, diff --git a/libs/ferriskey-cli-core/src/import/sources/mod.rs b/libs/ferriskey-cli-core/src/import/sources/mod.rs index 8b2a0cf..65d0382 100644 --- a/libs/ferriskey-cli-core/src/import/sources/mod.rs +++ b/libs/ferriskey-cli-core/src/import/sources/mod.rs @@ -44,12 +44,12 @@ fn build_from_inline( ) -> Result, ImportError> { match kind { ImportSource::Config => { - reject_passwords(args, "config")?; + reject_supabase_only(args, "config")?; let path = args.file.clone().ok_or(ImportError::MissingArg("--file"))?; Ok(Box::new(ConfigSource::new(path))) } ImportSource::Keycloak => { - reject_passwords(args, "keycloak")?; + reject_supabase_only(args, "keycloak")?; Ok(Box::new(KeycloakSource::build( args.source_url.clone(), args.source_realm.clone(), @@ -59,7 +59,7 @@ fn build_from_inline( )?)) } ImportSource::Zitadel => { - reject_passwords(args, "zitadel")?; + reject_supabase_only(args, "zitadel")?; Ok(Box::new(ZitadelSource::build( args.source_url.clone(), args.source_token.clone(), @@ -77,15 +77,19 @@ fn build_from_inline( .or_else(|| args.source_realm.clone()), user_filters(args), args.source_passwords.clone(), + args.source_preserve_ids, )?)), } } -fn reject_passwords(args: &RealmImportArgs, kind: &'static str) -> Result<(), ImportError> { - match args.source_passwords { - Some(_) => Err(ImportError::PasswordsUnsupportedBySource(kind)), - None => Ok(()), +fn reject_supabase_only(args: &RealmImportArgs, kind: &'static str) -> Result<(), ImportError> { + if args.source_passwords.is_some() { + return Err(ImportError::PasswordsUnsupportedBySource(kind)); } + if args.source_preserve_ids { + return Err(ImportError::PreserveIdsUnsupportedBySource(kind)); + } + Ok(()) } fn user_filters(args: &RealmImportArgs) -> UserFilters { @@ -103,7 +107,7 @@ fn build_from_stored( ) -> Result, ImportError> { match stored.kind.as_str() { "keycloak" => { - reject_passwords(args, "keycloak")?; + reject_supabase_only(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()), @@ -117,7 +121,7 @@ fn build_from_stored( )?)) } "zitadel" => { - reject_passwords(args, "zitadel")?; + reject_supabase_only(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()), @@ -137,6 +141,7 @@ fn build_from_stored( .or_else(|| stored.realm.clone()), user_filters(args), args.source_passwords.clone(), + args.source_preserve_ids, )?)), other => Err(ImportError::InvalidStoredKind { name: name.to_owned(), @@ -195,6 +200,28 @@ mod tests { } } + #[test] + fn rejects_preserved_ids_on_a_source_that_exposes_none() { + let args = RealmImportArgs { + source_preserve_ids: true, + source_url: Some("https://example.test".to_owned()), + source_token: Some("token".to_owned()), + file: Some(PathBuf::from("realm.yaml")), + ..Default::default() + }; + for (kind, name) in [ + (ImportSource::Config, "config"), + (ImportSource::Keycloak, "keycloak"), + (ImportSource::Zitadel, "zitadel"), + ] { + let built = build_from_inline(&kind, &args); + assert!( + matches!(built, Err(ImportError::PreserveIdsUnsupportedBySource(got)) if got == name), + "--source-preserve-ids must not be silently ignored by '{name}'" + ); + } + } + #[test] fn a_missing_password_export_is_reported_against_its_path() { let built = build_from_inline(&ImportSource::Supabase, &args_with_passwords()); diff --git a/libs/ferriskey-cli-core/src/import/sources/supabase.rs b/libs/ferriskey-cli-core/src/import/sources/supabase.rs index b091e98..67f57ea 100644 --- a/libs/ferriskey-cli-core/src/import/sources/supabase.rs +++ b/libs/ferriskey-cli-core/src/import/sources/supabase.rs @@ -46,6 +46,7 @@ pub struct SupabaseSource { realm_name: Option, filters: UserFilters, passwords: PasswordCatalogue, + preserve_ids: bool, http: Client, } @@ -56,6 +57,7 @@ impl SupabaseSource { realm_name: Option, filters: UserFilters, passwords_export: Option, + preserve_ids: bool, ) -> Result { let base_url = normalize_base_url(&base_url.ok_or(ImportError::MissingArg("--source-url"))?); @@ -71,6 +73,7 @@ impl SupabaseSource { realm_name, filters, passwords, + preserve_ids, http: Client::new(), }) } @@ -113,7 +116,7 @@ impl SupabaseSource { continue; } if self.filters.keeps(&user) { - users.push(map_user(user, &self.passwords)?); + users.push(map_user(user, &self.passwords, self.preserve_ids)?); } } @@ -164,6 +167,7 @@ fn normalize_base_url(url: &str) -> String { fn map_user( user: SupabaseUser, passwords: &PasswordCatalogue, + preserve_ids: bool, ) -> Result { let (firstname, lastname) = names_from_metadata(&user.user_metadata); let email_verified = user @@ -177,9 +181,11 @@ fn map_user( .unwrap_or_else(|| user.id.clone()); let roles = roles_from_metadata(&user.app_metadata, &username)?; let credential = passwords.get(&user.id); + let id = preserve_ids.then(|| user.id.clone()); Ok(UserBlueprint { username, + id, email: user.email, firstname, lastname, @@ -358,6 +364,7 @@ mod tests { fn user_with_roles(email: &str, roles: &[&str]) -> UserBlueprint { UserBlueprint { username: email.to_owned(), + id: None, email: Some(email.to_owned()), firstname: None, lastname: None, @@ -368,7 +375,11 @@ mod tests { } fn mapped(user: SupabaseUser) -> Result { - map_user(user, &PasswordCatalogue::default()) + map_user(user, &PasswordCatalogue::default(), false) + } + + fn mapped_preserving(user: SupabaseUser) -> Result { + map_user(user, &PasswordCatalogue::default(), true) } const BCRYPT_HASH: &str = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; @@ -383,11 +394,45 @@ mod tests { .expect("load") } + #[test] + fn carries_no_id_unless_asked() { + let blueprint = mapped(confirmed_email_user( + "2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11", + "alice@acme.test", + )) + .expect("map"); + assert!(blueprint.id.is_none()); + } + + #[test] + fn preserves_the_supabase_id_when_asked() { + let blueprint = mapped_preserving(confirmed_email_user( + "2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11", + "alice@acme.test", + )) + .expect("map"); + assert_eq!( + blueprint.id.as_deref(), + Some("2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11") + ); + } + + #[test] + fn preserving_ids_leaves_the_username_derived_from_the_email() { + let blueprint = mapped_preserving(confirmed_email_user( + "2b6f0cc9-04a4-4d4f-9e58-1f6a4e3d0a11", + "alice@acme.test", + )) + .expect("map"); + assert_eq!(blueprint.username, "alice@acme.test"); + } + #[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"), + false, ) .expect("map"); let credential = blueprint.credential.expect("credential"); @@ -401,6 +446,7 @@ mod tests { let blueprint = map_user( confirmed_email_user("id-21", "alice@acme.test"), &catalogue_for("alice@acme.test"), + false, ) .expect("map"); assert!( @@ -414,6 +460,7 @@ mod tests { let blueprint = map_user( confirmed_email_user("id-22", "bob@acme.test"), &catalogue_for("id-20"), + false, ) .expect("map"); assert!(blueprint.credential.is_none()); @@ -749,6 +796,7 @@ mod tests { None, UserFilters::default(), None, + false, ); assert!(matches!( missing_url, @@ -761,6 +809,7 @@ mod tests { None, UserFilters::default(), None, + false, ); assert!(matches!( missing_key, diff --git a/libs/ferriskey-cli-core/src/import/sources/zitadel.rs b/libs/ferriskey-cli-core/src/import/sources/zitadel.rs index 45fbefc..59f9253 100644 --- a/libs/ferriskey-cli-core/src/import/sources/zitadel.rs +++ b/libs/ferriskey-cli-core/src/import/sources/zitadel.rs @@ -215,6 +215,7 @@ fn map_human(user_name: String, human: Human) -> UserBlueprint { let email = human.email.unwrap_or_default(); UserBlueprint { username: user_name, + id: None, email: email.email, firstname: profile.first_name, lastname: profile.last_name, diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs index ecba057..fc855ed 100644 --- a/libs/ferriskey-cli-core/src/realm.rs +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -676,6 +676,7 @@ mod tests { name: "acme".to_owned(), users: vec![UserBlueprint { username: "alice".to_owned(), + id: None, email: None, firstname: None, lastname: None, diff --git a/libs/ferriskey-cli-core/src/user.rs b/libs/ferriskey-cli-core/src/user.rs index f2a4154..323c2de 100644 --- a/libs/ferriskey-cli-core/src/user.rs +++ b/libs/ferriskey-cli-core/src/user.rs @@ -221,6 +221,7 @@ fn create_user( let client = auth_client(&context)?; let request = CreateUserRequest { username: args.username, + id: None, firstname: args.firstname, lastname: args.lastname, email: args.email, From e86c681878e43e5d9d593343825f70a336b6081b Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 19:50:12 +0200 Subject: [PATCH 2/4] docs(import): document --source-preserve-ids --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index d038ad0..308f3a1 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,40 @@ 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. +#### Identifiers + +`--source-preserve-ids` creates each user with the id it already has in +Supabase, instead of letting FerrisKey mint a new one: + + ferris-ctl realm import \ + --from supabase \ + --source-url https://.supabase.co \ + --source-token \ + --source-preserve-ids \ + --target-realm my-realm + +That id becomes the `sub` claim of every token FerrisKey issues. A business +database that stores `auth.users.id` as a foreign key therefore keeps working +across the migration; without the flag, every one of those keys points at an +account that no longer exists under that id. + +Reusing a Supabase id is not a reassignment: OpenID Connect scopes `sub` +uniqueness to the issuer, and the issuer changes from +`https://.supabase.co/auth/v1` to `.../realms/`. The same rule is +why the flag only affects accounts the import creates — a `sub` is never +reassigned, so a user the target realm already holds keeps the id it was given, +and the import reports it under `already present`. + +The server has to accept a supplied id. One that does not silently mints its +own, so the import compares what came back against what it asked for and stops +on the first account that does not match, rather than migrating a whole +directory onto new subjects. An id already taken elsewhere in the instance — +possibly in a realm the token cannot see — stops it the same way, naming the +account. + +Like `--source-passwords`, the flag is Supabase-only and is never stored in a +saved source. + #### Roles Supabase has no role catalogue. Roles are read from each user's `app_metadata`, From 72f3f2d9134b73ec941640c7743d0f2f49f65af1 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 19:50:22 +0200 Subject: [PATCH 3/4] chore(repo): ignore machine-local claude configuration --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fd2c594..76174ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ *.key* .DS_Store **/.DS_Store + +.claude/*.local.* +.claude/settings.local.json From 54d4094ab9ef123c2b15a5b31d99739308ef716e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Thu, 24 Sep 2026 19:56:14 +0200 Subject: [PATCH 4/4] refactor(import): cut the --source-preserve-ids help down to one sentence --- libs/ferriskey-cli-commands/src/realm.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index 5705441..ee89815 100644 --- a/libs/ferriskey-cli-commands/src/realm.rs +++ b/libs/ferriskey-cli-commands/src/realm.rs @@ -235,20 +235,8 @@ pub struct RealmImportArgs { #[arg(long = "source-passwords", value_name = "FILE", verbatim_doc_comment)] pub source_passwords: Option, - /// Create every user with the id it already has in Supabase, instead of - /// letting FerrisKey mint a new one. - /// - /// The id becomes the `sub` claim of every token FerrisKey issues, so a - /// business database keyed on `auth.users.id` keeps working after the - /// migration. Without this flag those keys point at nothing. - /// - /// A `sub` is never reassigned, so this only applies to accounts the import - /// creates. Users that already exist in the target realm keep the id they - /// were given, and the import says so. - /// - /// Requires a FerrisKey server that accepts a supplied id. An older one - /// ignores it and mints its own, which the import detects and refuses to - /// continue past rather than migrate the whole directory onto new subjects. + /// Create each user with the id it already has in Supabase, so the `sub` of + /// every token survives the migration (Supabase only). #[arg(long = "source-preserve-ids", default_value_t = false)] pub source_preserve_ids: bool,