Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/dashboard/client_access-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/dashboard/client_scopes-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed apps/docs/public/dashboard/client_scopes.png
Binary file not shown.
Binary file added apps/docs/public/dashboard/client_secret-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed apps/docs/public/dashboard/client_secret.png
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed apps/docs/public/dashboard/client_settings.png
Binary file not shown.
Binary file added apps/docs/public/dashboard/compass-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/dashboard/compass-light.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/dashboard/create_client-dark.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed apps/docs/public/dashboard/create_client.png
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/public/dashboard/ldap_provider-dark.jpg
Binary file added apps/docs/public/dashboard/ldap_sync-dark.jpg
Binary file added apps/docs/public/dashboard/ldap_sync-light.jpg
Binary file added apps/docs/public/dashboard/list_clients-dark.jpg
Binary file added apps/docs/public/dashboard/list_clients-light.jpg
Binary file removed apps/docs/public/dashboard/list_clients.png
Diff not rendered.
Binary file added apps/docs/public/dashboard/portal_themes-dark.jpg
Binary file added apps/docs/public/dashboard/realm_switch-dark.jpg
Binary file added apps/docs/public/dashboard/realm_switch-light.jpg
Binary file removed apps/docs/public/dashboard/realm_switch.png
Diff not rendered.
Binary file added apps/docs/public/dashboard/seawatch-dark.jpg
Binary file added apps/docs/public/dashboard/seawatch-light.jpg
Binary file added apps/docs/public/dashboard/webhooks_list-dark.jpg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: client
title: Client
description: "Create, inspect, and delete OAuth2 clients within a realm."
icon: box
order: 3
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: context
title: Context
description: "Manage connection contexts: named profiles that store the server URL, client, and default realm."
icon: server
order: 1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: login
title: Login
description: "Sign in via the OAuth 2.0 Device Authorization Grant and persist a session for other commands."
icon: log-in
order: 6
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: logout
title: Logout
description: "Remove the stored login session."
icon: log-out
order: 7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,35 @@ order: 0
## Connect and authenticate

::::card-group{cols=3}
:::card{label="context" icon="lucide:server" href="/en/cli/commands/context"}
:::card{label="Context" icon="lucide:server" href="/en/cli/commands/context"}
Named connection profiles storing a server URL, a client, and a default realm. Switch environments without retyping flags.
:::
:::card{label="login" icon="lucide:log-in" href="/en/cli/commands/login"}
:::card{label="Login" icon="lucide:log-in" href="/en/cli/commands/login"}
Sign in with the OAuth 2.0 Device Authorization Grant and persist the session for every later command.
:::
:::card{label="logout" icon="lucide:log-out" href="/en/cli/commands/logout"}
:::card{label="Logout" icon="lucide:log-out" href="/en/cli/commands/logout"}
Drop the stored session by deleting the credentials file.
:::
::::

## Administer a realm

::::card-group{cols=3}
:::card{label="realm" icon="lucide:layers" href="/en/cli/commands/realm"}
:::card{label="Realm" icon="lucide:layers" href="/en/cli/commands/realm"}
Create, inspect, and delete realms, manage their roles, and import one from an external source.
:::
:::card{label="client" icon="lucide:box" href="/en/cli/commands/client"}
:::card{label="Client" icon="lucide:box" href="/en/cli/commands/client"}
Create, inspect, and delete OAuth2 clients within a realm.
:::
:::card{label="user" icon="lucide:users" href="/en/cli/commands/user"}
:::card{label="User" icon="lucide:users" href="/en/cli/commands/user"}
Create, inspect, and delete users, set their passwords, and assign roles.
:::
::::

## Import

