Jump to…
snowattribute changes to their author, and index SSH pushesowzkxxuxzulu1mo
Matt W1//! User provisioning and site-admin bootstrap.
Matt W2//!
Matt W3//! Access is invite/allowlist only (decided): completing an OIDC login is not
Matt W4//! sufficient to obtain a Dogfood account. An identity must additionally be on
Matt W5//! the configured allowlist or hold an open invitation.
Matt W6//!
Matt W7//! Site admin is claimed once through a one-time setup token that the web
Matt W8//! process mints and logs on first boot when no admin exists.
Matt W9
Matt W10use anyhow::{Context, Result};
Matt W11use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
Matt W12use argon2::Argon2;
Matt W13use df_db::ids::new_id;
Matt W14use df_db::models::User;
Matt W15use rand::RngCore;
Matt W16use sqlx::PgPool;
Matt W17use uuid::Uuid;
Matt W18
Matt W19use crate::oidc::Identity;
Matt W20
Matt W21/// Why a login did not result in an account.
Matt W22#[derive(Debug, Clone, PartialEq, Eq)]
Matt W23pub enum Denied {
Matt W24 /// Authenticated successfully, but not allowlisted or invited.
Matt W25 NotInvited,
Matt W26 /// The provider returned no email, so allowlist matching is impossible.
Matt W27 NoEmail,
Matt W28 /// A handle could not be derived and must be chosen by the user.
Matt W29 NeedsHandle { suggested: Option<String> },
Matt W30}
Matt W31
Matt W32/// Decide whether an identity may hold an account.
Matt W33///
Matt W34/// Pure so the policy is testable without a database. `invited` is the result
Matt W35/// of the invitation lookup; `allowlist` comes from configuration.
Matt W36pub fn admission(identity: &Identity, allowlist: &[String], invited: bool) -> Result<(), Denied> {
Matt W37 // Entries may be either an email address or a raw OIDC subject. Subjects are
Matt W38 // matched first and are the more dependable of the two: `sub` is guaranteed
Matt W39 // present on every login, whereas `email` depends on the provider's consent
Matt W40 // app re-supplying claims on each flow. Ory's Account Experience does not do
Matt W41 // that once Hydra starts skipping consent, so an email-only allowlist stops
Matt W42 // matching an hour after a user's first sign-in.
Matt W43 let subject = identity.subject.trim();
Matt W44 if !subject.is_empty()
Matt W45 && allowlist
Matt W46 .iter()
Matt W47 .map(|a| a.trim())
Matt W48 .filter(|a| !a.is_empty())
Matt W49 // Subjects are opaque and case-sensitive; do not fold case here.
Matt W50 .any(|a| a == subject)
Matt W51 {
Matt W52 return Ok(());
Matt W53 }
Matt W54
Matt W55 let email = identity.email.as_deref().map(str::trim).unwrap_or("");
Matt W56 if email.is_empty() {
Matt W57 // Without an email we cannot match the allowlist, and silently creating
Matt W58 // an account would defeat the policy. An empty-string email is treated
Matt W59 // as absent so it can never match a malformed allowlist entry.
Matt W60 return Err(Denied::NoEmail);
Matt W61 }
Matt W62
Matt W63 let allowed = invited
Matt W64 || allowlist
Matt W65 .iter()
Matt W66 .map(|a| a.trim())
Matt W67 // A blank entry — from `DOGFOOD_ALLOWLIST=""` or a stray comma —
Matt W68 // must never match anything.
Matt W69 .filter(|a| !a.is_empty())
Matt W70 .any(|a| a.eq_ignore_ascii_case(email));
Matt W71
Matt W72 if !allowed {
Matt W73 return Err(Denied::NotInvited);
Matt W74 }
Matt W75 Ok(())
Matt W76}
Matt W77
Matt W78/// Find an existing user by OIDC subject.
Matt W79///
Matt W80/// Keyed on `sub`, never email (spec §6).
Matt W81pub async fn find_by_subject(db: &PgPool, subject: &str) -> Result<Option<User>> {
Matt W82 let u = sqlx::query_as::<_, User>(
Matt W83 "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at
Matt W84 FROM users WHERE subject = $1",
Matt W85 )
Matt W86 .bind(subject)
Matt W87 .fetch_optional(db)
Matt W88 .await?;
Matt W89 Ok(u)
Matt W90}
Matt W91
Matt W92/// Fill in profile fields the account is missing, from a fresh login's claims.
Matt W93///
Matt W94/// Only ever writes over a `NULL`. An account whose email is already recorded
Matt W95/// keeps it, so this cannot silently move an identity from under commits that
Matt W96/// are attributed to it.
Matt W97///
Matt W98/// This exists because the claims are **not reliably present on every login**:
Matt W99/// Hydra's skip-consent path (taken once consent is remembered) can return an
Matt W100/// ID token carrying only `sub`, so an account created during such a login is
Matt W101/// created with no email at all — and email is the only thing that links a
Matt W102/// pushed commit back to an account. Backfilling on any later login that does
Matt W103/// carry the claim is what repairs that without the user doing anything.
Matt W104pub async fn backfill_profile(db: &PgPool, user_id: Uuid, identity: &Identity) -> Result<()> {
Matt W105 let email = identity.email.as_deref().map(str::trim).filter(|e| !e.is_empty());
Matt W106 let name = identity.name.as_deref().map(str::trim).filter(|n| !n.is_empty());
Matt W107
Matt W108 if email.is_none() && name.is_none() {
Matt W109 return Ok(());
Matt W110 }
Matt W111
Matt W112 let updated = sqlx::query(
Matt W113 "UPDATE users
Matt W114 SET email = COALESCE(email, $2),
Matt W115 display_name = COALESCE(display_name, $3)
Matt W116 WHERE id = $1
Matt W117 AND (($2 IS NOT NULL AND email IS NULL)
Matt W118 OR ($3 IS NOT NULL AND display_name IS NULL))",
Matt W119 )
Matt W120 .bind(user_id)
Matt W121 .bind(email)
Matt W122 .bind(name)
Matt W123 .execute(db)
Matt W124 .await
Matt W125 .context("backfilling user profile")?;
Matt W126
Matt W127 if updated.rows_affected() > 0 {
Matt W128 tracing::info!(user = %user_id, "filled in profile fields from a fresh login");
Matt W129 }
Matt W130 Ok(())
Matt W131}
Matt W132
Matt W133/// Whether an open invitation exists for this email.
Matt W134pub async fn has_invitation(db: &PgPool, email: &str) -> Result<bool> {
Matt W135 let found: Option<(Uuid,)> =
Matt W136 sqlx::query_as("SELECT id FROM invitations WHERE email = $1 AND accepted_at IS NULL")
Matt W137 .bind(email)
Matt W138 .fetch_optional(db)
Matt W139 .await?;
Matt W140 Ok(found.is_some())
Matt W141}
Matt W142
Matt W143/// Create a user, marking any matching invitation accepted.
Matt W144pub async fn create_user(
Matt W145 db: &PgPool,
Matt W146 identity: &Identity,
Matt W147 handle: &str,
Matt W148) -> Result<User> {
Matt W149 let mut tx = db.begin().await?;
Matt W150 let id = new_id();
Matt W151
Matt W152 let user = sqlx::query_as::<_, User>(
Matt W153 "INSERT INTO users (id, subject, handle, display_name, email)
Matt W154 VALUES ($1, $2, $3, $4, $5)
Matt W155 RETURNING id, subject, handle, display_name, email, avatar_url, is_admin, created_at",
Matt W156 )
Matt W157 .bind(id)
Matt W158 .bind(&identity.subject)
Matt W159 .bind(handle)
Matt W160 .bind(identity.name.as_deref())
Matt W161 .bind(identity.email.as_deref())
Matt W162 .fetch_one(&mut *tx)
Matt W163 .await
Matt W164 .context("creating user")?;
Matt W165
Matt W166 if let Some(email) = identity.email.as_deref() {
Matt W167 sqlx::query(
Matt W168 "UPDATE invitations SET accepted_at = now(), accepted_by = $2
Matt W169 WHERE email = $1 AND accepted_at IS NULL",
Matt W170 )
Matt W171 .bind(email)
Matt W172 .bind(user.id)
Matt W173 .execute(&mut *tx)
Matt W174 .await?;
Matt W175 }
Matt W176
Matt W177 sqlx::query(
Matt W178 "INSERT INTO audit_log (id, actor_id, action, target, metadata)
Matt W179 VALUES ($1, $2, 'user.provisioned', $3, $4)",
Matt W180 )
Matt W181 .bind(new_id())
Matt W182 .bind(user.id)
Matt W183 .bind(format!("user:{}", user.id))
Matt W184 .bind(serde_json::json!({ "handle": handle, "subject": identity.subject }))
Matt W185 .execute(&mut *tx)
Matt W186 .await?;
Matt W187
Matt W188 tx.commit().await?;
Matt W189 Ok(user)
Matt W190}
Matt W191
Matt W192/// Whether a handle is free and not reserved.
Matt W193pub async fn handle_available(db: &PgPool, handle: &str) -> Result<bool> {
Matt W194 let taken: Option<(i32,)> = sqlx::query_as(
Matt W195 "SELECT 1 FROM (
Matt W196 SELECT handle FROM users
Matt W197 UNION ALL SELECT handle FROM orgs
Matt W198 UNION ALL SELECT handle FROM reserved_handles
Matt W199 ) t WHERE handle = $1",
Matt W200 )
Matt W201 .bind(handle)
Matt W202 .fetch_optional(db)
Matt W203 .await?;
Matt W204 Ok(taken.is_none())
Matt W205}
Matt W206
Matt W207/// Pick a free handle derived from `base`, appending a numeric suffix if needed.
Matt W208pub async fn unique_handle(db: &PgPool, base: &str) -> Result<Option<String>> {
Matt W209 if handle_available(db, base).await? {
Matt W210 return Ok(Some(base.to_string()));
Matt W211 }
Matt W212 for n in 2..=99 {
Matt W213 // Keep within the 39-character limit the CHECK enforces.
Matt W214 let suffix = n.to_string();
Matt W215 let trimmed: String = base.chars().take(39 - suffix.len() - 1).collect();
Matt W216 let candidate = format!("{}-{}", trimmed.trim_end_matches('-'), suffix);
Matt W217 if handle_available(db, &candidate).await? {
Matt W218 return Ok(Some(candidate));
Matt W219 }
Matt W220 }
Matt W221 Ok(None)
Matt W222}
Matt W223
Matt W224// ─── site admin bootstrap ────────────────────────────────────────────────────
Matt W225
Matt W226/// Whether any site admin exists.
Matt W227pub async fn any_admin_exists(db: &PgPool) -> Result<bool> {
Matt W228 let found: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE is_admin LIMIT 1")
Matt W229 .fetch_optional(db)
Matt W230 .await?;
Matt W231 Ok(found.is_some())
Matt W232}
Matt W233
Matt W234/// Mint a setup token if no admin exists and no unconsumed token is outstanding.
Matt W235///
Matt W236/// Returns the plaintext exactly once, for logging. Only the hash is stored.
Matt W237pub async fn ensure_setup_token(db: &PgPool) -> Result<Option<String>> {
Matt W238 if any_admin_exists(db).await? {
Matt W239 return Ok(None);
Matt W240 }
Matt W241
Matt W242 let outstanding: Option<(Uuid,)> =
Matt W243 sqlx::query_as("SELECT id FROM setup_tokens WHERE consumed_at IS NULL LIMIT 1")
Matt W244 .fetch_optional(db)
Matt W245 .await?;
Matt W246 if outstanding.is_some() {
Matt W247 // A token is already outstanding. We cannot show it again — we do not
Matt W248 // have the plaintext — so say so rather than minting a second one.
Matt W249 return Ok(None);
Matt W250 }
Matt W251
Matt W252 let mut bytes = [0u8; 24];
Matt W253 rand::thread_rng().fill_bytes(&mut bytes);
Matt W254 let plaintext = hex::encode(bytes);
Matt W255
Matt W256 let salt = SaltString::generate(&mut rand::thread_rng());
Matt W257 let hash = Argon2::default()
Matt W258 .hash_password(plaintext.as_bytes(), &salt)
Matt W259 .map_err(|e| anyhow::anyhow!("hashing setup token: {e}"))?
Matt W260 .to_string();
Matt W261
Matt W262 sqlx::query("INSERT INTO setup_tokens (id, token_hash) VALUES ($1, $2)")
Matt W263 .bind(new_id())
Matt W264 .bind(hash)
Matt W265 .execute(db)
Matt W266 .await?;
Matt W267
Matt W268 Ok(Some(plaintext))
Matt W269}
Matt W270
Matt W271/// Consume a setup token, promoting `user_id` to site admin.
Matt W272///
Matt W273/// Returns false for an unknown, already-consumed, or mismatched token.
Matt W274pub async fn claim_admin(db: &PgPool, user_id: Uuid, presented: &str) -> Result<bool> {
Matt W275 let candidates: Vec<(Uuid, String)> =
Matt W276 sqlx::query_as("SELECT id, token_hash FROM setup_tokens WHERE consumed_at IS NULL")
Matt W277 .fetch_all(db)
Matt W278 .await?;
Matt W279
Matt W280 for (token_id, stored) in candidates {
Matt W281 let Ok(parsed) = PasswordHash::new(&stored) else {
Matt W282 tracing::error!(%token_id, "setup token hash unparseable");
Matt W283 continue;
Matt W284 };
Matt W285 if Argon2::default()
Matt W286 .verify_password(presented.as_bytes(), &parsed)
Matt W287 .is_err()
Matt W288 {
Matt W289 continue;
Matt W290 }
Matt W291
Matt W292 let mut tx = db.begin().await?;
Matt W293 // Guard against two requests racing on the same token: the UPDATE only
Matt W294 // matches while it is still unconsumed.
Matt W295 let claimed = sqlx::query(
Matt W296 "UPDATE setup_tokens SET consumed_at = now(), consumed_by = $2
Matt W297 WHERE id = $1 AND consumed_at IS NULL",
Matt W298 )
Matt W299 .bind(token_id)
Matt W300 .bind(user_id)
Matt W301 .execute(&mut *tx)
Matt W302 .await?;
Matt W303
Matt W304 if claimed.rows_affected() == 0 {
Matt W305 tx.rollback().await?;
Matt W306 return Ok(false);
Matt W307 }
Matt W308
Matt W309 sqlx::query("UPDATE users SET is_admin = true WHERE id = $1")
Matt W310 .bind(user_id)
Matt W311 .execute(&mut *tx)
Matt W312 .await?;
Matt W313
Matt W314 sqlx::query(
Matt W315 "INSERT INTO audit_log (id, actor_id, action, target, metadata)
Matt W316 VALUES ($1, $2, 'admin.claimed', $3, '{}')",
Matt W317 )
Matt W318 .bind(new_id())
Matt W319 .bind(user_id)
Matt W320 .bind(format!("user:{user_id}"))
Matt W321 .execute(&mut *tx)
Matt W322 .await?;
Matt W323
Matt W324 tx.commit().await?;
Matt W325 return Ok(true);
Matt W326 }
Matt W327
Matt W328 Ok(false)
Matt W329}
Matt W330
Matt W331#[cfg(test)]
Matt W332mod tests {
Matt W333 use super::*;
Matt W334
Matt W335 fn ident(email: Option<&str>) -> Identity {
Matt W336 Identity {
Matt W337 subject: "kratos-uuid".into(),
Matt W338 email: email.map(str::to_string),
Matt W339 name: None,
Matt W340 preferred_username: None,
Matt W341 id_token: None,
Matt W342 }
Matt W343 }
Matt W344
Matt W345 #[test]
Matt W346 fn allowlisted_email_is_admitted() {
Matt W347 let allow = vec!["nycmattw@gmail.com".to_string()];
Matt W348 assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(()));
Matt W349 }
Matt W350
Matt W351 #[test]
Matt W352 fn allowlist_matching_is_case_and_whitespace_insensitive() {
Matt W353 let allow = vec![" NycMattW@Gmail.com ".to_string()];
Matt W354 assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(()));
Matt W355 }
Matt W356
Matt W357 #[test]
Matt W358 fn a_stranger_is_denied_even_after_a_successful_login() {
Matt W359 // The decided policy: authentication is not authorization.
Matt W360 let allow = vec!["nycmattw@gmail.com".to_string()];
Matt W361 assert_eq!(
Matt W362 admission(&ident(Some("attacker@example.com")), &allow, false),
Matt W363 Err(Denied::NotInvited)
Matt W364 );
Matt W365 }
Matt W366
Matt W367 #[test]
Matt W368 fn an_invitation_admits_without_the_allowlist() {
Matt W369 assert_eq!(admission(&ident(Some("new@example.com")), &[], true), Ok(()));
Matt W370 }
Matt W371
Matt W372 #[test]
Matt W373 fn no_email_is_denied_rather_than_admitted() {
Matt W374 // Failing open here would let anyone Kratos authenticates hold an
Matt W375 // account, which is the whole policy defeated.
Matt W376 assert_eq!(admission(&ident(None), &[], false), Err(Denied::NoEmail));
Matt W377 assert_eq!(
Matt W378 admission(&ident(None), &["x@y.z".into()], false),
Matt W379 Err(Denied::NoEmail),
Matt W380 "a missing email must never fall through to an allowlist match"
Matt W381 );
Matt W382 }
Matt W383
Matt W384 #[test]
Matt W385 fn an_empty_allowlist_admits_nobody_uninvited() {
Matt W386 assert_eq!(
Matt W387 admission(&ident(Some("anyone@example.com")), &[], false),
Matt W388 Err(Denied::NotInvited)
Matt W389 );
Matt W390 }
Matt W391
Matt W392 #[test]
Matt W393 fn a_blank_allowlist_entry_matches_nothing() {
Matt W394 // `DOGFOOD_ALLOWLIST=""` or a stray comma parses to [""] or ["", "x"].
Matt W395 // A blank entry must never admit anyone.
Matt W396 assert_eq!(
Matt W397 admission(&ident(Some("")), &["".to_string()], false),
Matt W398 Err(Denied::NoEmail)
Matt W399 );
Matt W400 assert_eq!(
Matt W401 admission(&ident(Some(" ")), &[" ".to_string()], false),
Matt W402 Err(Denied::NoEmail)
Matt W403 );
Matt W404 assert_eq!(
Matt W405 admission(&ident(Some("someone@example.com")), &["".to_string()], false),
Matt W406 Err(Denied::NotInvited)
Matt W407 );
Matt W408 }
Matt W409
Matt W410 #[test]
Matt W411 fn whitespace_only_email_is_treated_as_absent() {
Matt W412 assert_eq!(admission(&ident(Some(" ")), &[], false), Err(Denied::NoEmail));
Matt W413 }
Matt W414
Matt W415 // ─── subject allowlisting ────────────────────────────────────────────────
Matt W416
Matt W417 #[test]
Matt W418 fn a_subject_entry_admits_without_any_email_claim() {
Matt W419 // The case that unblocked sign-in on this deployment: Hydra skipped
Matt W420 // consent, the AX supplied no claims, and the ID token carried only
Matt W421 // `sub`. Subject matching must still admit.
Matt W422 let allow = vec!["kratos-uuid".to_string()];
Matt W423 assert_eq!(admission(&ident(None), &allow, false), Ok(()));
Matt W424 }
Matt W425
Matt W426 #[test]
Matt W427 fn subject_matching_is_case_sensitive() {
Matt W428 // Subjects are opaque identifiers, not addresses. Folding case could
Matt W429 // collide two distinct subjects on a provider that issues them.
Matt W430 let allow = vec!["KRATOS-UUID".to_string()];
Matt W431 assert_eq!(admission(&ident(None), &allow, false), Err(Denied::NoEmail));
Matt W432 }
Matt W433
Matt W434 #[test]
Matt W435 fn a_non_matching_subject_still_falls_through_to_email() {
Matt W436 let allow = vec!["some-other-subject".into(), "matt@dogfood.sh".into()];
Matt W437 assert_eq!(admission(&ident(Some("matt@dogfood.sh")), &allow, false), Ok(()));
Matt W438 assert_eq!(
Matt W439 admission(&ident(Some("other@example.com")), &allow, false),
Matt W440 Err(Denied::NotInvited)
Matt W441 );
Matt W442 }
Matt W443}

443 lines · Rust