Skip to main content

cratefield_import_supabase/
users.rs

1//! Step two of moving a Supabase project onto the harness (issue #659):
2//! read `auth.users` and `auth.identities` through the same read-only
3//! snapshot [`crate::ReadOnlySession`] gives `inspect`, and shape them into
4//! the admin import contract — `POST /v1/auth-core/admin/users/import`
5//! (`cratefield-auth-core`).
6//!
7//! A user becomes an [`ImportRecord`]: `external_provider` `supabase`,
8//! `external_id` the `auth.users.id`, the address and whether the source
9//! confirmed it, the bcrypt hash when the source has one, and the OIDC
10//! links the harness can hold. Password hashes are bcrypt `$2a$`/`$2b$`/
11//! `$2y$` only and are carried verbatim; the login path upgrades them to
12//! argon2id on the first sign-in. A user created through Supabase's admin
13//! API has a random bcrypt hash we cannot tell apart from a real one, so it
14//! passes through too — harmless, and it upgrades the same way.
15//!
16//! What the harness cannot hold is reported, not guessed: a phone-only or
17//! anonymous account is [`SkippedUser`], and an OAuth identity the harness
18//! has no slug for (GitHub, today) or the caller did not list is an
19//! [`UnmappedProvider`] — those people sign in by magic link.
20//!
21//! The [`UserMetadata`] read here does not go to auth (harness accounts
22//! have no metadata column yet); it goes to the source-to-account mapping
23//! table [`record_mapping`] writes, which step three rewrites the copied
24//! data through.
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::str::FromStr as _;
28
29use serde::Serialize;
30use serde_json::Value;
31use sqlx::Connection as _;
32use sqlx::postgres::{PgConnectOptions, PgConnection};
33
34use crate::collect::{column_exists, relation_exists};
35use crate::secret::Secret;
36use crate::session::{InspectError, ReadOnlySession};
37
38/// The `external_provider` every record carries: the source system, so a
39/// re-run and another source's ids never collide.
40pub const EXTERNAL_PROVIDER: &str = "supabase";
41
42/// Users read per keyset page when none is set on [`UsersOptions`].
43pub const DEFAULT_BATCH_SIZE: usize = 500;
44
45/// The mapping table, in the target database. `account_id` is deliberately
46/// not unique: `--merge-by-email` maps several source users to one account,
47/// so the source id is the only key.
48const CREATE_MAPPING_TABLE: &str = "CREATE TABLE IF NOT EXISTS import_supabase_users (\
49     supabase_id uuid PRIMARY KEY, account_id text NOT NULL, user_metadata jsonb, \
50     app_metadata jsonb, imported_at timestamptz NOT NULL DEFAULT now())";
51
52/// What [`read_users`] needs.
53pub struct UsersOptions {
54    /// The source database URL, ideally for the read-only role.
55    pub db_url: Secret,
56    /// Rows per keyset page; the pages are one snapshot regardless.
57    pub batch_size: usize,
58    /// The Supabase OAuth providers the caller can carry over: an identity
59    /// of any other provider is reported unmapped.
60    pub oidc_providers: BTreeSet<String>,
61}
62
63impl UsersOptions {
64    /// Options that read [`DEFAULT_BATCH_SIZE`] a page and map no provider.
65    #[must_use]
66    pub fn new(db_url: Secret) -> Self {
67        Self {
68            db_url,
69            batch_size: DEFAULT_BATCH_SIZE,
70            oidc_providers: BTreeSet::new(),
71        }
72    }
73}
74
75/// One user in the admin import contract. `Debug` prints ids, flags and
76/// counts only — never the address, a subject or the hash; the JSON carries
77/// the address and the hash for the import to store.
78#[derive(Clone, Serialize)]
79pub struct ImportRecord {
80    /// Always [`EXTERNAL_PROVIDER`].
81    pub external_provider: &'static str,
82    /// The `auth.users.id`, as text.
83    pub external_id: String,
84    pub email: String,
85    pub email_verified: bool,
86    /// Verbatim, when the source has one.
87    #[serde(
88        skip_serializing_if = "Option::is_none",
89        serialize_with = "serialize_password_hash"
90    )]
91    pub password_hash: Option<Secret>,
92    /// RFC 3339, when the row has one.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub created_at: Option<String>,
95    pub identities: Vec<ImportIdentity>,
96}
97
98/// One OIDC link for [`ImportRecord::identities`]: the harness slug and the
99/// source's `sub`.
100#[derive(Debug, Clone, Serialize)]
101pub struct ImportIdentity {
102    pub provider: String,
103    pub subject: String,
104}
105
106// Hand-written so a stray `{:?}` on a record cannot disclose an address or a
107// subject: the address is not printed, the subjects are a count, and the
108// hash is a `Secret` (`Some([redacted])`).
109#[allow(clippy::missing_fields_in_debug)] // `email` is deliberately omitted
110impl std::fmt::Debug for ImportRecord {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("ImportRecord")
113            .field("external_provider", &self.external_provider)
114            .field("external_id", &self.external_id)
115            .field("email_verified", &self.email_verified)
116            .field("password_hash", &self.password_hash)
117            .field("created_at", &self.created_at)
118            .field("identities", &self.identities.len())
119            .finish()
120    }
121}
122
123// `serialize_with` fixes the receiver to `&Option<Secret>`.
124#[allow(clippy::ref_option)]
125fn serialize_password_hash<S: serde::Serializer>(
126    value: &Option<Secret>,
127    serializer: S,
128) -> Result<S::Ok, S::Error> {
129    match value {
130        Some(secret) => serializer.serialize_str(secret.expose()),
131        None => serializer.serialize_none(),
132    }
133}
134
135/// A user's source-side metadata (`raw_user_meta_data` /
136/// `raw_app_meta_data`): carried to the mapping table, never to the harness,
137/// whose accounts have no metadata column yet. No `Debug`: the values are
138/// the source's own, so a stray `{:?}` must not print them.
139#[derive(Clone)]
140pub struct UserMetadata {
141    pub supabase_id: String,
142    pub user_metadata: Option<Value>,
143    pub app_metadata: Option<Value>,
144}
145
146impl UserMetadata {
147    /// The mapping row for the account the import created for this user.
148    #[must_use]
149    pub fn mapping(&self, account_id: impl Into<String>) -> MappedUser {
150        MappedUser {
151            supabase_id: self.supabase_id.clone(),
152            account_id: account_id.into(),
153            user_metadata: self.user_metadata.clone(),
154            app_metadata: self.app_metadata.clone(),
155        }
156    }
157}
158
159/// Why a user is not imported.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "kebab-case")]
162pub enum SkipReason {
163    /// A phone-only (or empty) account.
164    NoEmail,
165    /// An anonymous (guest) account.
166    Anonymous,
167}
168
169impl SkipReason {
170    /// The stable slug a report names it by.
171    #[must_use]
172    pub fn as_str(self) -> &'static str {
173        match self {
174            Self::NoEmail => "no-email",
175            Self::Anonymous => "anonymous",
176        }
177    }
178}
179
180/// A user not imported, and why.
181#[derive(Debug, Clone, Serialize)]
182pub struct SkippedUser {
183    pub supabase_id: String,
184    pub reason: SkipReason,
185}
186
187/// One Supabase OAuth provider none of whose identities were linked, with
188/// the users left to sign in by magic link.
189#[derive(Debug, Clone, Serialize)]
190pub struct UnmappedProvider {
191    pub provider: String,
192    pub supabase_ids: Vec<String>,
193}
194
195/// Everything [`read_users`] read, in `auth.users.id` order; every count is
196/// exact (the whole table, not a page of it). `Debug` prints counts only —
197/// never a record, an address or the metadata.
198#[derive(Default)]
199pub struct ImportPlan {
200    pub records: Vec<ImportRecord>,
201    /// Their metadata, one entry per record, in id order — built by
202    /// `plan_user`, which pushes the pair together, so index `n` of
203    /// `metadata` is index `n` of `records`. [`ImportPlan::users`] is the
204    /// pairing.
205    pub metadata: Vec<UserMetadata>,
206    pub skipped: Vec<SkippedUser>,
207    pub unmapped: Vec<UnmappedProvider>,
208}
209
210impl ImportPlan {
211    /// Each record with the metadata read alongside it, one pair per user in
212    /// id order.
213    pub fn users(&self) -> impl Iterator<Item = (&ImportRecord, &UserMetadata)> {
214        debug_assert_eq!(
215            self.records.len(),
216            self.metadata.len(),
217            "every record has its metadata"
218        );
219        self.records.iter().zip(&self.metadata)
220    }
221}
222
223// Counts, not the values: a plan holds every address and every metadata blob
224// the source has, and a `{:?}` on the error path must not print them.
225impl std::fmt::Debug for ImportPlan {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("ImportPlan")
228            .field("records", &self.records.len())
229            .field("metadata", &self.metadata.len())
230            .field("skipped", &self.skipped.len())
231            .field("unmapped", &self.unmapped.len())
232            .finish()
233    }
234}
235
236/// Reads the source's auth users through one read-only snapshot and plans
237/// their import.
238///
239/// # Errors
240///
241/// [`InspectError::Permission`] when `auth.users` or `auth.identities` has
242/// row-level security this role cannot bypass — a read would silently
243/// return no rows, so it is refused instead; [`InspectError::Query`] when
244/// the `auth` schema is missing; [`InspectError::Connect`] when the
245/// database cannot be reached. No message carries a row value.
246pub async fn read_users(options: &UsersOptions) -> Result<ImportPlan, InspectError> {
247    let mut session = ReadOnlySession::open(&options.db_url).await?;
248    guard_auth(&mut session).await?;
249    let anonymous = column_exists(&mut session, "auth", "users", "is_anonymous").await?;
250    // `provider_id` (a uuid) replaced `identity_data->>'sub'`; either works.
251    let subject = if column_exists(&mut session, "auth", "identities", "provider_id").await? {
252        "coalesce(provider_id, '')"
253    } else {
254        "coalesce(identity_data->>'sub', '')"
255    };
256
257    let mut plan = ImportPlan::default();
258    let mut unmapped: BTreeMap<String, Vec<String>> = BTreeMap::new();
259    // Keyset pagination over the whole table, so every page sees the one
260    // snapshot and no row is read twice.
261    let mut after = "00000000-0000-0000-0000-000000000000".to_owned();
262    loop {
263        let rows = fetch_users(&mut session, &after, options.batch_size.max(1), anonymous).await?;
264        let Some(last) = rows.last() else { break };
265        after.clone_from(&last.0);
266        let mut identities = fetch_identities(&mut session, &rows, subject).await?;
267        for row in rows {
268            let links = identities.remove(&row.0).unwrap_or_default();
269            plan_user(&mut plan, &mut unmapped, row, links, options);
270        }
271    }
272    plan.unmapped = unmapped
273        .into_iter()
274        .map(|(provider, supabase_ids)| UnmappedProvider {
275            provider,
276            supabase_ids,
277        })
278        .collect();
279    session.finish().await?;
280    Ok(plan)
281}
282
283/// Refuses before reading a row when RLS on an auth table would hide rows
284/// from this role without saying so — the silent-zero trap `inspect`
285/// reports for `storage.objects` (`collect::read_storage`).
286async fn guard_auth(session: &mut ReadOnlySession) -> Result<(), InspectError> {
287    for table in ["auth.users", "auth.identities"] {
288        if !relation_exists(session, table).await? {
289            return Err(InspectError::Query(format!(
290                "the source has no {table} table, so there is no Supabase auth to move"
291            )));
292        }
293        let hidden: bool = sqlx::query_scalar(
294            "SELECT c.relrowsecurity AND NOT (r.rolsuper OR r.rolbypassrls) AND \
295             (c.relforcerowsecurity OR NOT pg_has_role(c.relowner, 'USAGE')) FROM pg_class c JOIN \
296             pg_roles r ON r.rolname = current_user WHERE c.oid = to_regclass($1)",
297        )
298        .bind(table)
299        .fetch_one(&mut session.conn)
300        .await
301        .map_err(|error| session.error(&error))?;
302        if hidden {
303            return Err(InspectError::Permission(format!(
304                "{table} has row-level security and this role cannot bypass it, so the read \
305                 would silently return no rows; use a role with BYPASSRLS for the import (an \
306                 owner is exempt only when the table is not FORCE ROW LEVEL SECURITY)"
307            )));
308        }
309    }
310    Ok(())
311}
312
313/// One page's `auth.users` columns, in [`fetch_users`]' select order: id,
314/// email, confirmed, hash, `created_at` (RFC 3339), the two jsonb texts, guest.
315type UserRow = (
316    String,
317    Option<String>,
318    bool,
319    Option<String>,
320    Option<String>,
321    Option<String>,
322    Option<String>,
323    bool,
324);
325
326async fn fetch_users(
327    session: &mut ReadOnlySession,
328    after: &str,
329    limit: usize,
330    anonymous: bool,
331) -> Result<Vec<UserRow>, InspectError> {
332    let is_anonymous = if anonymous {
333        "coalesce(is_anonymous, false)"
334    } else {
335        "false"
336    };
337    // `to_char` rather than the driver's own decoding: the text is RFC 3339
338    // whatever the session's time zone.
339    let rows: Vec<UserRow> = sqlx::query_as(&format!(
340        "SELECT id::text, email, email_confirmed_at IS NOT NULL, encrypted_password, \
341         to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS') || 'Z', \
342         raw_user_meta_data::text, raw_app_meta_data::text, {is_anonymous} FROM auth.users \
343         WHERE id > $1::uuid ORDER BY id LIMIT $2"
344    ))
345    .bind(after)
346    .bind(i64::try_from(limit).unwrap_or(i64::MAX))
347    .fetch_all(&mut session.conn)
348    .await
349    .map_err(|error| session.error(&error))?;
350    Ok(rows)
351}
352
353/// The identities of the users in one page, keyed by user id.
354async fn fetch_identities(
355    session: &mut ReadOnlySession,
356    rows: &[UserRow],
357    subject: &str,
358) -> Result<BTreeMap<String, Vec<(String, String)>>, InspectError> {
359    let ids = rows
360        .iter()
361        .map(|row| row.0.as_str())
362        .collect::<Vec<_>>()
363        .join(",");
364    let identities: Vec<(String, String, String)> = sqlx::query_as(&format!(
365        "SELECT user_id::text, provider::text, {subject} FROM auth.identities WHERE user_id = \
366         ANY(string_to_array($1, ',')::uuid[]) ORDER BY user_id, provider"
367    ))
368    .bind(ids)
369    .fetch_all(&mut session.conn)
370    .await
371    .map_err(|error| session.error(&error))?;
372    let mut by_user: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
373    for (user_id, provider, subject) in identities {
374        by_user
375            .entry(user_id)
376            .or_default()
377            .push((provider, subject));
378    }
379    Ok(by_user)
380}
381
382fn plan_user(
383    plan: &mut ImportPlan,
384    unmapped: &mut BTreeMap<String, Vec<String>>,
385    row: UserRow,
386    identities: Vec<(String, String)>,
387    options: &UsersOptions,
388) {
389    let (id, email, email_confirmed, password, created_at, user_metadata, app_metadata, guest) =
390        row;
391    let email = match email {
392        Some(email) if !email.is_empty() && !guest => email,
393        _ => {
394            let reason = if guest {
395                SkipReason::Anonymous
396            } else {
397                SkipReason::NoEmail
398            };
399            plan.skipped.push(SkippedUser {
400                supabase_id: id,
401                reason,
402            });
403            return;
404        }
405    };
406
407    let mut links = Vec::new();
408    for (provider, subject) in identities {
409        // `email`/`phone` are not OAuth: nothing is lost, so not unmapped.
410        if matches!(provider.as_str(), "email" | "phone") {
411            continue;
412        }
413        match oidc_slug(&provider) {
414            Some(slug) if !subject.is_empty() && options.oidc_providers.contains(&provider) => {
415                links.push(ImportIdentity {
416                    provider: slug.to_owned(),
417                    subject,
418                });
419            }
420            _ => unmapped.entry(provider).or_default().push(id.clone()),
421        }
422    }
423
424    plan.metadata.push(UserMetadata {
425        supabase_id: id.clone(),
426        user_metadata: parse_json(user_metadata),
427        app_metadata: parse_json(app_metadata),
428    });
429    plan.records.push(ImportRecord {
430        external_provider: EXTERNAL_PROVIDER,
431        external_id: id,
432        email,
433        email_verified: email_confirmed,
434        password_hash: password.filter(|hash| is_bcrypt(hash)).map(Secret::new),
435        created_at,
436        identities: links,
437    });
438}
439
440/// The harness slug for a Supabase OAuth provider, or `None` when it has
441/// none (GitHub, today). Supabase spells Meta's provider `facebook`.
442fn oidc_slug(provider: &str) -> Option<&'static str> {
443    match provider {
444        "google" => Some("google"),
445        "apple" => Some("apple"),
446        "facebook" => Some("meta"),
447        _ => None,
448    }
449}
450
451/// Only these prefixes are bcrypt; anything else the harness refuses, so it
452/// is not worth sending.
453fn is_bcrypt(hash: &str) -> bool {
454    hash.starts_with("$2a$") || hash.starts_with("$2b$") || hash.starts_with("$2y$")
455}
456
457/// jsonb is read as text: the workspace pins sqlx without its json support,
458/// and `serde_json` parses what the database guarantees valid.
459fn parse_json(text: Option<String>) -> Option<Value> {
460    text.and_then(|text| serde_json::from_str(&text).ok())
461}
462
463/// One row for the mapping table: a Supabase user, the account the import
464/// created for it, and its source metadata.
465#[derive(Debug, Clone)]
466pub struct MappedUser {
467    pub supabase_id: String,
468    pub account_id: String,
469    pub user_metadata: Option<Value>,
470    pub app_metadata: Option<Value>,
471}
472
473/// What [`record_mapping`] did.
474#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
475pub struct MappingCounts {
476    /// Rows inserted: a Supabase user mapped for the first time.
477    pub inserted: usize,
478    /// Rows already mapped to the same account (metadata refreshed).
479    pub unchanged: usize,
480}
481
482/// Why a mapping write failed.
483#[derive(Debug, thiserror::Error)]
484pub enum MappingError {
485    /// The target database refused the read or the write.
486    #[error("could not write the mapping table: {0}")]
487    Write(String),
488    /// The table disagrees with what is being recorded: the Supabase user
489    /// already maps to another account, or the account is already used by
490    /// another Supabase user. Step three rewrites every reference through
491    /// this map, so a disagreement would move somebody's data to the wrong
492    /// person — it stops here instead.
493    #[error("the mapping table does not agree with this import: {0}")]
494    Drift(String),
495}
496
497/// Opens a writable connection to the *target* database — the harness
498/// Postgres, not the Supabase source — for [`record_mapping`].
499///
500/// # Errors
501///
502/// [`MappingError::Write`] when the URL does not parse or the database
503/// cannot be reached. Neither message quotes the URL.
504pub async fn connect_mapping(url: &Secret) -> Result<PgConnection, MappingError> {
505    let options = PgConnectOptions::from_str(url.expose())
506        .map_err(|_| {
507            MappingError::Write(
508                "the target database URL is not a valid Postgres connection string".to_owned(),
509            )
510        })?
511        .application_name("cratefield-import-supabase-users");
512    PgConnection::connect_with(&options)
513        .await
514        .map_err(|error| MappingError::Write(crate::session::redact(&error.to_string(), url)))
515}
516
517/// Creates `import_supabase_users` if needed and upserts `rows` in one
518/// transaction: a new Supabase id is inserted; one already mapped to the
519/// same account has its metadata refreshed (counted unchanged); a new id may
520/// share an account with an existing row (`--merge-by-email` maps several
521/// source users to one account). The only drift is an existing id that maps
522/// to a *different* account — [`MappingError::Drift`].
523///
524/// # Errors
525///
526/// [`MappingError::Write`] when the database refuses a statement;
527/// [`MappingError::Drift`] on a disagreement. Write messages are scrubbed of
528/// everything `url` holds, as [`connect_mapping`] does; both carry ids only.
529pub async fn record_mapping(
530    conn: &mut PgConnection,
531    url: &Secret,
532    rows: &[MappedUser],
533) -> Result<MappingCounts, MappingError> {
534    let write =
535        |error: sqlx::Error| MappingError::Write(crate::session::redact(&error.to_string(), url));
536    let json = |value: Option<&Value>| value.map(Value::to_string);
537    let mut tx = conn.begin().await.map_err(write)?;
538    sqlx::raw_sql(CREATE_MAPPING_TABLE)
539        .execute(&mut *tx)
540        .await
541        .map_err(write)?;
542    let mut counts = MappingCounts::default();
543    for row in rows {
544        // Only the id is a key: an existing row whose account differs is the
545        // one disagreement that would move somebody's data to the wrong
546        // person, so it stops before the write.
547        let existing: Option<String> = sqlx::query_scalar(
548            "SELECT account_id FROM import_supabase_users WHERE supabase_id = $1::uuid",
549        )
550        .bind(&row.supabase_id)
551        .fetch_optional(&mut *tx)
552        .await
553        .map_err(write)?;
554        if let Some(account) = &existing
555            && account != &row.account_id
556        {
557            return Err(MappingError::Drift(format!(
558                "supabase user {} already maps to account {account}, not {}",
559                row.supabase_id, row.account_id
560            )));
561        }
562        sqlx::query(
563            "INSERT INTO import_supabase_users (supabase_id, account_id, user_metadata, \
564             app_metadata) VALUES ($1::uuid, $2, $3::jsonb, $4::jsonb) ON CONFLICT (supabase_id) \
565             DO UPDATE SET user_metadata = EXCLUDED.user_metadata, app_metadata = \
566             EXCLUDED.app_metadata",
567        )
568        .bind(&row.supabase_id)
569        .bind(&row.account_id)
570        .bind(json(row.user_metadata.as_ref()))
571        .bind(json(row.app_metadata.as_ref()))
572        .execute(&mut *tx)
573        .await
574        .map_err(write)?;
575        if existing.is_some() {
576            counts.unchanged += 1;
577        } else {
578            counts.inserted += 1;
579        }
580    }
581    tx.commit().await.map_err(write)?;
582    Ok(counts)
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn only_bcrypt_is_carried() {
591        assert!(is_bcrypt("$2a$10$abc"));
592        assert!(is_bcrypt("$2b$10$abc"));
593        assert!(is_bcrypt("$2y$10$abc"));
594        assert!(!is_bcrypt("$argon2id$v=19$m=1,t=1,p=1$abc"));
595        assert!(!is_bcrypt(""));
596    }
597}