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
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,90 @@ cargo install ferris-ctl

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`.

**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.

#### 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.
24 changes: 23 additions & 1 deletion libs/ferriskey-cli-commands/src/realm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ pub enum ImportSource {
Keycloak,
/// 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).
Supabase,
}

/// Arguments for `realm import`.
Expand Down Expand Up @@ -184,10 +189,27 @@ pub struct RealmImportArgs {
#[arg(long = "source-client-secret")]
pub source_client_secret: Option<String>,

/// Bearer token / personal access token for the source (Zitadel PAT, or a ready Keycloak token).
/// Bearer token / personal access token for the source (Zitadel PAT,
/// Supabase `service_role` key, or a ready Keycloak token).
#[arg(long = "source-token")]
pub source_token: Option<String>,

/// Import users an operator soft-deleted (Supabase). Dropped by default:
/// their `deleted_at` is set but the row survives, so a plain import would
/// resurrect accounts somebody removed on purpose.
#[arg(long = "source-include-deleted", default_value_t = false)]
pub source_include_deleted: bool,

/// Import anonymous sign-in sessions (Supabase). Dropped by default: they
/// are real rows with neither an email nor a phone number.
#[arg(long = "source-include-anonymous", default_value_t = false)]
pub source_include_anonymous: bool,

/// Import users who never confirmed an email or a phone number (Supabase).
/// Dropped by default.
#[arg(long = "source-include-unconfirmed", default_value_t = false)]
pub source_include_unconfirmed: 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
5 changes: 4 additions & 1 deletion libs/ferriskey-cli-commands/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ pub enum SourceSubcommand {
pub enum SourceKind {
Keycloak,
Zitadel,
Supabase,
}

impl SourceKind {
pub fn as_str(&self) -> &'static str {
match self {
SourceKind::Keycloak => "keycloak",
SourceKind::Zitadel => "zitadel",
SourceKind::Supabase => "supabase",
}
}
}
Expand Down Expand Up @@ -61,7 +63,8 @@ pub struct SourceAddArgs {
#[arg(long = "client-secret")]
pub client_secret: Option<String>,

/// Bearer token / personal access token (Zitadel PAT, or a ready Keycloak token).
/// Bearer token / personal access token (Zitadel PAT, Supabase
/// `service_role` key, or a ready Keycloak token).
#[arg(long)]
pub token: Option<String>,

Expand Down
6 changes: 6 additions & 0 deletions libs/ferriskey-cli-core/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ pub enum ImportError {
pin a single organization with --source-org / org-id, or grant the token an IAM manager role"
)]
ZitadelOrgListingForbidden,
#[error(
"Supabase role '{role}' on user '{username}' contains ':', which a realm blueprint \
reserves for client-scoped roles ('client_id:role_name'); Supabase defines no clients, \
so rename the role in app_metadata before importing"
)]
SupabaseNamespacedRole { role: String, username: String },
#[error("stored source '{name}' has kind '{kind}', which is not a valid import kind")]
InvalidStoredKind { name: String, kind: String },
#[error("failed to read source file '{path}'")]
Expand Down
25 changes: 25 additions & 0 deletions libs/ferriskey-cli-core/src/import/sources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

pub mod config;
pub mod keycloak;
pub mod supabase;
pub mod zitadel;

use ferriskey_cli_commands::{ImportSource, RealmImportArgs};
Expand All @@ -12,6 +13,7 @@ use crate::config::{FileContextRepository, StoredSource};
use super::{ImportError, RealmSource};
use config::ConfigSource;
use keycloak::KeycloakSource;
use supabase::{SupabaseSource, UserFilters};
use zitadel::ZitadelSource;

/// Builds the appropriate [`RealmSource`] from the parsed CLI arguments.
Expand Down Expand Up @@ -57,6 +59,20 @@ fn build_from_inline(
args.source_org.clone(),
args.target_realm.clone().or_else(|| args.source_realm.clone()),
)?)),
ImportSource::Supabase => Ok(Box::new(SupabaseSource::build(
args.source_url.clone(),
args.source_token.clone(),
args.target_realm.clone().or_else(|| args.source_realm.clone()),
user_filters(args),
)?)),
}
}

fn user_filters(args: &RealmImportArgs) -> UserFilters {
UserFilters {
include_deleted: args.source_include_deleted,
include_anonymous: args.source_include_anonymous,
include_unconfirmed: args.source_include_unconfirmed,
}
}

Expand Down Expand Up @@ -84,6 +100,15 @@ fn build_from_stored(
.or_else(|| args.source_realm.clone())
.or_else(|| stored.realm.clone()),
)?)),
"supabase" => Ok(Box::new(SupabaseSource::build(
args.source_url.clone().or_else(|| Some(stored.url.clone())),
args.source_token.clone().or_else(|| stored.token.clone()),
args.target_realm
.clone()
.or_else(|| args.source_realm.clone())
.or_else(|| stored.realm.clone()),
user_filters(args),
)?)),
other => Err(ImportError::InvalidStoredKind {
name: name.to_owned(),
kind: other.to_owned(),
Expand Down
Loading
Loading