Skip to content

feat(import): add supabase as a user and role import source - #43

Merged
NathaelB merged 2 commits into
mainfrom
feature/supabase-import
Sep 24, 2026
Merged

NathaelB merged 2 commits into
mainfrom
feature/supabase-import

Conversation

@LeadcodeDev

@LeadcodeDev LeadcodeDev commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #42

Adds --from supabase to ferris-ctl realm import. Supabase joins
config/keycloak/zitadel as a RealmSource, read through the Auth (GoTrue)
Admin API using the project's service_role key.

Supabase has no realm and no OIDC client, so the adapter carries users and
their roles
, and nothing else. The realm name comes from --target-realm (or
--source-realm) and defaults to supabase.

Decisions the diff does not show

Pagination does not use the short-page test

The Keycloak adapter stops when a page comes back shorter than the size it
asked for. That test is wrong against GoTrue, which caps per_page
server-side
: the CLI asks for 100 and a deployment may hand back 50, so every
page looks short and the walk would stop after the first batch — silently
importing a fraction of the directory and reporting success.

This adapter instead stops when a page brings no id it has not already seen.
That also bounds the walk against a deployment that ignores page and keeps
serving the first one.

Verified against a stub server capping per_page at 2 while the CLI asked for
100: all 7 users came back, where the short-page test would have returned 2.

Passwords are not migrated, and cannot be

Supabase keeps bcrypt hashes in auth.users.encrypted_password and does not
serve them over the Admin API. The FerrisKey API accepts only a plaintext
password on reset-password. Neither side exposes a hash, so imported users
arrive without credentials and need a reset.

This is documented in the --from supabase help text and the README rather than
surfaced as an ImportReport warning. Warnings are produced in apply.rs,
which has no knowledge of the source; giving a source a channel to emit one
would have meant changing the RealmSource trait and therefore touching the
Keycloak and Zitadel adapters. That is a change of its own.

Usernames come from the full email address

Supabase users have no username; FerrisKey requires one. The full address is
used, falling back to the phone number and then to the Supabase id.

The email's local part was considered and rejected: two users from different
domains sharing a prefix would collide, and an import that is not replayable
stops converging on re-run.

Roles come from app_metadata, and the catalogue follows the filters

Supabase has no role catalogue. Roles are read from each user's app_metadata
— a roles array, a single role string, or both, merged with the first
occurrence winning so a project that moved between conventions neither loses a
role nor gains a duplicate.

The realm catalogue is derived from the users that survived the filters, not
from the raw directory, so a role held only by a soft-deleted or unconfirmed
account is never created. Verified against the stub server: ghost-only-role
and unconfirmed-only-role are absent under the default filters and reappear
once the filters are lifted.

Supabase attaches neither description nor permission to a role, so entries carry
a name and nothing else; permissions are granted in FerrisKey afterwards.

The top-level user.role is not imported. It is the Postgres RLS role,
authenticated for virtually every account — mapping it would create one realm
role held by the entire directory.

A role name containing : is rejected at the boundary

parse_role_ref splits a UserBlueprint.roles entry on the first colon to read
client_id:role_name. A namespaced Supabase role such as billing:read would
therefore have been read as the role read of a client billing. Supabase
produces no clients, so the import would have failed deep in apply.rs with an
UnresolvedClientRole error talking about clients the operator never defined.

It now fails at the source boundary with a message naming the role, the user and
the fix. The import still stops either way; this only makes the reason legible.

email and phone are normalised at the boundary

GoTrue serialises an absent email or phone as "", not null
(storage.NullString without omitempty). A plain Option<String> would
therefore carry an empty string into the blueprint and create users with a blank
address. A custom deserializer maps blank to None so the rest of the adapter
never sees it.

Account filters

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

Flag Keeps
--source-include-deleted Accounts soft-deleted by an operator
--source-include-anonymous Anonymous sign-in sessions
--source-include-unconfirmed Accounts that never confirmed an email or phone

They are read from the command line only and never from a stored source: they
are per-run choices about which rows to replay, so a saved source must not
silently widen a later import.

include_anonymous is deliberately not undone by the confirmation filter.
An anonymous account has neither address nor phone, so it has nothing to
confirm — subjecting it to both filters would make the flag a no-op.

A note on comments

supabase.rs carries no comments, at the request of the repository owner. The
doc comments on the clap structures are kept deliberately: clap reads them to
build --help, so they are program output rather than commentary. The reasoning
those comments would have carried is in the commit messages and in this
description, where it cannot drift out of sync with the code.

