Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
*.key*
.DS_Store
**/.DS_Store

.claude/*.local.*
.claude/settings.local.json
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<project>.supabase.co \
--source-token <service_role key> \
--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://<ref>.supabase.co/auth/v1` to `.../realms/<realm>`. 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`,
Expand Down
2 changes: 2 additions & 0 deletions libs/ferriskey-cli-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub firstname: Option<String>,
pub lastname: Option<String>,
pub email: Option<String>,
Expand Down
5 changes: 5 additions & 0 deletions libs/ferriskey-cli-commands/src/realm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ pub struct RealmImportArgs {
#[arg(long = "source-passwords", value_name = "FILE", verbatim_doc_comment)]
pub source_passwords: Option<PathBuf>,

/// 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,

/// Override the name of the realm created in FerrisKey (defaults to the source realm name).
#[arg(long = "target-realm")]
pub target_realm: Option<String>,
Expand Down
77 changes: 77 additions & 0 deletions libs/ferriskey-cli-core/src/import/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -781,6 +809,7 @@ mod tests {
}],
users: vec![UserBlueprint {
username: "alice".to_owned(),
id: None,
email: None,
firstname: None,
lastname: None,
Expand Down Expand Up @@ -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, "")));
Expand All @@ -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, "")));
Expand Down
57 changes: 57 additions & 0 deletions libs/ferriskey-cli-core/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ impl ClientBlueprint {
pub struct UserBlueprint {
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firstname: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions libs/ferriskey-cli-core/src/import/sources/keycloak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ fn map_client(client: KcClient, roles: Vec<RoleBlueprint>) -> 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,
Expand Down
45 changes: 36 additions & 9 deletions libs/ferriskey-cli-core/src/import/sources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ fn build_from_inline(
) -> Result<Box<dyn RealmSource>, 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(),
Expand All @@ -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(),
Expand All @@ -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 {
Expand All @@ -103,7 +107,7 @@ fn build_from_stored(
) -> Result<Box<dyn RealmSource>, 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()),
Expand All @@ -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()),
Expand All @@ -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(),
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading