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
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 84 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<project>.supabase.co \
--source-token <service_role key> \
--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 "<connection string>" -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.<project-ref>.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
`<redacted>` 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`,
Expand Down
34 changes: 34 additions & 0 deletions libs/ferriskey-cli-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub temporary: bool,
}

impl FerriskeyClient {
pub fn new(
base_url: impl Into<String>,
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 29 additions & 4 deletions libs/ferriskey-cli-commands/src/realm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 "<connection string>" -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<PathBuf>,

/// 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
1 change: 1 addition & 0 deletions libs/ferriskey-cli-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
89 changes: 89 additions & 0 deletions libs/ferriskey-cli-core/src/import/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> = if user_existed && !user.roles.is_empty() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -737,6 +786,7 @@ mod tests {
lastname: None,
email_verified: None,
roles: vec!["admin".to_owned()],
credential: None,
}],
}
}
Expand All @@ -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();
Expand All @@ -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, "")));
Expand Down
Loading
Loading