Verification

  • 34 unit tests on the adapter; full suite 112 passed.
  • cargo clippy --workspace --all-targets -- -D warnings clean.
  • The two subtle behaviours were mutation-checked: removing the colon guard, and
    swapping the catalogue's BTreeSet for a Vec, each made exactly one test
    fail and no others.
  • End-to-end against a stub GoTrue server, exercising: default filters (4 of 7
    users kept), all filters disabled (7 of 7), the role catalogue under both, a
    per_page capped below the requested size, blank and non-string entries in a
    roles array, provider/providers not being mistaken for roles, a
    namespaced role name, a project URL given both with and without a trailing
    /auth/v1, a rejected service_role key (401 surfaced naming the source), a
    missing --source-url, the default realm name, and an import through a stored
    --source-ref.

cargo fmt was applied to the new file only. The repository is not currently
rustfmt-clean, and reformatting the files this PR touches would have mixed
unrelated churn into the diff.

Supabase joins config/keycloak/zitadel as a `RealmSource`, read through the
Auth (GoTrue) Admin API with the project's `service_role` key.

Supabase exposes no realm, no OIDC client and no role catalogue, so the
adapter carries users and nothing else; the realm name comes from
--target-realm and defaults to `supabase`.

Three decisions worth recording, none of them visible in the diff:

Pagination terminates on "this page brought no id we had not already seen"
rather than on the short-page test the Keycloak adapter uses. GoTrue caps
per_page server-side, so asking for 100 and getting 50 is the ordinary case,
not the last page — the short-page test would have stopped after the first
batch and silently dropped the rest of the directory. Tracking ids also
bounds the walk against a deployment that ignores `page`. Verified against a
stub server capping per_page at 2 while the CLI asked for 100: all 7 users
came back, where the short-page test would have returned 2.

Passwords are not migrated and cannot be. 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 on reset-password.
Neither side exposes a hash, so imported users need a reset. Documented in
the module header, the CLI help and the README rather than surfaced as an
import warning: the RealmSource trait has no channel for a source to emit
one, and adding it would have meant touching Keycloak and Zitadel too.

Supabase users have no username. The full email address is used, falling
back to the phone number and then to the Supabase id. The local part was
rejected: two users from different domains sharing a prefix would collide,
and the import has to stay replayable.

The deleted/anonymous/unconfirmed filters are per-run flags read from the
command line only, never from a stored source, so a saved source cannot
silently widen a later import. include_anonymous is deliberately not undone
by the confirmation filter — an anonymous account has nothing to confirm, so
subjecting it to both would make the flag a no-op.
@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Sep 20, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 20, 2026
@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 5cb9c04f-daa6-45ab-a975-99390b62cf52


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Supabase has no role catalogue, so roles are read from each user's
app_metadata, where applications conventionally keep them: a `roles` array,
a single `role` string, or both. They are merged with the first occurrence
winning, so a project that moved between the two conventions neither loses a
role nor gains a duplicate.

The realm catalogue is derived from the users that survived the filters, not
from the raw directory. A role held only by a soft-deleted, anonymous or
unconfirmed account is therefore never created in the target realm. Verified
against the stub server: `ghost-only-role` and `unconfirmed-only-role` are
absent under the default filters and reappear once the filters are lifted.

Supabase attaches neither description nor permission to a role, so every
catalogue entry carries a name and nothing else.

The top-level `user.role` is deliberately not imported. It is the Postgres
RLS role, `authenticated` for virtually every account; mapping it would
create a single realm role held by the entire directory, which tells a
reader nothing.

A role name containing ':' is now a hard error at the source boundary.
`parse_role_ref` splits a `UserBlueprint.roles` entry on the first colon to
get `client_id:role_name`, so a namespaced Supabase role such as
`billing:read` would have been read as the role `read` of a client
`billing`. Supabase produces no clients, so the import would have failed
deep in apply.rs with an UnresolvedClientRole error talking about clients
the operator never defined. Failing early with a message naming the role,
the user and the fix is strictly better than that.

The two subtle behaviours here — the colon rejection and the sorted,
deduplicated catalogue — were checked by mutation: removing the guard, and
swapping the BTreeSet for a Vec, each made exactly one test fail.

This commit also strips the comments from supabase.rs and the one added to
sources/mod.rs, per project preference. The doc comments on the clap
structures are kept: clap reads them to build `--help`, so they are output,
not commentary. The reasoning the comments carried lives in this history and
in the pull request instead, where it cannot drift out of sync with the code.
@LeadcodeDev LeadcodeDev changed the title feat(import): add supabase as a user import source feat(import): add supabase as a user and role import source Sep 20, 2026
@NathaelB
NathaelB merged commit cd91a7a into main Sep 24, 2026
4 checks passed
@NathaelB
NathaelB deleted the feature/supabase-import branch September 24, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Import users and their roles from Supabase

2 participants