Skip to main content

teamcraft/services/
users.rs

1//! Users, browser sessions and API tokens.
2
3use std::time::Duration;
4
5use chrono::Utc;
6use serde_json::json;
7use sqlx::PgPool;
8use uuid::Uuid;
9
10use crate::{
11    auth::{self, Actor},
12    config::Config,
13    domain::{self, Role, TokenScope},
14    error::{AppError, AppResult},
15    models::{ApiToken, User, TOKEN_COLUMNS, USER_COLUMNS},
16    services::{
17        access, audit,
18        project_policy::{self, Interface},
19    },
20};
21
22/// Columns (of `users u`) needed to build an [`Actor`], including the
23/// project groups the user belongs to and the groups the user may not read
24/// (see [`access::hidden_of_user`]; archived groups count too).
25fn actor_columns() -> String {
26    format!(
27        "u.id, u.username, u.display_name, u.role, u.skin, u.preferred_cad,
28    COALESCE((SELECT array_agg(pm.project_id) FROM project_members pm WHERE pm.user_id = u.id),
29             '{{}}'::uuid[]) AS projects,
30    {} AS hidden_projects",
31        access::hidden_of_user("u")
32    )
33}
34
35#[derive(sqlx::FromRow)]
36struct ActorRow {
37    id: Uuid,
38    username: String,
39    display_name: String,
40    role: String,
41    skin: String,
42    preferred_cad: String,
43    projects: Vec<Uuid>,
44    hidden_projects: Vec<Uuid>,
45}
46
47impl From<ActorRow> for Actor {
48    fn from(row: ActorRow) -> Self {
49        Actor {
50            id: row.id,
51            username: row.username,
52            display_name: row.display_name,
53            role: Role::parse(&row.role).unwrap_or(Role::Viewer),
54            skin: domain::skin_or_default(&row.skin).to_string(),
55            // an id no longer registered shows all files alike
56            preferred_cad: if crate::cadformat::is_family(&row.preferred_cad) {
57                row.preferred_cad
58            } else {
59                String::new()
60            },
61            // a member hidden by the conditions on membership of a group is
62            // treated as a non-member of it (also for editing)
63            projects: row
64                .projects
65                .into_iter()
66                .filter(|p| !row.hidden_projects.contains(p))
67                .collect(),
68            hidden_projects: row.hidden_projects,
69        }
70    }
71}
72
73/// Loads the full actor (incl. project memberships) for a user id.
74pub async fn actor(pool: &PgPool, user_id: Uuid) -> AppResult<Actor> {
75    let cols = actor_columns();
76    sqlx::query_as::<_, ActorRow>(&format!("SELECT {cols} FROM users u WHERE u.id = $1"))
77        .bind(user_id)
78        .fetch_optional(pool)
79        .await?
80        .map(Actor::from)
81        .ok_or_else(|| AppError::not_found("User not found."))
82}
83
84/// The actor of an active user as it is now (memberships and hidden groups
85/// read again); `None` for a deactivated or deleted user. Long-running work
86/// on behalf of a user (the assistant's tool loop) calls this per step, so
87/// a lost membership or a newly restricted group applies at once.
88pub async fn active_actor(pool: &PgPool, user_id: Uuid) -> AppResult<Option<Actor>> {
89    let cols = actor_columns();
90    Ok(sqlx::query_as::<_, ActorRow>(&format!(
91        "SELECT {cols} FROM users u WHERE u.id = $1 AND u.active"
92    ))
93    .bind(user_id)
94    .fetch_optional(pool)
95    .await?
96    .map(Actor::from))
97}
98
99// ---------------------------------------------------------------------------
100// Sessions & tokens
101// ---------------------------------------------------------------------------
102
103pub async fn actor_for_session(pool: &PgPool, token: &str) -> AppResult<Option<Actor>> {
104    Ok(session_actor(pool, token).await?.map(|(actor, _)| actor))
105}
106
107#[derive(sqlx::FromRow)]
108struct SessionActorRow {
109    #[sqlx(flatten)]
110    actor: ActorRow,
111    session_started: chrono::DateTime<Utc>,
112}
113
114/// The user of a web session and when the session began (for the maximum
115/// sign-in age of need-to-know groups); `None` for an unknown or expired
116/// session or a deactivated user.
117pub async fn session_actor(
118    pool: &PgPool,
119    token: &str,
120) -> AppResult<Option<(Actor, chrono::DateTime<Utc>)>> {
121    let cols = actor_columns();
122    let row = sqlx::query_as::<_, SessionActorRow>(&format!(
123        "SELECT {cols}, s.created_at AS session_started
124         FROM sessions s JOIN users u ON u.id = s.user_id
125         WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active"
126    ))
127    .bind(auth::hash_token(token))
128    .fetch_optional(pool)
129    .await?;
130    Ok(row.map(|r| (Actor::from(r.actor), r.session_started)))
131}
132
133#[derive(sqlx::FromRow)]
134struct TokenActorRow {
135    #[sqlx(flatten)]
136    actor: ActorRow,
137    scope: String,
138    /// The use is not recorded yet (see [`TOKEN_USE_RECORDED_EVERY`]).
139    record_use: bool,
140    /// Groups that switched personal access tokens off.
141    denied: Vec<Uuid>,
142}
143
144/// How often the use of a token is written down (`last_used_at`, the
145/// sliding expiry) – a PostgreSQL interval. Every API request presents its
146/// token; one write a minute per token keeps the write load low.
147const TOKEN_USE_RECORDED_EVERY: &str = "1 minute";
148
149/// The user of a personal access token and the token's scope; `None` for an
150/// unknown, expired or revoked token or a deactivated user. A use moves the
151/// expiry of a token with a sliding lifetime (`idle_days`) forward.
152///
153/// Every API and MCP request comes through here, so it is one query: the
154/// user, whether the use has to be recorded and the groups that switched
155/// personal access tokens off are read in the same statement. The write
156/// follows only when it is due.
157pub async fn actor_for_api_token(
158    pool: &PgPool,
159    token: &str,
160) -> AppResult<Option<(Actor, TokenScope)>> {
161    let token_hash = auth::hash_token(token);
162    let cols = actor_columns();
163    // Interfaces switched off per group (need-to-know policy): a PAT sees
164    // those groups as hidden. The web session is not affected.
165    let denied = project_policy::denied_groups_sql(&[Interface::ApiTokens]);
166    let row = sqlx::query_as::<_, TokenActorRow>(&format!(
167        "SELECT {cols}, t.scope,
168                (t.last_used_at IS NULL
169                 OR t.last_used_at < now() - interval '{TOKEN_USE_RECORDED_EVERY}') AS record_use,
170                COALESCE(ARRAY({denied}), '{{}}'::uuid[]) AS denied
171         FROM api_tokens t JOIN users u ON u.id = t.user_id
172         WHERE t.token_hash = $1 AND (t.expires_at IS NULL OR t.expires_at > now()) AND u.active"
173    ))
174    .bind(&token_hash)
175    .fetch_optional(pool)
176    .await?;
177    let Some(row) = row else { return Ok(None) };
178    if row.record_use {
179        // the condition again: of requests arriving together, one writes
180        sqlx::query(&format!(
181            "UPDATE api_tokens SET last_used_at = now(),
182                    expires_at = CASE WHEN idle_days IS NULL THEN expires_at
183                                      ELSE now() + make_interval(days => idle_days) END
184             WHERE token_hash = $1
185               AND (last_used_at IS NULL
186                    OR last_used_at < now() - interval '{TOKEN_USE_RECORDED_EVERY}')"
187        ))
188        .bind(&token_hash)
189        .execute(pool)
190        .await?;
191    }
192    // an unknown stored scope (the CHECK constraint prevents it) grants nothing
193    let Some(scope) = TokenScope::parse(&row.scope) else {
194        return Ok(None);
195    };
196    Ok(Some((
197        Actor::from(row.actor).without_groups(&row.denied),
198        scope,
199    )))
200}
201
202/// Stores the UI skin of the signed-in user.
203pub async fn set_skin(pool: &PgPool, actor: &Actor, skin: &str) -> AppResult<()> {
204    if !domain::is_skin(skin) {
205        return Err(AppError::BadRequest(format!("Unknown skin '{skin}'.")));
206    }
207    sqlx::query("UPDATE users SET skin = $2 WHERE id = $1")
208        .bind(actor.id)
209        .bind(skin)
210        .execute(pool)
211        .await?;
212    Ok(())
213}
214
215/// Stores the preferred CAD program of the signed-in user: a format family
216/// (`cadformat::families`) or `""` = show all files alike. Display only –
217/// it never changes what the user may read or download.
218pub async fn set_preferred_cad(pool: &PgPool, actor: &Actor, family: &str) -> AppResult<()> {
219    if !family.is_empty() && !crate::cadformat::is_family(family) {
220        return Err(AppError::BadRequest(format!(
221            "Unknown CAD program '{family}'."
222        )));
223    }
224    sqlx::query("UPDATE users SET preferred_cad = $2 WHERE id = $1")
225        .bind(actor.id)
226        .bind(family)
227        .execute(pool)
228        .await?;
229    Ok(())
230}
231
232/// How a user signed in (recorded in the audit log).
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub enum SignIn {
235    /// Username and password.
236    Password,
237    /// Single sign-on (OIDC).
238    Sso,
239    /// The administrator created at the first start.
240    Setup,
241}
242
243impl SignIn {
244    pub fn as_str(self) -> &'static str {
245        match self {
246            SignIn::Password => "password",
247            SignIn::Sso => "sso",
248            SignIn::Setup => "setup",
249        }
250    }
251}
252
253/// Creates a session and returns the plaintext token for the cookie; the
254/// sign-in is recorded in the audit log (`user.sign_in`, never the token).
255pub async fn create_session(
256    pool: &PgPool,
257    user_id: Uuid,
258    ttl: Duration,
259    how: SignIn,
260) -> AppResult<String> {
261    let me = actor(pool, user_id).await?;
262    let token = auth::generate_token(auth::SESSION_TOKEN_PREFIX);
263    let expires_at = Utc::now()
264        + chrono::Duration::from_std(ttl).unwrap_or_else(|_| chrono::Duration::hours(72));
265    let mut tx = pool.begin().await?;
266    sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)")
267        .bind(auth::hash_token(&token))
268        .bind(user_id)
269        .bind(expires_at)
270        .execute(&mut *tx)
271        .await?;
272    sqlx::query("UPDATE users SET last_login_at = now() WHERE id = $1")
273        .bind(user_id)
274        .execute(&mut *tx)
275        .await?;
276    audit::record(
277        &mut tx,
278        Some(&me),
279        "user.sign_in",
280        "user",
281        Some(user_id),
282        format!("Signed in ({})", how.as_str()),
283        json!({ "method": how.as_str() }),
284    )
285    .await?;
286    tx.commit().await?;
287    Ok(token)
288}
289
290/// Ends a web session (sign-out); recorded as `user.sign_out`. An unknown
291/// or already ended session is not an error and leaves no entry.
292pub async fn delete_session(pool: &PgPool, token: &str) -> AppResult<()> {
293    let mut tx = pool.begin().await?;
294    let ended: Option<Uuid> =
295        sqlx::query_scalar("DELETE FROM sessions WHERE token_hash = $1 RETURNING user_id")
296            .bind(auth::hash_token(token))
297            .fetch_optional(&mut *tx)
298            .await?;
299    if let Some(user_id) = ended {
300        let me = actor(pool, user_id).await?;
301        audit::record(
302            &mut tx,
303            Some(&me),
304            "user.sign_out",
305            "user",
306            Some(user_id),
307            "Signed out",
308            json!({}),
309        )
310        .await?;
311    }
312    tx.commit().await?;
313    Ok(())
314}
315
316/// Records a failed password sign-in (`user.sign_in_failed`). For an
317/// existing account the entry names it; for an unknown username it does
318/// not keep what was typed – that is often a password entered in the wrong
319/// field. The password itself is never recorded.
320pub async fn record_failed_sign_in(pool: &PgPool, username: &str) -> AppResult<()> {
321    let typed = username.trim().to_ascii_lowercase();
322    let account: Option<(Uuid, String)> =
323        sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
324            .bind(&typed)
325            .fetch_optional(pool)
326            .await?;
327    let mut tx = pool.begin().await?;
328    match &account {
329        Some((id, name)) => {
330            tracing::warn!(username = %name, "failed sign-in");
331            audit::record(
332                &mut tx,
333                None,
334                "user.sign_in_failed",
335                "user",
336                Some(*id),
337                format!("Failed sign-in as '{name}'"),
338                json!({ "known_account": true }),
339            )
340            .await?
341        }
342        None => {
343            tracing::warn!("failed sign-in with an unknown username");
344            audit::record(
345                &mut tx,
346                None,
347                "user.sign_in_failed",
348                "user",
349                None,
350                "Failed sign-in with an unknown username",
351                json!({ "known_account": false }),
352            )
353            .await?
354        }
355    }
356    tx.commit().await?;
357    Ok(())
358}
359
360/// Removes expired sessions and stale OIDC login attempts.
361pub async fn purge_expired(pool: &PgPool) -> AppResult<u64> {
362    let sessions = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
363        .execute(pool)
364        .await?
365        .rows_affected();
366    let states =
367        sqlx::query("DELETE FROM oidc_states WHERE created_at < now() - interval '15 minutes'")
368            .execute(pool)
369            .await?
370            .rows_affected();
371    let desktop_codes = super::desktop::purge_expired(pool).await?;
372    Ok(sessions + states + desktop_codes)
373}
374
375pub async fn list_tokens(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<ApiToken>> {
376    let tokens = sqlx::query_as::<_, ApiToken>(&format!(
377        "SELECT {TOKEN_COLUMNS} FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC"
378    ))
379    .bind(user_id)
380    .fetch_all(pool)
381    .await?;
382    Ok(tokens)
383}
384
385/// How long a new token lives.
386#[derive(Clone, Copy, Debug, PartialEq, Eq)]
387pub enum TokenLifetime {
388    /// Until revoked.
389    Unlimited,
390    /// A fixed number of days from creation.
391    Days(i64),
392    /// Expires after this many days without use; every use renews it.
393    IdleDays(i64),
394}
395
396impl TokenLifetime {
397    /// `None` = never expires, otherwise a fixed lifetime in days.
398    pub fn fixed(days: Option<i64>) -> Self {
399        days.map_or(TokenLifetime::Unlimited, TokenLifetime::Days)
400    }
401
402    /// The lifetime under an idle limit of `idle` days (0 = none): no expiry
403    /// or a longer one becomes `IdleDays(idle)`; a shorter fixed or idle
404    /// lifetime stays.
405    pub fn capped(self, idle: u32) -> Self {
406        let idle = i64::from(idle);
407        if idle == 0 {
408            return self;
409        }
410        match self {
411            TokenLifetime::Days(d) if d <= idle => self,
412            TokenLifetime::IdleDays(d) => TokenLifetime::IdleDays(d.min(idle)),
413            _ => TokenLifetime::IdleDays(idle),
414        }
415    }
416}
417
418/// The lifetime of a new token of `user`: for a single sign-on user capped by
419/// `TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS` (`sso_idle_days`, 0 = off; issue #173),
420/// for anybody else as asked.
421pub async fn token_lifetime_for(
422    pool: &PgPool,
423    user: Uuid,
424    asked: TokenLifetime,
425    sso_idle_days: u32,
426) -> AppResult<TokenLifetime> {
427    if sso_idle_days == 0 {
428        return Ok(asked);
429    }
430    let sso: bool = sqlx::query_scalar(
431        "SELECT COALESCE((SELECT auth_source = 'oidc' FROM users WHERE id = $1), false)",
432    )
433    .bind(user)
434    .fetch_one(pool)
435    .await?;
436    if sso {
437        return Ok(asked.capped(sso_idle_days));
438    }
439    Ok(asked)
440}
441
442/// What a back-channel logout of the identity provider did.
443#[derive(Clone, Debug, serde::Serialize)]
444pub struct IdpLogout {
445    pub user_id: Uuid,
446    pub username: String,
447    pub sessions_ended: u64,
448}
449
450/// The identity provider logged the user with the subject `sub` out
451/// (OpenID Connect Back-Channel Logout, issue #173 step 9): their browser
452/// sessions end and the time is noted (`users.idp_logout_at`). An unknown
453/// subject does nothing (`None`). API and desktop tokens stay.
454///
455/// # Invariants
456///
457/// * **Permission:** none – the caller has checked the provider's signed
458///   logout token (`oidc::OidcClient::verify_logout_token`).
459/// * **Locks:** the user row (`FOR UPDATE`).
460/// * **Audit:** `user.idp_logout` (by the system) with the number of
461///   sessions ended.
462/// * **Notifications / webhooks:** none.
463/// * **Atomic:** one transaction.
464pub async fn idp_logout(pool: &PgPool, sub: &str) -> AppResult<Option<IdpLogout>> {
465    let mut tx = pool.begin().await?;
466    let user: Option<(Uuid, String)> = sqlx::query_as(
467        "SELECT id, username FROM users WHERE oidc_subject = $1 AND auth_source = 'oidc'
468         FOR UPDATE",
469    )
470    .bind(sub)
471    .fetch_optional(&mut *tx)
472    .await?;
473    let Some((user_id, username)) = user else {
474        tx.rollback().await?;
475        return Ok(None);
476    };
477    let sessions_ended = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
478        .bind(user_id)
479        .execute(&mut *tx)
480        .await?
481        .rows_affected();
482    sqlx::query("UPDATE users SET idp_logout_at = now() WHERE id = $1")
483        .bind(user_id)
484        .execute(&mut *tx)
485        .await?;
486    audit::record(
487        &mut tx,
488        None,
489        "user.idp_logout",
490        "user",
491        Some(user_id),
492        format!("The identity provider logged {username} out ({sessions_ended} sessions ended)"),
493        json!({ "sessions": sessions_ended }),
494    )
495    .await?;
496    tx.commit().await?;
497    Ok(Some(IdpLogout {
498        user_id,
499        username,
500        sessions_ended,
501    }))
502}
503
504/// Creates a personal access token. The plaintext is returned exactly once.
505///
506/// # Invariants
507///
508/// * **Permission:** any signed-in actor; the token belongs to the actor
509///   and never carries more than the actor's rights; the scope only
510///   narrows them. A lifetime outside 1 to 3650 days is refused (400).
511/// * **Locks:** none.
512/// * **Data:** only the hash and a short prefix are stored; the plaintext
513///   is not recoverable later.
514/// * **Audit:** `token.create` (name, scope and expiry, never the token).
515/// * **Notifications / webhooks:** none.
516/// * **Atomic:** one transaction.
517pub async fn create_token(
518    pool: &PgPool,
519    actor: &Actor,
520    name: &str,
521    lifetime: TokenLifetime,
522    scope: TokenScope,
523) -> AppResult<(ApiToken, String)> {
524    let name = domain::require_text("Token name", name, 100)?;
525    let (days, idle_days) = match lifetime {
526        TokenLifetime::Unlimited => (None, None),
527        TokenLifetime::Days(d) => (Some(d), None),
528        TokenLifetime::IdleDays(d) => (Some(d), Some(d)),
529    };
530    if let Some(days) = days {
531        if !(1..=3650).contains(&days) {
532            return Err(AppError::BadRequest(
533                "Token lifetime must be between 1 and 3650 days.".into(),
534            ));
535        }
536    }
537    let token = auth::generate_token(auth::API_TOKEN_PREFIX);
538    let prefix: String = token
539        .chars()
540        .take(auth::API_TOKEN_PREFIX.len() + 6)
541        .collect();
542    let expires_at = days.map(|d| Utc::now() + chrono::Duration::days(d));
543    let mut tx = pool.begin().await?;
544    let record = sqlx::query_as::<_, ApiToken>(&format!(
545        "INSERT INTO api_tokens (id, user_id, name, token_hash, token_prefix, expires_at, scope, idle_days)
546         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
547         RETURNING {TOKEN_COLUMNS}"
548    ))
549    .bind(Uuid::new_v4())
550    .bind(actor.id)
551    .bind(&name)
552    .bind(auth::hash_token(&token))
553    .bind(&prefix)
554    .bind(expires_at)
555    .bind(scope.as_str())
556    .bind(idle_days.and_then(|d| i32::try_from(d).ok()))
557    .fetch_one(&mut *tx)
558    .await?;
559    audit::record(
560        &mut tx,
561        Some(actor),
562        "token.create",
563        "user",
564        Some(actor.id),
565        format!("Created API token '{name}' ({})", scope.label()),
566        json!({ "token_id": record.id, "expires_at": expires_at, "scope": scope.as_str(),
567                "idle_days": idle_days }),
568    )
569    .await?;
570    tx.commit().await?;
571    Ok((record, token))
572}
573
574/// Deletes an API token (its owner, or an administrator).
575///
576/// # Invariants
577///
578/// * **Permission:** the token is the actor's own or the actor is an
579///   administrator ("not found" otherwise).
580/// * **Locks:** none; the row is deleted in one statement.
581/// * **Audit:** `token.revoke`.
582/// * **Notifications / webhooks:** none.
583/// * **Atomic:** one transaction.
584pub async fn revoke_token(pool: &PgPool, actor: &Actor, token_id: Uuid) -> AppResult<()> {
585    let mut tx = pool.begin().await?;
586    let (name, owner) = sqlx::query_as::<_, (String, Uuid)>(
587        "DELETE FROM api_tokens WHERE id = $1 AND (user_id = $2 OR $3) RETURNING name, user_id",
588    )
589    .bind(token_id)
590    .bind(actor.id)
591    .bind(actor.is_admin())
592    .fetch_optional(&mut *tx)
593    .await?
594    .ok_or_else(|| AppError::not_found("API token not found."))?;
595    // The entry belongs to the token's owner – an admin revoking a leaver's
596    // token shows up in the leaver's history, not in the admin's own.
597    let summary = if owner == actor.id {
598        format!("Revoked API token '{name}'")
599    } else {
600        let username = sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = $1")
601            .bind(owner)
602            .fetch_one(&mut *tx)
603            .await?;
604        format!("Revoked API token '{name}' of {username}")
605    };
606    audit::record(
607        &mut tx,
608        Some(actor),
609        "token.revoke",
610        "user",
611        Some(owner),
612        summary,
613        json!({ "token_id": token_id }),
614    )
615    .await?;
616    tx.commit().await?;
617    Ok(())
618}
619
620// ---------------------------------------------------------------------------
621// Users
622// ---------------------------------------------------------------------------
623
624pub async fn count(pool: &PgPool) -> AppResult<i64> {
625    Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
626        .fetch_one(pool)
627        .await?)
628}
629
630pub async fn list(pool: &PgPool) -> AppResult<Vec<User>> {
631    Ok(sqlx::query_as::<_, User>(&format!(
632        "SELECT {USER_COLUMNS} FROM users ORDER BY active DESC, username"
633    ))
634    .fetch_all(pool)
635    .await?)
636}
637
638pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<User> {
639    sqlx::query_as::<_, User>(&format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"))
640        .bind(id)
641        .fetch_optional(pool)
642        .await?
643        .ok_or_else(|| AppError::not_found("User not found."))
644}
645
646pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult<Option<User>> {
647    Ok(sqlx::query_as::<_, User>(&format!(
648        "SELECT {USER_COLUMNS} FROM users WHERE username = $1"
649    ))
650    .bind(username.trim().to_ascii_lowercase())
651    .fetch_optional(pool)
652    .await?)
653}
654
655/// Active users that may be asked to review a change.
656pub async fn reviewer_candidates(pool: &PgPool) -> AppResult<Vec<User>> {
657    Ok(sqlx::query_as::<_, User>(&format!(
658        "SELECT {USER_COLUMNS} FROM users
659         WHERE active AND role IN ('editor', 'admin') ORDER BY display_name"
660    ))
661    .fetch_all(pool)
662    .await?)
663}
664
665#[derive(Clone, Debug)]
666pub struct NewLocalUser {
667    pub username: String,
668    pub display_name: String,
669    pub email: Option<String>,
670    pub role: Role,
671    pub password: String,
672}
673
674/// Creates a local (password) account. `actor = None` is used by the CLI and
675/// the bootstrap logic.
676///
677/// # Invariants
678///
679/// * **Permission:** administrator (403); without an actor (CLI, bootstrap)
680///   no check.
681/// * **Locks:** none; a taken username is refused by the unique index (409).
682/// * **Data:** the password is validated and stored only as a hash.
683/// * **Audit:** `user.create`.
684/// * **Notifications / webhooks:** none.
685/// * **Atomic:** one transaction.
686pub async fn create_local(
687    pool: &PgPool,
688    actor: Option<&Actor>,
689    input: NewLocalUser,
690) -> AppResult<User> {
691    if let Some(actor) = actor {
692        actor.require(Role::Admin)?;
693    }
694    let username = domain::normalize_username(&input.username)?;
695    let display_name = match input.display_name.trim() {
696        "" => username.clone(),
697        name => domain::limit_text("Display name", name, 128)?,
698    };
699    let email = clean_email(input.email.as_deref())?;
700    domain::validate_password(&input.password)?;
701    let hash = auth::hash_password_async(input.password).await?;
702
703    let mut tx = pool.begin().await?;
704    let user = sqlx::query_as::<_, User>(&format!(
705        "INSERT INTO users (id, username, display_name, email, auth_source, password_hash, role)
706         VALUES ($1, $2, $3, $4, 'local', $5, $6)
707         RETURNING {USER_COLUMNS}"
708    ))
709    .bind(Uuid::new_v4())
710    .bind(&username)
711    .bind(&display_name)
712    .bind(&email)
713    .bind(&hash)
714    .bind(input.role.as_str())
715    .fetch_one(&mut *tx)
716    .await
717    .map_err(|e| {
718        AppError::unique_violation(e, format!("The username '{username}' is already taken."))
719    })?;
720    audit::record(
721        &mut tx,
722        actor,
723        "user.create",
724        "user",
725        Some(user.id),
726        format!(
727            "Created local user '{}' with role {}",
728            user.username, user.role
729        ),
730        json!({ "role": user.role }),
731    )
732    .await?;
733    tx.commit().await?;
734    Ok(user)
735}
736
737/// Verifies username and password of a local account. Returns `None` for
738/// unknown users, wrong passwords, OIDC accounts and deactivated users.
739pub async fn authenticate_local(
740    pool: &PgPool,
741    username: &str,
742    password: &str,
743) -> AppResult<Option<User>> {
744    #[derive(sqlx::FromRow)]
745    struct Row {
746        id: Uuid,
747        password_hash: Option<String>,
748        active: bool,
749    }
750    let username = username.trim().to_ascii_lowercase();
751    let row = sqlx::query_as::<_, Row>(
752        "SELECT id, password_hash, active FROM users WHERE username = $1 AND auth_source = 'local'",
753    )
754    .bind(&username)
755    .fetch_optional(pool)
756    .await?;
757    let (id, hash, active) = match row {
758        Some(r) => (Some(r.id), r.password_hash, r.active),
759        None => (None, None, false),
760    };
761    let valid = auth::verify_password_async(password.to_string(), hash).await;
762    match id {
763        Some(id) if valid && active => Ok(Some(get(pool, id).await?)),
764        _ => Ok(None),
765    }
766}
767
768#[derive(Clone, Debug)]
769pub struct UserUpdate {
770    pub display_name: String,
771    pub email: Option<String>,
772    pub role: Role,
773    pub active: bool,
774}
775
776/// Updates a user (admin only). The role of OIDC users is managed through
777/// IdP groups and is overwritten at their next login.
778///
779/// # Invariants
780///
781/// * **Permission:** administrator (403); an unknown user is "not found".
782/// * **Locks:** the active administrators (`FOR UPDATE`, in id order), then
783///   the user's row. At least one active administrator remains: demoting or
784///   deactivating the last one is refused (409), also when two
785///   administrators remove each other at once (`tests/concurrency.rs`). The
786///   role sync of a single-sign-on login ([`upsert_oidc_user`]) follows the
787///   IdP groups and is not held to this rule; `teamcraft user create --role
788///   admin` adds an administrator again.
789/// * **Data:** deactivating a user deletes all their sessions.
790/// * **Audit:** `user.update`.
791/// * **Notifications / webhooks:** none.
792/// * **Atomic:** one transaction.
793pub async fn update(pool: &PgPool, actor: &Actor, id: Uuid, upd: UserUpdate) -> AppResult<User> {
794    actor.require(Role::Admin)?;
795    let display_name = domain::require_text("Display name", &upd.display_name, 128)?;
796    let email = clean_email(upd.email.as_deref())?;
797
798    let mut tx = pool.begin().await?;
799    // The active administrators first, locked in a fixed order: two updates
800    // that could each remove an administrator run one after the other, and
801    // the second counts without the first one's (no deadlock either, as
802    // every update takes these locks before the user's row).
803    let admins: Vec<Uuid> = sqlx::query_scalar(
804        "SELECT id FROM users WHERE role = 'admin' AND active ORDER BY id FOR UPDATE",
805    )
806    .fetch_all(&mut *tx)
807    .await?;
808    let current = sqlx::query_as::<_, User>(&format!(
809        "SELECT {USER_COLUMNS} FROM users WHERE id = $1 FOR UPDATE"
810    ))
811    .bind(id)
812    .fetch_optional(&mut *tx)
813    .await?
814    .ok_or_else(|| AppError::not_found("User not found."))?;
815
816    let was_admin = admins.contains(&id);
817    let stays_admin = upd.active && upd.role == Role::Admin;
818    if was_admin && !stays_admin && admins.len() == 1 {
819        return Err(AppError::Conflict(
820            "At least one active administrator must remain.".into(),
821        ));
822    }
823    let user = sqlx::query_as::<_, User>(&format!(
824        "UPDATE users SET display_name = $2, email = $3, role = $4, active = $5
825         WHERE id = $1 RETURNING {USER_COLUMNS}"
826    ))
827    .bind(id)
828    .bind(&display_name)
829    .bind(&email)
830    .bind(upd.role.as_str())
831    .bind(upd.active)
832    .fetch_one(&mut *tx)
833    .await?;
834    if !user.active {
835        sqlx::query("DELETE FROM sessions WHERE user_id = $1")
836            .bind(id)
837            .execute(&mut *tx)
838            .await?;
839    }
840    audit::record(
841        &mut tx,
842        Some(actor),
843        "user.update",
844        "user",
845        Some(id),
846        format!(
847            "Updated user '{}' (role {} → {}, active {} → {})",
848            user.username, current.role, user.role, current.active, user.active
849        ),
850        json!({ "role": user.role, "active": user.active }),
851    )
852    .await?;
853    tx.commit().await?;
854    Ok(user)
855}
856
857/// Sets the password of a local account. `actor = None` is used by the CLI.
858///
859/// # Invariants
860///
861/// * **Permission:** administrator (403); without an actor (CLI) no check.
862///   Single-sign-on accounts have no local password (400).
863/// * **Locks:** none.
864/// * **Data:** the password is stored only as a hash; all sessions of the
865///   user are deleted.
866/// * **Audit:** `user.password`.
867/// * **Notifications / webhooks:** none.
868/// * **Atomic:** one transaction.
869pub async fn set_password(
870    pool: &PgPool,
871    actor: Option<&Actor>,
872    id: Uuid,
873    password: &str,
874) -> AppResult<()> {
875    if let Some(actor) = actor {
876        actor.require(Role::Admin)?;
877    }
878    let user = get(pool, id).await?;
879    if user.auth_source != "local" {
880        return Err(AppError::BadRequest(
881            "This account signs in via single sign-on and has no local password.".into(),
882        ));
883    }
884    domain::validate_password(password)?;
885    let hash = auth::hash_password_async(password.to_string()).await?;
886    let mut tx = pool.begin().await?;
887    sqlx::query("UPDATE users SET password_hash = $2 WHERE id = $1")
888        .bind(id)
889        .bind(&hash)
890        .execute(&mut *tx)
891        .await?;
892    sqlx::query("DELETE FROM sessions WHERE user_id = $1")
893        .bind(id)
894        .execute(&mut *tx)
895        .await?;
896    audit::record(
897        &mut tx,
898        actor,
899        "user.password",
900        "user",
901        Some(id),
902        format!("Password of '{}' was reset", user.username),
903        json!({}),
904    )
905    .await?;
906    tx.commit().await?;
907    Ok(())
908}
909
910/// Changes the caller's own password after verifying the current one.
911///
912/// Signs out every other web session of the user (a session stolen before
913/// the change must not outlive it); `keep_session` is the token of the
914/// session that made the change. Personal access tokens stay: they are
915/// separate credentials the user revokes one by one.
916///
917/// Wrong current passwords count on the same per-username counter of
918/// `throttle` as failed sign-ins (whoever holds a stolen session or token
919/// must not guess the password without a limit); a full counter refuses the
920/// attempt before the password is checked. The web form and
921/// `POST /api/v1/me/password` both come here, so neither path is unthrottled.
922///
923/// # Invariants
924///
925/// * **Permission:** a local account (single sign-on accounts have no
926///   password, 400, not counted); not throttled (429 with the wait, also for
927///   the right password); the current password must be right (400, counted);
928///   the new one must pass the password rules (400).
929/// * **Locks:** none.
930/// * **Data:** the password is stored only as a hash; every web session of
931///   the user except `keep_session` is deleted, personal access tokens stay.
932///   A right current password clears the username's counter.
933/// * **Audit:** `user.password` (with the number of sessions ended);
934///   a wrong current password writes `user.password_failed` (never a
935///   password).
936/// * **Notifications / webhooks:** none.
937/// * **Atomic:** one transaction.
938pub async fn change_own_password(
939    pool: &PgPool,
940    throttle: &auth::SignInThrottle,
941    actor: &Actor,
942    current: &str,
943    new: &str,
944    keep_session: Option<&str>,
945) -> AppResult<()> {
946    if get(pool, actor.id).await?.auth_source != "local" {
947        return Err(AppError::BadRequest(
948            "This account signs in via single sign-on and has no local password.".into(),
949        ));
950    }
951    let keys = auth::SignInThrottle::keys(&actor.username, None);
952    if let Some(wait) = throttle.blocked(&keys) {
953        let minutes = wait.as_secs().div_ceil(60).max(1);
954        tracing::warn!(username = %actor.username, "own password change throttled");
955        return Err(AppError::TooManyRequests {
956            message: format!(
957                "Too many failed attempts. Try again in {minutes} minute{}.",
958                if minutes == 1 { "" } else { "s" }
959            ),
960            retry_after: wait,
961        });
962    }
963    if authenticate_local(pool, &actor.username, current)
964        .await?
965        .is_none()
966    {
967        throttle.failed(&keys);
968        record_failed_password_change(pool, actor).await?;
969        return Err(AppError::BadRequest(
970            "The current password is wrong.".into(),
971        ));
972    }
973    throttle.succeeded(&actor.username);
974    domain::validate_password(new)?;
975    let hash = auth::hash_password_async(new.to_string()).await?;
976    let mut tx = pool.begin().await?;
977    sqlx::query("UPDATE users SET password_hash = $2 WHERE id = $1")
978        .bind(actor.id)
979        .bind(&hash)
980        .execute(&mut *tx)
981        .await?;
982    let ended =
983        sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND token_hash IS DISTINCT FROM $2")
984            .bind(actor.id)
985            .bind(keep_session.map(auth::hash_token))
986            .execute(&mut *tx)
987            .await?
988            .rows_affected();
989    audit::record(
990        &mut tx,
991        Some(actor),
992        "user.password",
993        "user",
994        Some(actor.id),
995        "Changed own password",
996        json!({ "other_sessions_ended": ended }),
997    )
998    .await?;
999    tx.commit().await?;
1000    Ok(())
1001}
1002
1003/// How long a fresh single sign-on counts as the sign-off of a review
1004/// decision (`TEAMCRAFT_APPROVAL_REAUTH`).
1005pub const REAUTH_WINDOW_MINUTES: i64 = 10;
1006
1007/// Confirms the actor's own password for a sign-off (#152 step 4).
1008///
1009/// # Invariants
1010///
1011/// * **Permission:** a local account (single sign-on accounts sign in again
1012///   instead, 400); not throttled (429 with the wait); the password must be
1013///   right (400, counted like a failed sign-in).
1014/// * **Audit:** a wrong password writes `user.password_failed` (never the
1015///   password); a right one writes nothing (the decision records the method).
1016pub async fn confirm_own_password(
1017    pool: &PgPool,
1018    throttle: &auth::SignInThrottle,
1019    actor: &Actor,
1020    password: &str,
1021) -> AppResult<()> {
1022    if get(pool, actor.id).await?.auth_source != "local" {
1023        return Err(AppError::BadRequest(
1024            "This account signs in via single sign-on: confirm by signing in again.".into(),
1025        ));
1026    }
1027    let keys = auth::SignInThrottle::keys(&actor.username, None);
1028    if let Some(wait) = throttle.blocked(&keys) {
1029        let minutes = wait.as_secs().div_ceil(60).max(1);
1030        return Err(AppError::TooManyRequests {
1031            message: format!(
1032                "Too many failed attempts. Try again in {minutes} minute{}.",
1033                if minutes == 1 { "" } else { "s" }
1034            ),
1035            retry_after: wait,
1036        });
1037    }
1038    if authenticate_local(pool, &actor.username, password)
1039        .await?
1040        .is_none()
1041    {
1042        throttle.failed(&keys);
1043        record_failed_password_change(pool, actor).await?;
1044        return Err(AppError::BadRequest("The password is wrong.".into()));
1045    }
1046    throttle.succeeded(&actor.username);
1047    Ok(())
1048}
1049
1050/// Whether the web session `token` of the user signed in again with single
1051/// sign-on within the last [`REAUTH_WINDOW_MINUTES`].
1052pub async fn session_recently_reauthenticated(
1053    pool: &PgPool,
1054    user_id: Uuid,
1055    token: &str,
1056) -> AppResult<bool> {
1057    Ok(sqlx::query_scalar::<_, bool>(
1058        "SELECT EXISTS(SELECT 1 FROM sessions
1059                       WHERE token_hash = $1 AND user_id = $2 AND expires_at > now()
1060                         AND reauth_at > now() - make_interval(mins => $3::int))",
1061    )
1062    .bind(auth::hash_token(token))
1063    .bind(user_id)
1064    .bind(REAUTH_WINDOW_MINUTES as i32)
1065    .fetch_one(pool)
1066    .await?)
1067}
1068
1069/// Marks the web session (by the hash of its token) of `user_id` as signed
1070/// in again just now. False when the session is not the user's (or gone).
1071pub async fn mark_session_reauthenticated(
1072    pool: &PgPool,
1073    user_id: Uuid,
1074    token_hash: &str,
1075) -> AppResult<bool> {
1076    let me = actor(pool, user_id).await?;
1077    let mut tx = pool.begin().await?;
1078    let marked = sqlx::query(
1079        "UPDATE sessions SET reauth_at = now()
1080         WHERE token_hash = $1 AND user_id = $2 AND expires_at > now()",
1081    )
1082    .bind(token_hash)
1083    .bind(user_id)
1084    .execute(&mut *tx)
1085    .await?
1086    .rows_affected()
1087        == 1;
1088    if marked {
1089        audit::record(
1090            &mut tx,
1091            Some(&me),
1092            "user.reauthenticate",
1093            "user",
1094            Some(user_id),
1095            "Signed in again with single sign-on to confirm a decision",
1096            json!({}),
1097        )
1098        .await?;
1099    }
1100    tx.commit().await?;
1101    Ok(marked)
1102}
1103
1104/// Records a wrong current password of the own password change
1105/// (`user.password_failed`), like a failed sign-in: the account, never what
1106/// was typed.
1107async fn record_failed_password_change(pool: &PgPool, actor: &Actor) -> AppResult<()> {
1108    tracing::warn!(username = %actor.username, "wrong current password at a password change");
1109    let mut tx = pool.begin().await?;
1110    audit::record(
1111        &mut tx,
1112        Some(actor),
1113        "user.password_failed",
1114        "user",
1115        Some(actor.id),
1116        "Wrong current password at a password change",
1117        json!({}),
1118    )
1119    .await?;
1120    tx.commit().await?;
1121    Ok(())
1122}
1123
1124/// Identity asserted by the OIDC provider after a successful login.
1125#[derive(Clone, Debug)]
1126pub struct OidcIdentity {
1127    pub subject: String,
1128    pub username_hint: String,
1129    pub display_name: String,
1130    pub email: Option<String>,
1131    pub role: Role,
1132    /// All IdP groups (used to synchronise project memberships).
1133    pub groups: Vec<String>,
1134    /// The claims of the sign-in as name → values; only those that a
1135    /// need-to-know group's conditions name are kept
1136    /// (`membership_conditions::store_claims`).
1137    pub claims: std::collections::BTreeMap<String, Vec<String>>,
1138}
1139
1140/// Finds or provisions the user for an OIDC identity and synchronises name,
1141/// e-mail and role (derived from groups) on every login.
1142///
1143/// # Invariants
1144///
1145/// * **Permission:** a deactivated user is refused (403).
1146/// * **Locks:** the existing user row (`FOR UPDATE`).
1147/// * **Data:** the role always follows the IdP groups; a new user gets a free
1148///   username (suffix `-2` to `-99`, else 409). Memberships from the IdP are
1149///   added and dropped to match the groups; manual ones are kept. The claims
1150///   that a need-to-know group's conditions name replace the stored ones
1151///   (`users.idp_claims`); other claims are not kept.
1152/// * **Audit:** `user.provision` for a new user, `user.role_sync` when the
1153///   role changed.
1154/// * **Notifications / webhooks:** none.
1155/// * **Atomic:** one transaction.
1156pub async fn upsert_oidc_user(pool: &PgPool, ident: OidcIdentity) -> AppResult<User> {
1157    let email = clean_email(ident.email.as_deref()).unwrap_or(None);
1158    let display_name: String = ident.display_name.trim().chars().take(128).collect();
1159    let mut tx = pool.begin().await?;
1160
1161    let existing = sqlx::query_as::<_, User>(&format!(
1162        "SELECT {USER_COLUMNS} FROM users WHERE oidc_subject = $1 FOR UPDATE"
1163    ))
1164    .bind(&ident.subject)
1165    .fetch_optional(&mut *tx)
1166    .await?;
1167
1168    let user = if let Some(existing) = existing {
1169        if !existing.active {
1170            return Err(AppError::Forbidden(
1171                "Your TeamCraft account has been deactivated by an administrator.".into(),
1172            ));
1173        }
1174        let user = sqlx::query_as::<_, User>(&format!(
1175            "UPDATE users SET display_name = $2, email = $3, role = $4, last_login_at = now()
1176             WHERE id = $1 RETURNING {USER_COLUMNS}"
1177        ))
1178        .bind(existing.id)
1179        .bind(if display_name.is_empty() {
1180            &existing.display_name
1181        } else {
1182            &display_name
1183        })
1184        .bind(&email)
1185        .bind(ident.role.as_str())
1186        .fetch_one(&mut *tx)
1187        .await?;
1188        if existing.role != user.role {
1189            audit::record(
1190                &mut tx,
1191                None,
1192                "user.role_sync",
1193                "user",
1194                Some(user.id),
1195                format!(
1196                    "Role of '{}' changed from {} to {} (IdP groups)",
1197                    user.username, existing.role, user.role
1198                ),
1199                json!({ "role": user.role }),
1200            )
1201            .await?;
1202        }
1203        user
1204    } else {
1205        let base = username_from_hint(&ident.username_hint);
1206        let mut username = base.clone();
1207        let mut suffix = 2;
1208        loop {
1209            let taken = sqlx::query_scalar::<_, bool>(
1210                "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",
1211            )
1212            .bind(&username)
1213            .fetch_one(&mut *tx)
1214            .await?;
1215            if !taken {
1216                break;
1217            }
1218            if suffix > 99 {
1219                return Err(AppError::Conflict(format!(
1220                    "Could not find a free username for '{base}'."
1221                )));
1222            }
1223            username = format!("{base}-{suffix}");
1224            suffix += 1;
1225        }
1226        let user = sqlx::query_as::<_, User>(&format!(
1227            "INSERT INTO users (id, username, display_name, email, auth_source, oidc_subject, role, last_login_at)
1228             VALUES ($1, $2, $3, $4, 'oidc', $5, $6, now())
1229             RETURNING {USER_COLUMNS}"
1230        ))
1231        .bind(Uuid::new_v4())
1232        .bind(&username)
1233        .bind(if display_name.is_empty() { &username } else { &display_name })
1234        .bind(&email)
1235        .bind(&ident.subject)
1236        .bind(ident.role.as_str())
1237        .fetch_one(&mut *tx)
1238        .await?;
1239        audit::record(
1240            &mut tx,
1241            None,
1242            "user.provision",
1243            "user",
1244            Some(user.id),
1245            format!(
1246                "Provisioned SSO user '{}' with role {}",
1247                user.username, user.role
1248            ),
1249            json!({ "role": user.role }),
1250        )
1251        .await?;
1252        user
1253    };
1254    sync_oidc_memberships(&mut tx, user.id, &ident.groups).await?;
1255    crate::services::membership_conditions::store_claims(&mut tx, user.id, &ident.claims).await?;
1256    tx.commit().await?;
1257    Ok(user)
1258}
1259
1260/// Adds/removes the 'oidc' memberships of a user according to the IdP groups
1261/// mapped to projects (`projects.oidc_group`). Manual memberships are kept.
1262async fn sync_oidc_memberships(
1263    conn: &mut sqlx::PgConnection,
1264    user_id: Uuid,
1265    groups: &[String],
1266) -> AppResult<()> {
1267    let groups: Vec<String> = groups.iter().map(|g| g.to_ascii_lowercase()).collect();
1268    sqlx::query(
1269        "INSERT INTO project_members (project_id, user_id, source)
1270         SELECT p.id, $1, 'oidc' FROM projects p
1271         WHERE p.oidc_group IS NOT NULL AND lower(p.oidc_group) = ANY($2)
1272         ON CONFLICT (project_id, user_id) DO NOTHING",
1273    )
1274    .bind(user_id)
1275    .bind(&groups)
1276    .execute(&mut *conn)
1277    .await?;
1278    sqlx::query(
1279        "DELETE FROM project_members pm USING projects p
1280         WHERE pm.project_id = p.id AND pm.user_id = $1 AND pm.source = 'oidc'
1281           AND (p.oidc_group IS NULL OR NOT (lower(p.oidc_group) = ANY($2)))",
1282    )
1283    .bind(user_id)
1284    .bind(&groups)
1285    .execute(&mut *conn)
1286    .await?;
1287    Ok(())
1288}
1289
1290/// Turns an IdP username (or e-mail address) into a valid TeamCraft username.
1291pub fn username_from_hint(hint: &str) -> String {
1292    let local_part = hint.split('@').next().unwrap_or(hint);
1293    let mut out: String = local_part
1294        .trim()
1295        .to_ascii_lowercase()
1296        .chars()
1297        .map(|c| {
1298            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
1299                c
1300            } else {
1301                '.'
1302            }
1303        })
1304        .take(60)
1305        .collect();
1306    out = out.trim_matches('.').to_string();
1307    if out.len() < 2 {
1308        out = "user".to_string();
1309    }
1310    out
1311}
1312
1313/// Creates the bootstrap administrator if the database contains no users.
1314/// First start without any account: creates the administrator (the
1315/// "create the administrator" form of the login page). Concurrent attempts
1316/// are serialised with an advisory lock; once an account exists it fails.
1317pub async fn create_first_admin(
1318    pool: &PgPool,
1319    username: &str,
1320    display_name: &str,
1321    password: &str,
1322) -> AppResult<User> {
1323    const SETUP_LOCK: i64 = 0x7465_616d_6372; // "teamcr"
1324    let mut guard = pool.acquire().await?;
1325    sqlx::query("SELECT pg_advisory_lock($1)")
1326        .bind(SETUP_LOCK)
1327        .execute(&mut *guard)
1328        .await?;
1329    let result = async {
1330        if count(pool).await? > 0 {
1331            return Err(AppError::Conflict(
1332                "TeamCraft already has accounts – please sign in.".into(),
1333            ));
1334        }
1335        let display_name = match display_name.trim() {
1336            "" => "Administrator",
1337            name => name,
1338        };
1339        let user = create_local(
1340            pool,
1341            None,
1342            NewLocalUser {
1343                username: username.into(),
1344                display_name: display_name.into(),
1345                email: None,
1346                role: Role::Admin,
1347                password: password.into(),
1348            },
1349        )
1350        .await?;
1351        tracing::info!(username = %user.username, "created the first administrator (setup page)");
1352        Ok(user)
1353    }
1354    .await;
1355    sqlx::query("SELECT pg_advisory_unlock($1)")
1356        .bind(SETUP_LOCK)
1357        .execute(&mut *guard)
1358        .await?;
1359    result
1360}
1361
1362pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &Config) -> AppResult<()> {
1363    if count(pool).await? > 0 {
1364        return Ok(());
1365    }
1366    match &config.bootstrap_admin {
1367        Some(admin) => {
1368            if !config.local_login {
1369                tracing::warn!(
1370                    "TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD is set but local login is disabled; the account can only be used after enabling TEAMCRAFT_LOCAL_LOGIN"
1371                );
1372            }
1373            let user = create_local(
1374                pool,
1375                None,
1376                NewLocalUser {
1377                    username: admin.username.clone(),
1378                    display_name: "Administrator".into(),
1379                    email: None,
1380                    role: Role::Admin,
1381                    password: admin.password.clone(),
1382                },
1383            )
1384            .await?;
1385            tracing::info!(username = %user.username, "created bootstrap administrator");
1386        }
1387        None if config.oidc.is_none() && config.bind_addr.ip().is_loopback() => tracing::info!(
1388            "the database has no users yet: open {} and create the administrator",
1389            config.public_url
1390        ),
1391        None if config.oidc.is_none() => tracing::warn!(
1392            "the database has no users; set TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD or run `teamcraft user create --role admin`"
1393        ),
1394        None => tracing::info!("the database has no users yet; the first SSO login provisions an account"),
1395    }
1396    Ok(())
1397}
1398
1399fn clean_email(email: Option<&str>) -> AppResult<Option<String>> {
1400    match email.map(str::trim).filter(|e| !e.is_empty()) {
1401        None => Ok(None),
1402        Some(e) if e.len() <= 254 && e.contains('@') && !e.contains(char::is_whitespace) => {
1403            Ok(Some(e.to_string()))
1404        }
1405        Some(e) => Err(AppError::BadRequest(format!(
1406            "'{e}' is not a valid e-mail address."
1407        ))),
1408    }
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414
1415    #[test]
1416    fn usernames_from_idp_hints() {
1417        assert_eq!(username_from_hint("Max.Mustermann"), "max.mustermann");
1418        assert_eq!(username_from_hint("max@example.org"), "max");
1419        assert_eq!(username_from_hint("Jane Doe"), "jane.doe");
1420        assert_eq!(username_from_hint("ä"), "user");
1421        assert_eq!(username_from_hint(""), "user");
1422        assert!(domain::normalize_username(&username_from_hint("Ünïcødé Ñame")).is_ok());
1423        assert!(username_from_hint(&"x".repeat(200)).len() <= 60);
1424    }
1425
1426    #[test]
1427    fn email_cleanup() {
1428        assert_eq!(clean_email(None).unwrap(), None);
1429        assert_eq!(clean_email(Some("  ")).unwrap(), None);
1430        assert_eq!(
1431            clean_email(Some(" a@b.de ")).unwrap().as_deref(),
1432            Some("a@b.de")
1433        );
1434        assert!(clean_email(Some("not-an-email")).is_err());
1435    }
1436
1437    #[test]
1438    fn the_sso_idle_limit_caps_token_lifetimes() {
1439        use TokenLifetime::*;
1440        // off: everything as asked
1441        assert_eq!(Unlimited.capped(0), Unlimited);
1442        assert_eq!(Days(400).capped(0), Days(400));
1443        // no expiry or a longer one becomes the idle limit
1444        assert_eq!(Unlimited.capped(30), IdleDays(30));
1445        assert_eq!(Days(31).capped(30), IdleDays(30));
1446        assert_eq!(IdleDays(90).capped(30), IdleDays(30));
1447        // a shorter or equal lifetime stays
1448        assert_eq!(Days(30).capped(30), Days(30));
1449        assert_eq!(Days(7).capped(30), Days(7));
1450        assert_eq!(IdleDays(14).capped(30), IdleDays(14));
1451    }
1452}