Skip to content

Repository files navigation

ferris-ctl

Official CLI for FerrisKey IAM.

Install

cargo install ferris-ctl

Usage

ferris-ctl realm list ferris-ctl realm create myrealm

Importing a realm

ferris-ctl realm import pulls a realm description out of an external system and replays it against FerrisKey. Available sources: config (a YAML or TOML file, see examples/realm.yaml), keycloak, zitadel, and supabase.

Add --dry-run to any import to resolve the source and print what would be created without calling FerrisKey. A dry run needs neither a configured context nor authentication, so it is the cheapest way to check a mapping.

An import converges: re-running it skips what the realm already has rather than failing or duplicating.

Supabase

Reads a project through its Auth (GoTrue) Admin API, authenticating with the project's service_role key.

ferris-ctl realm import \
  --from supabase \
  --source-url https://<project>.supabase.co \
  --source-token <service_role key> \
  --target-realm my-realm

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.

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.

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, which is where applications conventionally keep them — either as a roles array or as a single role string. Both are read and merged:

"app_metadata": { "provider": "email", "roles": ["admin", "billing"] }
"app_metadata": { "provider": "email", "role": "admin" }

Every distinct role named by an imported user becomes a realm role. The catalogue is built from the users that survived the filters above, so a role held only by a soft-deleted or unconfirmed account is not created.

Supabase attaches no description or permission to a role, so imported roles carry a name and nothing else. Permissions have to be granted in FerrisKey afterwards.

The top-level user.role field is not imported. It is the Postgres RLS role, authenticated for virtually every account, and importing it would create a single realm role held by the entire directory.

A role name containing : is rejected with an error naming the role and the user. A realm blueprint reserves that character for client-scoped roles (client_id:role_name), and Supabase defines no clients, so such a name cannot be expressed. Rename it in app_metadata before importing.

Three kinds of account are dropped by default, each re-enabled by its own flag:

Flag Keeps
--source-include-deleted Accounts an operator soft-deleted (deleted_at set, row still present)
--source-include-anonymous Anonymous sign-in sessions, which have neither email nor phone
--source-include-unconfirmed Accounts that never confirmed an email or a phone number

--source-include-anonymous is not undone by the confirmation filter: an anonymous account has nothing to confirm, so it is governed by that flag alone.

Reusable sources

Connection details can be stored once and referenced by name:

ferris-ctl source add supa --kind supabase \
  --url https://<project>.supabase.co --token <service_role key>
ferris-ctl realm import --source-ref supa --target-realm my-realm

Inline --source-* flags override individual fields of a stored source. The Supabase account filters above are deliberately not stored: they are per-run choices, so a saved source can never silently widen a later import.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages