realm

Manage realms, the isolated tenants that hold clients, users, roles, and credentials.

ferris-ctl realm <subcommand>

get, create, and delete take the realm name as an argument. The role subcommands act on --realm, which defaults to the context’s realm. Every subcommand signs in to the context’s realm, so the context must have one. See Authentication.

Known limit: only the first page is read

The server returns lists in pages of 20 by default (100 at most), and ferris-ctl reads the first page only. This affects realm list, which shows at most 20 realms, and the realm roles: realm role list shows the first 20, and realm role get and realm role delete find a role by name in that page, so they answer role '<name>' not found for a role beyond it. --client <client_id> resolves the client by client_id in the first 20 clients of the realm. The roles of a client are not paged. The API reaches the rest with its page and limit parameters, and so does the admin console.

realm list

List the realms the signed-in identity may access.

ferris-ctl realm list

Table columns: NAME, ID.

realm get

Show a single realm’s id and name.

ferris-ctl realm get <name>
ArgumentRequiredDescription
<name>yesRealm name

realm create

Create a new realm and print its id and name.

ferris-ctl realm create <name>
ArgumentRequiredDescription
<name>yesName of the realm to create

realm delete

Delete a realm. This prompts for confirmation, since it also removes the realm’s clients, users, and roles.

ferris-ctl realm delete <name> [--force]
ArgumentRequiredDescription
<name>yesName of the realm to delete
--force / -fnoSkip the confirmation prompt

Confirmation in scripts

Without --force, the command refuses to run in a non-interactive shell rather than deleting silently. Pass --force (or -f) in CI and automation.

realm role

Manage the roles of a realm, or the roles scoped to one of its clients. Every subcommand takes --realm to target a realm other than the context default, and --client <client_id> to work on that client’s roles instead of the realm’s.

realm role create

ferris-ctl realm role create <name> [--description <text>] [--permission <name>]... [--realm <realm>] [--client <client_id>]
ArgumentRequiredDescription
<name>yesRole name
--descriptionnoFree-text description
--permissionnoA permission granted by the role. Repeat the flag for several
--realmnoRealm (defaults to the context realm)
--clientnoCreate a client role scoped to this client id, instead of a realm role

Permissions are passed by their snake_case name, for example manage_users. See the permissions reference for the full list.

ferris-ctl realm role create user-admin \
  --description "Create and update users" \
  --permission manage_users \
  --permission query_users \
  --permission view_users
ferris-ctl realm role create service-reader --client backend

You can only grant permissions you hold

The server lets an identity put on a role only the permissions it holds itself. Without manage_realm, a permission you do not hold, or a name FerrisKey does not know, is refused with 403 cannot grant a permission you do not hold. With manage_realm, any name is accepted, but a name FerrisKey does not recognize is dropped without an error, so a typo gives a role with fewer permissions than you asked for. realm role get prints only the id and name, so check the permissions of the new role in the admin console.

realm role list

ferris-ctl realm role list [--realm <realm>] [--client <client_id>]

Lists realm roles, or the roles of --client when given. Table columns: NAME, ID.

realm role get

ferris-ctl realm role get <name> [--realm <realm>] [--client <client_id>]

Prints the id and name of the role.

realm role delete

ferris-ctl realm role delete <name> [--realm <realm>] [--client <client_id>] [--force]
ArgumentRequiredDescription
<name>yesRole name
--realmnoRealm (defaults to the context realm)
--clientnoDelete a client role instead of a realm role
--force / -fnoSkip the confirmation prompt (required in non-interactive shells)

realm import

Import a realm (its settings, roles, clients, and users) from a description file, a live Keycloak, a live Zitadel, or a Supabase project.

ferris-ctl realm import --from <kind> [source flags] [--target-realm <name>] [--dry-run]
FlagDescription
--fromSource kind: config, keycloak, zitadel, or supabase. Optional with --source-ref, and ignored then: the kind of the stored source wins
--source-refName of a stored source; inline flags below override its values
--filePath to a realm description file (required for --from config); .yaml, .yml, or .toml
--source-urlBase URL of the source instance (Keycloak, Zitadel, Supabase project)
--source-realmKeycloak: the realm to read (required). Supabase and Zitadel with a pinned organization: the name of the realm to create
--source-orgSource organization id (Zitadel); sent as the x-zitadel-orgid header
--source-client-idClient id for source authentication (Keycloak client credentials)
--source-client-secretClient secret for source authentication (Keycloak)
--source-tokenBearer token / personal access token (Zitadel PAT, Supabase service_role key, or a ready Keycloak token)
--target-realmName of the realm created in FerrisKey. See the table below for what it defaults to
--dry-runResolve and print the planned realm without calling the FerrisKey API

Name of the created realm

SourceName of the realm in FerrisKey
configThe name in the file, or --target-realm
keycloakThe name of the source realm, or --target-realm
zitadel with --source-org--target-realm, else --source-realm, else zitadel
zitadel without --source-orgOne realm per organization, named after it
supabase--target-realm, else --source-realm, else supabase

When several Zitadel organizations resolve, --target-realm is ignored and the CLI prints note: --target-realm ignored (N realms resolved; keeping source names) on standard error. With exactly one organization it renames the realm as usual.

Supabase-only flags

--source-passwords and --source-preserve-ids are refused on any other source: the command stops with an error. The three --source-include-* flags are not checked, so on a source other than Supabase they are ignored without a message.

FlagDescription
--source-include-deletedImport users an operator soft-deleted. Dropped by default: their deleted_at is set but the row survives
--source-include-anonymousImport anonymous sign-in sessions. Dropped by default: rows with neither an email nor a phone
--source-include-unconfirmedImport users who never confirmed an email or a phone. Dropped by default
--source-passwords <FILE>Carry passwords over, read from a CSV export of auth.users. The Auth API never serves hashes
--source-preserve-idsCreate each user with its existing Supabase id, so the sub of every token survives

Each one is covered in From Supabase.

Supabase is not in a release yet

The latest release, 0.3.0, imports from config, keycloak, and zitadel only. The Supabase source, --source-passwords, the --source-include-* flags, and --source-preserve-ids are merged on the main branch of ferriskey-cli, but no release includes them yet. Until one does, build from the repository with cargo install --git https://github.com/ferriskey/ferriskey-cli.

ferris-ctl realm import --from config --file realm.yaml --dry-run -o yaml
ferris-ctl realm import --from keycloak \
  --source-url https://keycloak.example.com \
  --source-realm acme \
  --source-client-id ferriskey-import \
  --source-client-secret "$KC_SECRET" \
  --target-realm acme
ferris-ctl realm import --from supabase \
  --source-url https://abcdefgh.supabase.co \
  --source-token "$SUPABASE_SERVICE_ROLE_KEY" \
  --source-passwords auth_users.csv \
  --source-preserve-ids \
  --target-realm acme

Dry run and report

--dry-run resolves the source and prints what it found without calling FerrisKey, so it needs no context and no login. It still reads the source, so the source credentials must be valid, and a --source-passwords file is read too. The table form shows counts: whether there are settings, and how many roles, clients, users, and passwords. With -o json or -o yaml you get the whole description as an array, with every password hash replaced by <redacted>.

A real import prints a report of what it created, any warnings, and the secrets of confidential clients. Import describes it.

Re-running an import

Entities that already exist are reused and counted as already present; they are not updated. Realm settings and per-client settings from the source are applied again on every run. Idempotency lists what that means in practice.

The Import section covers each source in full, including what is and isn’t migrated.