feat(import): add supabase as a user and role import source - #43
Merged
Merged
Conversation
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.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
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.
NathaelB
approved these changes
Sep 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #42
Adds
--from supabasetoferris-ctl realm import. Supabase joinsconfig/keycloak/zitadel as a
RealmSource, read through the Auth (GoTrue)Admin API using the project's
service_rolekey.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 tosupabase.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_pageserver-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
pageand keepsserving the first one.
Verified against a stub server capping
per_pageat 2 while the CLI asked for100: 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_passwordand does notserve them over the Admin API. The FerrisKey API accepts only a plaintext
password on
reset-password. Neither side exposes a hash, so imported usersarrive without credentials and need a reset.
This is documented in the
--from supabasehelp text and the README rather thansurfaced as an
ImportReportwarning. Warnings are produced inapply.rs,which has no knowledge of the source; giving a source a channel to emit one
would have meant changing the
RealmSourcetrait and therefore touching theKeycloak 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 filtersSupabase has no role catalogue. Roles are read from each user's
app_metadata— a
rolesarray, a singlerolestring, or both, merged with the firstoccurrence 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-roleand
unconfirmed-only-roleare absent under the default filters and reappearonce 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.roleis not imported. It is the Postgres RLS role,authenticatedfor virtually every account — mapping it would create one realmrole held by the entire directory.
A role name containing
:is rejected at the boundaryparse_role_refsplits aUserBlueprint.rolesentry on the first colon to readclient_id:role_name. A namespaced Supabase role such asbilling:readwouldtherefore have been read as the role
readof a clientbilling. Supabaseproduces no clients, so the import would have failed deep in
apply.rswith anUnresolvedClientRoleerror 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.
emailandphoneare normalised at the boundaryGoTrue serialises an absent email or phone as
"", notnull(
storage.NullStringwithoutomitempty). A plainOption<String>wouldtherefore carry an empty string into the blueprint and create users with a blank
address. A custom deserializer maps blank to
Noneso the rest of the adapternever sees it.
Account filters
Three kinds of account are dropped by default, each re-enabled by its own flag:
--source-include-deleted--source-include-anonymous--source-include-unconfirmedThey 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_anonymousis 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.rscarries no comments, at the request of the repository owner. Thedoc comments on the clap structures are kept deliberately: clap reads them to
build
--help, so they are program output rather than commentary. The reasoningthose comments would have carried is in the commit messages and in this
description, where it cannot drift out of sync with the code.
Verification
cargo clippy --workspace --all-targets -- -D warningsclean.swapping the catalogue's
BTreeSetfor aVec, each made exactly one testfail and no others.
users kept), all filters disabled (7 of 7), the role catalogue under both, a
per_pagecapped below the requested size, blank and non-string entries in arolesarray,provider/providersnot being mistaken for roles, anamespaced role name, a project URL given both with and without a trailing
/auth/v1, a rejectedservice_rolekey (401 surfaced naming the source), amissing
--source-url, the default realm name, and an import through a stored--source-ref.cargo fmtwas applied to the new file only. The repository is not currentlyrustfmt-clean, and reformatting the files this PR touches would have mixed
unrelated churn into the diff.