1use 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
38pub const EXTERNAL_PROVIDER: &str = "supabase";
41
42pub const DEFAULT_BATCH_SIZE: usize = 500;
44
45const 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
52pub struct UsersOptions {
54 pub db_url: Secret,
56 pub batch_size: usize,
58 pub oidc_providers: BTreeSet<String>,
61}
62
63impl UsersOptions {
64 #[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#[derive(Clone, Serialize)]
79pub struct ImportRecord {
80 pub external_provider: &'static str,
82 pub external_id: String,
84 pub email: String,
85 pub email_verified: bool,
86 #[serde(
88 skip_serializing_if = "Option::is_none",
89 serialize_with = "serialize_password_hash"
90 )]
91 pub password_hash: Option<Secret>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub created_at: Option<String>,
95 pub identities: Vec<ImportIdentity>,
96}
97
98#[derive(Debug, Clone, Serialize)]
101pub struct ImportIdentity {
102 pub provider: String,
103 pub subject: String,
104}
105
106#[allow(clippy::missing_fields_in_debug)] impl 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#[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#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "kebab-case")]
162pub enum SkipReason {
163 NoEmail,
165 Anonymous,
167}
168
169impl SkipReason {
170 #[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#[derive(Debug, Clone, Serialize)]
182pub struct SkippedUser {
183 pub supabase_id: String,
184 pub reason: SkipReason,
185}
186
187#[derive(Debug, Clone, Serialize)]
190pub struct UnmappedProvider {
191 pub provider: String,
192 pub supabase_ids: Vec<String>,
193}
194
195#[derive(Default)]
199pub struct ImportPlan {
200 pub records: Vec<ImportRecord>,
201 pub metadata: Vec<UserMetadata>,
206 pub skipped: Vec<SkippedUser>,
207 pub unmapped: Vec<UnmappedProvider>,
208}
209
210impl ImportPlan {
211 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
223impl 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
236pub 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 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 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
283async 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
313type 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 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
353async 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 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
440fn 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
451fn is_bcrypt(hash: &str) -> bool {
454 hash.starts_with("$2a$") || hash.starts_with("$2b$") || hash.starts_with("$2y$")
455}
456
457fn parse_json(text: Option<String>) -> Option<Value> {
460 text.and_then(|text| serde_json::from_str(&text).ok())
461}
462
463#[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
475pub struct MappingCounts {
476 pub inserted: usize,
478 pub unchanged: usize,
480}
481
482#[derive(Debug, thiserror::Error)]
484pub enum MappingError {
485 #[error("could not write the mapping table: {0}")]
487 Write(String),
488 #[error("the mapping table does not agree with this import: {0}")]
494 Drift(String),
495}
496
497pub 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
517pub 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 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}