::::card-group{cols=2}
:::card{label="source" icon="lucide:database" href="/en/cli/commands/source"}
:::card{label="Source" icon="lucide:database" href="/en/cli/commands/source"}
Store reusable import sources, so credentials and URLs are not repeated on every import.
:::
:::card{label="Importing realms" icon="lucide:import" href="/en/cli/import/overview"}
Expand Down
33 changes: 28 additions & 5 deletions apps/docs/src/content/docs/cli/default/en/commands/realm.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: realm
title: Realm
description: "Create, inspect, delete, and import realms, and manage realm and client roles."
icon: layers
order: 2
Expand Down Expand Up @@ -131,26 +131,40 @@ ferris-ctl realm role delete <name> [--realm <realm>] [--client <client_id>] [--

## `realm import`

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

```bash
ferris-ctl realm import --from <kind> [source flags] [--target-realm <name>] [--dry-run]
```

| Flag | Description |
|------|-------------|
| `--from` | Source kind: `config`, `keycloak`, or `zitadel` (optional if `--source-ref` is given) |
| `--from` | Source kind: `config`, `keycloak`, `zitadel`, or `supabase` (optional if `--source-ref` is given) |
| `--source-ref` | Name of a stored [source](/en/cli/commands/source); inline flags below override its values |
| `--file` | Path to a realm description file (required for `--from config`); `.yaml`, `.yml`, or `.toml` |
| `--source-url` | Base URL of the source instance (Keycloak / Zitadel) |
| `--source-url` | Base URL of the source instance (Keycloak, Zitadel, Supabase project) |
| `--source-realm` | Source realm name (Keycloak) |
| `--source-org` | Source organization id (Zitadel); sent as the `x-zitadel-orgid` header |
| `--source-client-id` | Client id for source authentication (Keycloak client credentials) |
| `--source-client-secret` | Client secret for source authentication (Keycloak) |
| `--source-token` | Bearer token / personal access token (Zitadel PAT, or a ready Keycloak token) |
| `--source-token` | Bearer token / personal access token (Zitadel PAT, Supabase `service_role` key, or a ready Keycloak token) |
| `--target-realm` | Override the name of the realm created in FerrisKey (defaults to the source realm name) |
| `--dry-run` | Resolve and print the planned realm without calling the FerrisKey API |

### Supabase-only flags

These five are refused on any other source rather than silently ignored:

| Flag | Description |
|------|-------------|
| `--source-include-deleted` | Import users an operator soft-deleted. Dropped by default: their `deleted_at` is set but the row survives |
| `--source-include-anonymous` | Import anonymous sign-in sessions. Dropped by default: rows with neither an email nor a phone |
| `--source-include-unconfirmed` | Import 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-ids` | Create each user with its existing Supabase id, so the `sub` of every token survives |

Each one is covered in [From Supabase](/en/cli/import/supabase).

```bash title="Preview a realm from a file (no network writes)"
ferris-ctl realm import --from config --file examples/realm.yaml --dry-run -o yaml
```
Expand All @@ -164,6 +178,15 @@ ferris-ctl realm import --from keycloak \
--target-realm acme
```

```bash title="Import a Supabase project, passwords and ids included"
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
```

The [Import](/en/cli/import/overview) section covers each source in full, including what is and isn't migrated.

:::callout{variant="info" title="Re-running imports is safe"}
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/cli/default/en/commands/source.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: source
title: Source
description: "Store reusable import sources so you don't repeat credentials and URLs on every realm import."
icon: database
order: 5
Expand Down Expand Up @@ -34,7 +34,7 @@ ferris-ctl source add <name> --kind <kind> --url <url> [auth flags]
| Argument | Required | Description |
|----------|----------|-------------|
| `<name>` | yes | Source name |
| `--kind` | yes | `keycloak` or `zitadel` |
| `--kind` | yes | `keycloak`, `zitadel` or `supabase`. A config file is not a storable source: it is a path, passed with `--file` at import time |
| `--url` | yes | Base URL of the source instance |
| `--realm` | no | Source realm name (Keycloak) |
| `--client-id` | no | Client id for source auth (Keycloak) |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: user
title: User
description: "Create, inspect, and delete users, set passwords, and manage role assignments."
icon: users
order: 4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Permissions use the names from the [permissions reference](/en/discover/core-con
| `public_client` | `false` | Public client, no secret |
| `service_account_enabled` | `false` | Create a linked service account user |
| `direct_access_grants_enabled` | `false` | Allow the password grant |
| `device_authorization_grant_enabled` | `false` | Allow the device code grant |
| `oauth_device_code_grant_enabled` | `false` | Allow the device code grant |
| `redirect_uris` | `[]` | Allowed redirect URIs |
| `post_logout_redirect_uris` | `[]` | Allowed post-logout redirect URIs |
| `web_origins` | `[]` | Browser origins allowed on this client's realm-scoped routes |
Expand Down
9 changes: 6 additions & 3 deletions apps/docs/src/content/docs/cli/default/en/import/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ order: 1

# Import

`ferris-ctl realm import` brings a realm (its settings, roles, clients, and users) into FerrisKey from three kinds of source:
`ferris-ctl realm import` brings a realm (its settings, roles, clients, and users) into FerrisKey from four kinds of source:

::::card-group{cols=3}
:::card{label="Config file" icon="lucide:file-text" href="/en/cli/import/config-file"}
Expand All @@ -19,20 +19,23 @@ Read a realm live from a Keycloak Admin REST API.
:::card{label="Zitadel" icon="lucide:server" href="/en/cli/import/zitadel"}
Read organizations and projects live from a Zitadel instance.
:::
:::card{label="Supabase" icon="lucide:database" href="/en/cli/import/supabase"}
Read users live from a Supabase project. Users only, with optional password and id carry-over.
:::
::::

## How it works

Each source is resolved into a **blueprint** (a normalized description of the realm) which is then applied to FerrisKey.

```
source (file | keycloak | zitadel) → blueprint → apply to FerrisKey
source (file | keycloak | zitadel | supabase) → blueprint → apply to FerrisKey
```

Apply order: realm → settings → realm roles → clients (with redirect URIs and client roles) → users (with role assignments).

```bash title="Basic shape"
ferris-ctl realm import --from <config|keycloak|zitadel> [source flags] [--target-realm <name>]
ferris-ctl realm import --from <config|keycloak|zitadel|supabase> [source flags] [--target-realm <name>]
```

See the full flag list on the [`realm import`](/en/cli/commands/realm) reference.
Expand Down
159 changes: 159 additions & 0 deletions apps/docs/src/content/docs/cli/default/en/import/supabase.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
---
title: From Supabase
description: "Import users from a Supabase project, carry their password hashes over, and keep their ids so tokens survive."
icon: database
order: 5
---

# From Supabase

Supabase is the one source that is **users only**. It has no client catalogue and no role catalogue to read, so an import brings the accounts across and nothing else — you create the clients and the realm settings yourself afterwards.

```bash
ferris-ctl realm import --from supabase \
--source-url https://abcdefgh.supabase.co \
--source-token "$SUPABASE_SERVICE_ROLE_KEY" \
--target-realm acme
```

Accounts are read through the project's **Auth (GoTrue) Admin API**, which is why `--source-token` wants the project's `service_role` key rather than the anon key.

## What gets carried over

| FerrisKey field | Comes from |
|---|---|
| `username` | The email. Falling back to the phone number, then to the Supabase id |
| `email` | The email, when there is one |
| `email_verified` | Whether `email_confirmed_at` is set — only computed for users who have an email |
| `firstname` / `lastname` | `user_metadata`, trying `first_name`, `firstName`, `given_name` and the matching last-name keys; failing that, `full_name` or `name` split in two |
| Roles | `app_metadata.roles` (an array) and `app_metadata.role` (a single string), merged and de-duplicated |
| Password | Only from `--source-passwords`, below |

Roles are created in the target realm as they are encountered, so a `roles` array in `app_metadata` is enough to rebuild a role catalogue Supabase never had.

:::callout{variant="warning" title="A role name cannot contain a colon"}
FerrisKey reserves `:` to separate a client from its role, so a Supabase role like `billing:admin` is refused rather than silently reinterpreted. The import stops and names both the role and the user carrying it. Rename it in `app_metadata` before re-running.
:::

## Three kinds of row are dropped by default

Supabase's `auth.users` holds rows that are not really accounts you want. All three filters are **off** by default, and turning one on is a deliberate act:

| Flag | What it lets in | Why it is off by default |
|---|---|---|
| `--source-include-deleted` | Users an operator soft-deleted | Their `deleted_at` is set but the row survives. A plain import would resurrect accounts somebody removed on purpose |
| `--source-include-anonymous` | Anonymous sign-in sessions | They are real rows with neither an email nor a phone number |
| `--source-include-unconfirmed` | Users who never confirmed an email or a phone | Nothing proves the address or the number belongs to them |

A user counts as confirmed when **either** `email_confirmed_at` or `phone_confirmed_at` is set.

:::callout{variant="info" title="Count before you commit"}
Run the import with `--dry-run` first, then again with a filter added, and compare the user counts. That difference is exactly how many soft-deleted, anonymous or unconfirmed rows your project is carrying — usually more than anyone expects.
:::

## Carrying the passwords over

The Auth Admin API **never serves password hashes**. Exporting the table is the only way to get them, so `--source-passwords` takes a CSV file rather than reading the API:

```bash
ferris-ctl realm import --from supabase \
--source-url https://abcdefgh.supabase.co \
--source-token "$SUPABASE_SERVICE_ROLE_KEY" \
--source-passwords auth_users.csv \
--target-realm acme
```

### Producing the export

The connection string is behind the project's **Connect** button:

```bash
psql "<connection string>" -c \
"\copy (select id, encrypted_password from auth.users) to 'auth_users.csv' with (format csv, header)"
```

:::callout{variant="warning" title="The backslash on \\copy matters"}
`\copy` runs client-side and writes the file on **your** machine. A plain `COPY TO` would try to write on the database server, where you have no filesystem access.
:::

Or run `select id, encrypted_password from auth.users;` in the dashboard's 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** — which is what makes the join safe when an address has changed on one side.

:::callout{variant="danger" title="That file holds every password hash in your directory"}
Keep it to yourself, never commit it, and delete it once the import has run. It is the single most sensitive artefact a migration produces.
:::

### What survives, and what does not

Only **bcrypt** hashes that FerrisKey accepts are imported. Every other account arrives without credentials and needs a password reset — so plan the reset emails for that subset rather than for everyone.

An imported hash is re-encoded as argon2id the first time that user signs in successfully. See [Export & Import](/en/discover/guides/portability#password-hashes) for what that means on the FerrisKey side, including the `password_imported` audit event that lets you measure migration progress.

## Keeping the user ids

```bash
ferris-ctl realm import --from supabase … --source-preserve-ids
```

Creates each user with the id it already has in Supabase, so the **`sub` claim of every token survives the migration**. Supabase only.

Reach for it when anything downstream stores the Supabase user id as a foreign key — your own tables, an analytics warehouse, a billing system. Without it those references all break and you are writing a mapping table.

## A realistic migration

::::step-group
:::step{title="Preview, and read the counts"}
```bash
ferris-ctl realm import --from supabase \
--source-url https://abcdefgh.supabase.co \
--source-token "$SUPABASE_SERVICE_ROLE_KEY" \
--target-realm acme --dry-run -o yaml
```

No FerrisKey call is made. Check the user count and the roles that were derived from `app_metadata`.
:::

:::step{title="Decide on the filtered rows"}
Re-run the preview with each `--source-include-*` flag to see what you are leaving behind, and make that a decision rather than a default.
:::

:::step{title="Export the passwords"}
Only once you are about to run the real import — the file should exist for as short a time as possible.
:::

:::step{title="Import"}
Add `--source-passwords` and, if anything references the Supabase ids, `--source-preserve-ids`. Delete the CSV afterwards.
:::

:::step{title="Build what Supabase did not have"}
Clients, redirect URIs, scopes and realm settings. [Connect an Application with SSO](/en/discover/guides/application-sso) is the path for each application.
:::
::::

:::callout{variant="info" title="Re-running is safe"}
Like every source, a Supabase import is idempotent: accounts that already exist are skipped with a warning, so you can fix the source and run it again.
:::

## Storing the connection

Typing the service_role key on every run is how it ends up in a shell history. Store the source once:

```bash
ferris-ctl source add supabase-prod --kind supabase \
--url https://abcdefgh.supabase.co \
--token "$SUPABASE_SERVICE_ROLE_KEY"

ferris-ctl realm import --source-ref supabase-prod --target-realm acme
```

Inline `--source-*` flags still override individual fields of a stored source, so the stored entry can hold the connection while the run decides the filters. See [`source`](/en/cli/commands/source).

::::card-group{cols=2}
:::card{label="Import overview" icon="lucide:download" href="/en/cli/import/overview"}
How imports resolve, dry runs, and idempotency.
:::
:::card{label="realm import" icon="lucide:layers" href="/en/cli/commands/realm"}
Every flag, for all four sources.
:::
::::
Loading
Loading