Jump to…
snowattribute changes to their author, and index SSH pushesowzkxxuxzulu1mo
1//! User provisioning and site-admin bootstrap.
2//!
3//! Access is invite/allowlist only (decided): completing an OIDC login is not
4//! sufficient to obtain a Dogfood account. An identity must additionally be on
5//! the configured allowlist or hold an open invitation.
6//!
7//! Site admin is claimed once through a one-time setup token that the web
8//! process mints and logs on first boot when no admin exists.
9
10use anyhow::{Context, Result};
11use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
12use argon2::Argon2;
13use df_db::ids::new_id;
14use df_db::models::User;
15use rand::RngCore;
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use crate::oidc::Identity;
20
21/// Why a login did not result in an account.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Denied {
24 /// Authenticated successfully, but not allowlisted or invited.
25 NotInvited,
26 /// The provider returned no email, so allowlist matching is impossible.
27 NoEmail,
28 /// A handle could not be derived and must be chosen by the user.
29 NeedsHandle { suggested: Option<String> },
30}
31
32/// Decide whether an identity may hold an account.
33///
34/// Pure so the policy is testable without a database. `invited` is the result
35/// of the invitation lookup; `allowlist` comes from configuration.
36pub fn admission(identity: &Identity, allowlist: &[String], invited: bool) -> Result<(), Denied> {
37 // Entries may be either an email address or a raw OIDC subject. Subjects are
38 // matched first and are the more dependable of the two: `sub` is guaranteed
39 // present on every login, whereas `email` depends on the provider's consent
40 // app re-supplying claims on each flow. Ory's Account Experience does not do
41 // that once Hydra starts skipping consent, so an email-only allowlist stops
42 // matching an hour after a user's first sign-in.
43 let subject = identity.subject.trim();
44 if !subject.is_empty()
45 && allowlist
46 .iter()
47 .map(|a| a.trim())
48 .filter(|a| !a.is_empty())
49 // Subjects are opaque and case-sensitive; do not fold case here.
50 .any(|a| a == subject)
51 {
52 return Ok(());
53 }
54
55 let email = identity.email.as_deref().map(str::trim).unwrap_or("");
56 if email.is_empty() {
57 // Without an email we cannot match the allowlist, and silently creating
58 // an account would defeat the policy. An empty-string email is treated
59 // as absent so it can never match a malformed allowlist entry.
60 return Err(Denied::NoEmail);
61 }
62
63 let allowed = invited
64 || allowlist
65 .iter()
66 .map(|a| a.trim())
67 // A blank entry — from `DOGFOOD_ALLOWLIST=""` or a stray comma —
68 // must never match anything.
69 .filter(|a| !a.is_empty())
70 .any(|a| a.eq_ignore_ascii_case(email));
71
72 if !allowed {
73 return Err(Denied::NotInvited);
74 }
75 Ok(())
76}
77
78/// Find an existing user by OIDC subject.
79///
80/// Keyed on `sub`, never email (spec §6).
81pub async fn find_by_subject(db: &PgPool, subject: &str) -> Result<Option<User>> {
82 let u = sqlx::query_as::<_, User>(
83 "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at
84 FROM users WHERE subject = $1",
85 )
86 .bind(subject)
87 .fetch_optional(db)
88 .await?;
89 Ok(u)
90}
91
92/// Fill in profile fields the account is missing, from a fresh login's claims.
93///
94/// Only ever writes over a `NULL`. An account whose email is already recorded
95/// keeps it, so this cannot silently move an identity from under commits that
96/// are attributed to it.
97///
98/// This exists because the claims are **not reliably present on every login**:
99/// Hydra's skip-consent path (taken once consent is remembered) can return an
100/// ID token carrying only `sub`, so an account created during such a login is
101/// created with no email at all — and email is the only thing that links a
102/// pushed commit back to an account. Backfilling on any later login that does
103/// carry the claim is what repairs that without the user doing anything.
104pub async fn backfill_profile(db: &PgPool, user_id: Uuid, identity: &Identity) -> Result<()> {
105 let email = identity.email.as_deref().map(str::trim).filter(|e| !e.is_empty());
106 let name = identity.name.as_deref().map(str::trim).filter(|n| !n.is_empty());
107
108 if email.is_none() && name.is_none() {
109 return Ok(());
110 }
111
112 let updated = sqlx::query(
113 "UPDATE users
114 SET email = COALESCE(email, $2),
115 display_name = COALESCE(display_name, $3)
116 WHERE id = $1
117 AND (($2 IS NOT NULL AND email IS NULL)
118 OR ($3 IS NOT NULL AND display_name IS NULL))",
119 )
120 .bind(user_id)
121 .bind(email)
122 .bind(name)
123 .execute(db)
124 .await
125 .context("backfilling user profile")?;
126
127 if updated.rows_affected() > 0 {
128 tracing::info!(user = %user_id, "filled in profile fields from a fresh login");
129 }
130 Ok(())
131}
132
133/// Whether an open invitation exists for this email.
134pub async fn has_invitation(db: &PgPool, email: &str) -> Result<bool> {
135 let found: Option<(Uuid,)> =
136 sqlx::query_as("SELECT id FROM invitations WHERE email = $1 AND accepted_at IS NULL")
137 .bind(email)
138 .fetch_optional(db)
139 .await?;
140 Ok(found.is_some())
141}
142
143/// Create a user, marking any matching invitation accepted.
144pub async fn create_user(
145 db: &PgPool,
146 identity: &Identity,
147 handle: &str,
148) -> Result<User> {
149 let mut tx = db.begin().await?;
150 let id = new_id();
151
152 let user = sqlx::query_as::<_, User>(
153 "INSERT INTO users (id, subject, handle, display_name, email)
154 VALUES ($1, $2, $3, $4, $5)
155 RETURNING id, subject, handle, display_name, email, avatar_url, is_admin, created_at",
156 )
157 .bind(id)
158 .bind(&identity.subject)
159 .bind(handle)
160 .bind(identity.name.as_deref())
161 .bind(identity.email.as_deref())
162 .fetch_one(&mut *tx)
163 .await
164 .context("creating user")?;
165
166 if let Some(email) = identity.email.as_deref() {
167 sqlx::query(
168 "UPDATE invitations SET accepted_at = now(), accepted_by = $2
169 WHERE email = $1 AND accepted_at IS NULL",
170 )
171 .bind(email)
172 .bind(user.id)
173 .execute(&mut *tx)
174 .await?;
175 }
176
177 sqlx::query(
178 "INSERT INTO audit_log (id, actor_id, action, target, metadata)
179 VALUES ($1, $2, 'user.provisioned', $3, $4)",
180 )
181 .bind(new_id())
182 .bind(user.id)
183 .bind(format!("user:{}", user.id))
184 .bind(serde_json::json!({ "handle": handle, "subject": identity.subject }))
185 .execute(&mut *tx)
186 .await?;
187
188 tx.commit().await?;
189 Ok(user)
190}
191
192/// Whether a handle is free and not reserved.
193pub async fn handle_available(db: &PgPool, handle: &str) -> Result<bool> {
194 let taken: Option<(i32,)> = sqlx::query_as(
195 "SELECT 1 FROM (
196 SELECT handle FROM users
197 UNION ALL SELECT handle FROM orgs
198 UNION ALL SELECT handle FROM reserved_handles
199 ) t WHERE handle = $1",
200 )
201 .bind(handle)
202 .fetch_optional(db)
203 .await?;
204 Ok(taken.is_none())
205}
206
207/// Pick a free handle derived from `base`, appending a numeric suffix if needed.
208pub async fn unique_handle(db: &PgPool, base: &str) -> Result<Option<String>> {
209 if handle_available(db, base).await? {
210 return Ok(Some(base.to_string()));
211 }
212 for n in 2..=99 {
213 // Keep within the 39-character limit the CHECK enforces.
214 let suffix = n.to_string();
215 let trimmed: String = base.chars().take(39 - suffix.len() - 1).collect();
216 let candidate = format!("{}-{}", trimmed.trim_end_matches('-'), suffix);
217 if handle_available(db, &candidate).await? {
218 return Ok(Some(candidate));
219 }
220 }
221 Ok(None)
222}
223
224// ─── site admin bootstrap ────────────────────────────────────────────────────
225
226/// Whether any site admin exists.
227pub async fn any_admin_exists(db: &PgPool) -> Result<bool> {
228 let found: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE is_admin LIMIT 1")
229 .fetch_optional(db)
230 .await?;
231 Ok(found.is_some())
232}
233
234/// Mint a setup token if no admin exists and no unconsumed token is outstanding.
235///
236/// Returns the plaintext exactly once, for logging. Only the hash is stored.
237pub async fn ensure_setup_token(db: &PgPool) -> Result<Option<String>> {
238 if any_admin_exists(db).await? {
239 return Ok(None);
240 }
241
242 let outstanding: Option<(Uuid,)> =
243 sqlx::query_as("SELECT id FROM setup_tokens WHERE consumed_at IS NULL LIMIT 1")
244 .fetch_optional(db)
245 .await?;
246 if outstanding.is_some() {
247 // A token is already outstanding. We cannot show it again — we do not
248 // have the plaintext — so say so rather than minting a second one.
249 return Ok(None);
250 }
251
252 let mut bytes = [0u8; 24];
253 rand::thread_rng().fill_bytes(&mut bytes);
254 let plaintext = hex::encode(bytes);
255
256 let salt = SaltString::generate(&mut rand::thread_rng());
257 let hash = Argon2::default()
258 .hash_password(plaintext.as_bytes(), &salt)
259 .map_err(|e| anyhow::anyhow!("hashing setup token: {e}"))?
260 .to_string();
261
262 sqlx::query("INSERT INTO setup_tokens (id, token_hash) VALUES ($1, $2)")
263 .bind(new_id())
264 .bind(hash)
265 .execute(db)
266 .await?;
267
268 Ok(Some(plaintext))
269}
270
271/// Consume a setup token, promoting `user_id` to site admin.
272///
273/// Returns false for an unknown, already-consumed, or mismatched token.
274pub async fn claim_admin(db: &PgPool, user_id: Uuid, presented: &str) -> Result<bool> {
275 let candidates: Vec<(Uuid, String)> =
276 sqlx::query_as("SELECT id, token_hash FROM setup_tokens WHERE consumed_at IS NULL")
277 .fetch_all(db)
278 .await?;
279
280 for (token_id, stored) in candidates {
281 let Ok(parsed) = PasswordHash::new(&stored) else {
282 tracing::error!(%token_id, "setup token hash unparseable");
283 continue;
284 };
285 if Argon2::default()
286 .verify_password(presented.as_bytes(), &parsed)
287 .is_err()
288 {
289 continue;
290 }
291
292 let mut tx = db.begin().await?;
293 // Guard against two requests racing on the same token: the UPDATE only
294 // matches while it is still unconsumed.
295 let claimed = sqlx::query(
296 "UPDATE setup_tokens SET consumed_at = now(), consumed_by = $2
297 WHERE id = $1 AND consumed_at IS NULL",
298 )
299 .bind(token_id)
300 .bind(user_id)
301 .execute(&mut *tx)
302 .await?;
303
304 if claimed.rows_affected() == 0 {
305 tx.rollback().await?;
306 return Ok(false);
307 }
308
309 sqlx::query("UPDATE users SET is_admin = true WHERE id = $1")
310 .bind(user_id)
311 .execute(&mut *tx)
312 .await?;
313
314 sqlx::query(
315 "INSERT INTO audit_log (id, actor_id, action, target, metadata)
316 VALUES ($1, $2, 'admin.claimed', $3, '{}')",
317 )
318 .bind(new_id())
319 .bind(user_id)
320 .bind(format!("user:{user_id}"))
321 .execute(&mut *tx)
322 .await?;
323
324 tx.commit().await?;
325 return Ok(true);
326 }
327
328 Ok(false)
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn ident(email: Option<&str>) -> Identity {
336 Identity {
337 subject: "kratos-uuid".into(),
338 email: email.map(str::to_string),
339 name: None,
340 preferred_username: None,
341 id_token: None,
342 }
343 }
344
345 #[test]
346 fn allowlisted_email_is_admitted() {
347 let allow = vec!["nycmattw@gmail.com".to_string()];
348 assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(()));
349 }
350
351 #[test]
352 fn allowlist_matching_is_case_and_whitespace_insensitive() {
353 let allow = vec![" NycMattW@Gmail.com ".to_string()];
354 assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(()));
355 }
356
357 #[test]
358 fn a_stranger_is_denied_even_after_a_successful_login() {
359 // The decided policy: authentication is not authorization.
360 let allow = vec!["nycmattw@gmail.com".to_string()];
361 assert_eq!(
362 admission(&ident(Some("attacker@example.com")), &allow, false),
363 Err(Denied::NotInvited)
364 );
365 }
366
367 #[test]
368 fn an_invitation_admits_without_the_allowlist() {
369 assert_eq!(admission(&ident(Some("new@example.com")), &[], true), Ok(()));
370 }
371
372 #[test]
373 fn no_email_is_denied_rather_than_admitted() {
374 // Failing open here would let anyone Kratos authenticates hold an
375 // account, which is the whole policy defeated.
376 assert_eq!(admission(&ident(None), &[], false), Err(Denied::NoEmail));
377 assert_eq!(
378 admission(&ident(None), &["x@y.z".into()], false),
379 Err(Denied::NoEmail),
380 "a missing email must never fall through to an allowlist match"
381 );
382 }
383
384 #[test]
385 fn an_empty_allowlist_admits_nobody_uninvited() {
386 assert_eq!(
387 admission(&ident(Some("anyone@example.com")), &[], false),
388 Err(Denied::NotInvited)
389 );
390 }
391
392 #[test]
393 fn a_blank_allowlist_entry_matches_nothing() {
394 // `DOGFOOD_ALLOWLIST=""` or a stray comma parses to [""] or ["", "x"].
395 // A blank entry must never admit anyone.
396 assert_eq!(
397 admission(&ident(Some("")), &["".to_string()], false),
398 Err(Denied::NoEmail)
399 );
400 assert_eq!(
401 admission(&ident(Some(" ")), &[" ".to_string()], false),
402 Err(Denied::NoEmail)
403 );
404 assert_eq!(
405 admission(&ident(Some("someone@example.com")), &["".to_string()], false),
406 Err(Denied::NotInvited)
407 );
408 }
409
410 #[test]
411 fn whitespace_only_email_is_treated_as_absent() {
412 assert_eq!(admission(&ident(Some(" ")), &[], false), Err(Denied::NoEmail));
413 }
414
415 // ─── subject allowlisting ────────────────────────────────────────────────
416
417 #[test]
418 fn a_subject_entry_admits_without_any_email_claim() {
419 // The case that unblocked sign-in on this deployment: Hydra skipped
420 // consent, the AX supplied no claims, and the ID token carried only
421 // `sub`. Subject matching must still admit.
422 let allow = vec!["kratos-uuid".to_string()];
423 assert_eq!(admission(&ident(None), &allow, false), Ok(()));
424 }
425
426 #[test]
427 fn subject_matching_is_case_sensitive() {
428 // Subjects are opaque identifiers, not addresses. Folding case could
429 // collide two distinct subjects on a provider that issues them.
430 let allow = vec!["KRATOS-UUID".to_string()];
431 assert_eq!(admission(&ident(None), &allow, false), Err(Denied::NoEmail));
432 }
433
434 #[test]
435 fn a_non_matching_subject_still_falls_through_to_email() {
436 let allow = vec!["some-other-subject".into(), "matt@dogfood.sh".into()];
437 assert_eq!(admission(&ident(Some("matt@dogfood.sh")), &allow, false), Ok(()));
438 assert_eq!(
439 admission(&ident(Some("other@example.com")), &allow, false),
440 Err(Denied::NotInvited)
441 );
442 }
443}

443 lines · Rust