diff --git a/README.md b/README.md index 1d82b05..70b00d1 100644 --- a/README.md +++ b/README.md @@ -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://.supabase.co \ + --source-token \ + --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://.supabase.co --token + 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. diff --git a/libs/ferriskey-cli-commands/src/realm.rs b/libs/ferriskey-cli-commands/src/realm.rs index 1448c51..c0b13a7 100644 --- a/libs/ferriskey-cli-commands/src/realm.rs +++ b/libs/ferriskey-cli-commands/src/realm.rs @@ -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`. @@ -184,10 +189,27 @@ pub struct RealmImportArgs { #[arg(long = "source-client-secret")] pub source_client_secret: Option, - /// 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, + /// 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, diff --git a/libs/ferriskey-cli-commands/src/source.rs b/libs/ferriskey-cli-commands/src/source.rs index 995c0a4..63882a8 100644 --- a/libs/ferriskey-cli-commands/src/source.rs +++ b/libs/ferriskey-cli-commands/src/source.rs @@ -24,6 +24,7 @@ pub enum SourceSubcommand { pub enum SourceKind { Keycloak, Zitadel, + Supabase, } impl SourceKind { @@ -31,6 +32,7 @@ impl SourceKind { match self { SourceKind::Keycloak => "keycloak", SourceKind::Zitadel => "zitadel", + SourceKind::Supabase => "supabase", } } } @@ -61,7 +63,8 @@ pub struct SourceAddArgs { #[arg(long = "client-secret")] pub client_secret: Option, - /// 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, diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index 2eb81ce..5e89068 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -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}'")] diff --git a/libs/ferriskey-cli-core/src/import/sources/mod.rs b/libs/ferriskey-cli-core/src/import/sources/mod.rs index 8928829..53a7290 100644 --- a/libs/ferriskey-cli-core/src/import/sources/mod.rs +++ b/libs/ferriskey-cli-core/src/import/sources/mod.rs @@ -3,6 +3,7 @@ pub mod config; pub mod keycloak; +pub mod supabase; pub mod zitadel; use ferriskey_cli_commands::{ImportSource, RealmImportArgs}; @@ -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. @@ -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, } } @@ -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(), diff --git a/libs/ferriskey-cli-core/src/import/sources/supabase.rs b/libs/ferriskey-cli-core/src/import/sources/supabase.rs new file mode 100644 index 0000000..a2f6063 --- /dev/null +++ b/libs/ferriskey-cli-core/src/import/sources/supabase.rs @@ -0,0 +1,681 @@ +use std::collections::{BTreeSet, HashSet}; + +use reqwest::blocking::Client; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value}; + +use crate::import::{ImportError, RealmBlueprint, RealmSource, RoleBlueprint, UserBlueprint}; + +const SOURCE: &str = "supabase"; +const USER_PAGE_SIZE: usize = 100; +const DEFAULT_REALM_NAME: &str = "supabase"; + +const CLIENT_SCOPE_SEPARATOR: char = ':'; + +const ROLE_LIST_KEY: &str = "roles"; +const ROLE_SINGLE_KEY: &str = "role"; + +const FIRST_NAME_KEYS: [&str; 3] = ["first_name", "firstName", "given_name"]; +const LAST_NAME_KEYS: [&str; 3] = ["last_name", "lastName", "family_name"]; +const FULL_NAME_KEYS: [&str; 2] = ["full_name", "name"]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct UserFilters { + pub include_deleted: bool, + pub include_anonymous: bool, + pub include_unconfirmed: bool, +} + +impl UserFilters { + fn keeps(&self, user: &SupabaseUser) -> bool { + if user.deleted_at.is_some() && !self.include_deleted { + return false; + } + if user.is_anonymous { + return self.include_anonymous; + } + self.include_unconfirmed || user.is_confirmed() + } +} + +pub struct SupabaseSource { + base_url: String, + service_role_key: String, + realm_name: Option, + filters: UserFilters, + http: Client, +} + +impl SupabaseSource { + pub fn build( + base_url: Option, + service_role_key: Option, + realm_name: Option, + filters: UserFilters, + ) -> Result { + let base_url = + normalize_base_url(&base_url.ok_or(ImportError::MissingArg("--source-url"))?); + let service_role_key = service_role_key.ok_or(ImportError::MissingArg("--source-token"))?; + + Ok(Self { + base_url, + service_role_key, + realm_name, + filters, + http: Client::new(), + }) + } + + fn page(&self, page: usize) -> Result, ImportError> { + let url = format!( + "{}/auth/v1/admin/users?page={page}&per_page={USER_PAGE_SIZE}", + self.base_url + ); + let response = self + .http + .get(url) + .header("apikey", &self.service_role_key) + .bearer_auth(&self.service_role_key) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(ImportError::Source { + provider: SOURCE, + status, + body, + }); + } + + Ok(response.json::()?.users) + } + + fn all_users(&self) -> Result, ImportError> { + let mut seen: HashSet = HashSet::new(); + let mut users = Vec::new(); + let mut page = 1; + + loop { + let batch = self.page(page)?; + let seen_before = seen.len(); + for user in batch { + if !seen.insert(user.id.clone()) { + continue; + } + if self.filters.keeps(&user) { + users.push(map_user(user)?); + } + } + + let page_brought_nothing_new = seen.len() == seen_before; + if page_brought_nothing_new { + return Ok(users); + } + page += 1; + } + } +} + +impl RealmSource for SupabaseSource { + fn fetch(&self) -> Result, ImportError> { + let name = self + .realm_name + .clone() + .unwrap_or_else(|| DEFAULT_REALM_NAME.to_owned()); + let users = self.all_users()?; + + Ok(vec![RealmBlueprint { + name, + settings: None, + roles: role_catalogue(&users), + clients: Vec::new(), + users, + }]) + } +} + +fn normalize_base_url(url: &str) -> String { + url.trim_end_matches('/') + .trim_end_matches("/auth/v1") + .trim_end_matches('/') + .to_owned() +} + +fn map_user(user: SupabaseUser) -> Result { + let (firstname, lastname) = names_from_metadata(&user.user_metadata); + let email_verified = user + .email + .as_ref() + .map(|_| user.email_confirmed_at.is_some()); + let username = user + .email + .clone() + .or_else(|| user.phone.clone()) + .unwrap_or_else(|| user.id.clone()); + let roles = roles_from_metadata(&user.app_metadata, &username)?; + + Ok(UserBlueprint { + username, + email: user.email, + firstname, + lastname, + email_verified, + roles, + }) +} + +fn role_catalogue(users: &[UserBlueprint]) -> Vec { + users + .iter() + .flat_map(|user| &user.roles) + .map(String::as_str) + .collect::>() + .into_iter() + .map(|name| RoleBlueprint { + name: name.to_owned(), + description: None, + permissions: Vec::new(), + }) + .collect() +} + +fn roles_from_metadata( + metadata: &Map, + username: &str, +) -> Result, ImportError> { + let listed = metadata + .get(ROLE_LIST_KEY) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_str); + let single = metadata.get(ROLE_SINGLE_KEY).and_then(Value::as_str); + + let mut roles: Vec = Vec::new(); + for name in listed.chain(single) { + let name = name.trim(); + if name.is_empty() || roles.iter().any(|kept| kept == name) { + continue; + } + if name.contains(CLIENT_SCOPE_SEPARATOR) { + return Err(ImportError::SupabaseNamespacedRole { + role: name.to_owned(), + username: username.to_owned(), + }); + } + roles.push(name.to_owned()); + } + + Ok(roles) +} + +fn names_from_metadata(metadata: &Map) -> (Option, Option) { + let first = first_string(metadata, &FIRST_NAME_KEYS); + let last = first_string(metadata, &LAST_NAME_KEYS); + if first.is_some() || last.is_some() { + return (first, last); + } + + match first_string(metadata, &FULL_NAME_KEYS) { + Some(full) => split_full_name(&full), + None => (None, None), + } +} + +fn first_string(metadata: &Map, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + metadata + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) +} + +fn split_full_name(full: &str) -> (Option, Option) { + match full.split_once(char::is_whitespace) { + Some((first, rest)) => { + let rest = rest.trim(); + ( + Some(first.to_owned()), + (!rest.is_empty()).then(|| rest.to_owned()), + ) + } + None => (Some(full.to_owned()), None), + } +} + +fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.filter(|value| !value.trim().is_empty())) +} + +#[derive(Debug, Deserialize)] +struct UserList { + #[serde(default)] + users: Vec, +} + +#[derive(Debug, Deserialize)] +struct SupabaseUser { + id: String, + #[serde(default, deserialize_with = "empty_string_as_none")] + email: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + phone: Option, + #[serde(default)] + email_confirmed_at: Option, + #[serde(default)] + phone_confirmed_at: Option, + #[serde(default)] + confirmed_at: Option, + #[serde(default)] + deleted_at: Option, + #[serde(default)] + is_anonymous: bool, + #[serde(default)] + user_metadata: Map, + #[serde(default)] + app_metadata: Map, +} + +impl SupabaseUser { + fn is_confirmed(&self) -> bool { + self.email_confirmed_at.is_some() + || self.phone_confirmed_at.is_some() + || self.confirmed_at.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user(id: &str) -> SupabaseUser { + SupabaseUser { + id: id.to_owned(), + email: None, + phone: None, + email_confirmed_at: None, + phone_confirmed_at: None, + confirmed_at: None, + deleted_at: None, + is_anonymous: false, + user_metadata: Map::new(), + app_metadata: Map::new(), + } + } + + fn confirmed_email_user(id: &str, email: &str) -> SupabaseUser { + SupabaseUser { + email: Some(email.to_owned()), + email_confirmed_at: Some("2024-01-01T00:00:00Z".to_owned()), + ..user(id) + } + } + + fn metadata(pairs: &[(&str, &str)]) -> Map { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) + .collect() + } + + fn json_metadata(raw: &str) -> Map { + serde_json::from_str(raw).expect("metadata fixture") + } + + fn user_with_roles(email: &str, roles: &[&str]) -> UserBlueprint { + UserBlueprint { + username: email.to_owned(), + email: Some(email.to_owned()), + firstname: None, + lastname: None, + email_verified: Some(true), + roles: roles.iter().map(|role| (*role).to_owned()).collect(), + } + } + + #[test] + fn uses_the_full_email_as_username() { + let blueprint = map_user(confirmed_email_user("id-1", "alice@acme.test")).expect("map"); + assert_eq!(blueprint.username, "alice@acme.test"); + assert_eq!(blueprint.email.as_deref(), Some("alice@acme.test")); + assert_eq!(blueprint.email_verified, Some(true)); + } + + #[test] + fn falls_back_to_phone_when_there_is_no_email() { + let blueprint = map_user(SupabaseUser { + phone: Some("+33612345678".to_owned()), + ..user("id-2") + }) + .expect("map"); + assert_eq!(blueprint.username, "+33612345678"); + assert_eq!(blueprint.email, None); + } + + #[test] + fn falls_back_to_the_supabase_id_when_there_is_neither() { + let blueprint = map_user(user("8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11")).expect("map"); + assert_eq!(blueprint.username, "8f14e45f-ceea-467a-9ba3-6a1e8a1f0c11"); + } + + #[test] + fn reports_an_unverified_email_as_unverified() { + let blueprint = map_user(SupabaseUser { + email: Some("bob@acme.test".to_owned()), + ..user("id-3") + }) + .expect("map"); + assert_eq!(blueprint.email_verified, Some(false)); + } + + #[test] + fn leaves_verification_unset_when_there_is_no_email() { + let blueprint = map_user(SupabaseUser { + phone: Some("+33612345678".to_owned()), + ..user("id-4") + }) + .expect("map"); + assert_eq!(blueprint.email_verified, None); + } + + #[test] + fn reads_an_empty_email_as_absent() { + let json = r#"{"id":"id-5","email":"","phone":" "}"#; + let parsed: SupabaseUser = serde_json::from_str(json).expect("parse"); + assert_eq!(parsed.email, None); + assert_eq!(parsed.phone, None); + } + + #[test] + fn deserializes_a_user_list_page() { + let json = r#"{"aud":"authenticated","users":[{"id":"id-6","email":"a@b.test"}]}"#; + let list: UserList = serde_json::from_str(json).expect("parse"); + assert_eq!(list.users.len(), 1); + assert_eq!(list.users[0].email.as_deref(), Some("a@b.test")); + } + + #[test] + fn drops_soft_deleted_users_by_default() { + let deleted = SupabaseUser { + deleted_at: Some("2024-02-01T00:00:00Z".to_owned()), + ..confirmed_email_user("id-7", "gone@acme.test") + }; + assert!(!UserFilters::default().keeps(&deleted)); + assert!( + UserFilters { + include_deleted: true, + ..UserFilters::default() + } + .keeps(&deleted) + ); + } + + #[test] + fn drops_anonymous_users_by_default() { + let anonymous = SupabaseUser { + is_anonymous: true, + ..user("id-8") + }; + assert!(!UserFilters::default().keeps(&anonymous)); + } + + #[test] + fn keeps_anonymous_users_when_asked_even_though_they_are_unconfirmed() { + let anonymous = SupabaseUser { + is_anonymous: true, + ..user("id-9") + }; + assert!( + UserFilters { + include_anonymous: true, + ..UserFilters::default() + } + .keeps(&anonymous), + "include_anonymous must not be undone by the confirmation filter" + ); + } + + #[test] + fn drops_unconfirmed_users_by_default() { + let unconfirmed = SupabaseUser { + email: Some("never@acme.test".to_owned()), + ..user("id-10") + }; + assert!(!UserFilters::default().keeps(&unconfirmed)); + assert!( + UserFilters { + include_unconfirmed: true, + ..UserFilters::default() + } + .keeps(&unconfirmed) + ); + } + + #[test] + fn treats_a_phone_confirmation_as_a_confirmation() { + let phone_only = SupabaseUser { + phone: Some("+33612345678".to_owned()), + phone_confirmed_at: Some("2024-01-01T00:00:00Z".to_owned()), + ..user("id-11") + }; + assert!(UserFilters::default().keeps(&phone_only)); + } + + #[test] + fn keeps_a_plain_confirmed_user_under_the_default_filters() { + assert!(UserFilters::default().keeps(&confirmed_email_user("id-12", "ok@acme.test"))); + } + + #[test] + fn reads_snake_case_names_from_metadata() { + let (first, last) = + names_from_metadata(&metadata(&[("first_name", "Alice"), ("last_name", "Doe")])); + assert_eq!(first.as_deref(), Some("Alice")); + assert_eq!(last.as_deref(), Some("Doe")); + } + + #[test] + fn reads_oidc_claim_names_from_metadata() { + let (first, last) = + names_from_metadata(&metadata(&[("given_name", "Alice"), ("family_name", "Doe")])); + assert_eq!(first.as_deref(), Some("Alice")); + assert_eq!(last.as_deref(), Some("Doe")); + } + + #[test] + fn skips_a_blank_key_and_takes_the_next_one() { + let (first, _) = + names_from_metadata(&metadata(&[("first_name", " "), ("given_name", "Alice")])); + assert_eq!(first.as_deref(), Some("Alice")); + } + + #[test] + fn splits_a_full_name_when_no_explicit_part_is_present() { + let (first, last) = names_from_metadata(&metadata(&[("full_name", "Alice Van Doe")])); + assert_eq!(first.as_deref(), Some("Alice")); + assert_eq!(last.as_deref(), Some("Van Doe")); + } + + #[test] + fn keeps_a_single_word_display_name_as_the_first_name() { + assert_eq!(split_full_name("Alice"), (Some("Alice".to_owned()), None)); + } + + #[test] + fn ignores_metadata_values_that_are_not_strings() { + let mut raw = Map::new(); + raw.insert("first_name".to_owned(), Value::Bool(true)); + assert_eq!(names_from_metadata(&raw), (None, None)); + } + + #[test] + fn maps_metadata_names_onto_the_blueprint() { + let blueprint = map_user(SupabaseUser { + user_metadata: metadata(&[("full_name", "Alice Doe")]), + ..confirmed_email_user("id-13", "alice@acme.test") + }) + .expect("map"); + assert_eq!(blueprint.firstname.as_deref(), Some("Alice")); + assert_eq!(blueprint.lastname.as_deref(), Some("Doe")); + } + + #[test] + fn reads_a_roles_array_from_app_metadata() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":["admin","billing"]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned(), "billing".to_owned()]); + } + + #[test] + fn reads_a_single_role_string_from_app_metadata() { + let roles = roles_from_metadata(&json_metadata(r#"{"role":"admin"}"#), "alice@acme.test") + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned()]); + } + + #[test] + fn merges_both_role_conventions_without_duplicating() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":["admin","billing"],"role":"admin"}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned(), "billing".to_owned()]); + } + + #[test] + fn never_reads_supabase_own_app_metadata_keys_as_roles() { + let roles = roles_from_metadata( + &json_metadata(r#"{"provider":"email","providers":["email","google"]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert!(roles.is_empty()); + } + + #[test] + fn ignores_role_entries_that_are_not_strings() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":["admin",42,null,{"a":1}]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned()]); + } + + #[test] + fn skips_blank_role_names() { + let roles = roles_from_metadata( + &json_metadata(r#"{"roles":[" ","admin",""]}"#), + "alice@acme.test", + ) + .expect("roles"); + assert_eq!(roles, vec!["admin".to_owned()]); + } + + #[test] + fn rejects_a_role_name_that_collides_with_the_client_scope_syntax() { + let rejected = roles_from_metadata( + &json_metadata(r#"{"roles":["billing:read"]}"#), + "alice@acme.test", + ); + assert!(matches!( + rejected, + Err(ImportError::SupabaseNamespacedRole { role, username }) + if role == "billing:read" && username == "alice@acme.test" + )); + } + + #[test] + fn carries_roles_onto_the_user_blueprint() { + let blueprint = map_user(SupabaseUser { + app_metadata: json_metadata(r#"{"provider":"email","roles":["admin"]}"#), + ..confirmed_email_user("id-15", "alice@acme.test") + }) + .expect("map"); + assert_eq!(blueprint.roles, vec!["admin".to_owned()]); + } + + #[test] + fn imports_no_roles_when_app_metadata_names_none() { + let blueprint = map_user(confirmed_email_user("id-14", "alice@acme.test")).expect("map"); + assert!(blueprint.roles.is_empty()); + } + + #[test] + fn builds_a_sorted_deduplicated_catalogue_from_the_users() { + let catalogue = role_catalogue(&[ + user_with_roles("alice@acme.test", &["billing", "admin"]), + user_with_roles("bob@acme.test", &["admin", "support"]), + ]); + let names: Vec<&str> = catalogue.iter().map(|role| role.name.as_str()).collect(); + assert_eq!(names, vec!["admin", "billing", "support"]); + } + + #[test] + fn catalogue_carries_no_description_or_permission() { + let catalogue = role_catalogue(&[user_with_roles("alice@acme.test", &["admin"])]); + assert_eq!(catalogue.len(), 1); + assert_eq!(catalogue[0].description, None); + assert!(catalogue[0].permissions.is_empty()); + } + + #[test] + fn catalogue_is_empty_when_no_user_names_a_role() { + let catalogue = role_catalogue(&[user_with_roles("alice@acme.test", &[])]); + assert!(catalogue.is_empty()); + } + + #[test] + fn accepts_a_project_url_with_or_without_the_auth_path() { + assert_eq!( + normalize_base_url("https://abc.supabase.co"), + "https://abc.supabase.co" + ); + assert_eq!( + normalize_base_url("https://abc.supabase.co/"), + "https://abc.supabase.co" + ); + assert_eq!( + normalize_base_url("https://abc.supabase.co/auth/v1"), + "https://abc.supabase.co" + ); + assert_eq!( + normalize_base_url("https://abc.supabase.co/auth/v1/"), + "https://abc.supabase.co" + ); + } + + #[test] + fn requires_a_url_and_a_key() { + let missing_url = + SupabaseSource::build(None, Some("key".to_owned()), None, UserFilters::default()); + assert!(matches!( + missing_url, + Err(ImportError::MissingArg("--source-url")) + )); + + let missing_key = SupabaseSource::build( + Some("https://abc.supabase.co".to_owned()), + None, + None, + UserFilters::default(), + ); + assert!(matches!( + missing_key, + Err(ImportError::MissingArg("--source-token")) + )); + } +}