Skip to main content

isb_server/auth/
mod.rs

1//! Identity for `isb serve`: users, org memberships and roles, browser
2//! sessions, invitations, API tokens and password resets, in SQLite at
3//! `<state>/isb.db`.
4//!
5//! - Orgs are the trust boundary. A user is a member of an org with a
6//!   [`Role`]; what a role may do is the table in [`Role::permissions`], so
7//!   roles can be refined without touching the callers. A platform admin
8//!   spans orgs.
9//! - Every bearer secret (session, API token, invitation, reset) is 32 random
10//!   bytes shown once; only its SHA-256 is stored ([`secret`]).
11//! - Passwords are argon2id. Login failures never say which half was wrong,
12//!   cost the same either way, and are rate-limited per email and per IP.
13//! - The HTTP endpoints are in [`http`]; the CLI uses this API directly on the
14//!   same file (SQLite in WAL mode handles the daemon and the CLI at once).
15//!
16//! External sign-in ([`oauth`]) attaches rows to `user_identities`; passkeys
17//! ([`webauthn`]) live in `passkeys`; [`external`] manages both.
18
19mod actors;
20pub mod agent_identities;
21pub mod cbor;
22pub mod db;
23pub mod edge;
24pub mod external;
25pub mod http;
26pub mod limit;
27pub mod oauth;
28pub mod oidc;
29pub mod ops;
30pub mod secret;
31mod setup;
32pub mod ssh_keys;
33pub mod superadmin;
34pub mod webauthn;
35
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
38use std::time::Duration;
39
40use rusqlite::{Connection, OptionalExtension, Row, TransactionBehavior, params};
41use serde::{Deserialize, Serialize};
42
43use crate::org::OrgId;
44pub use actors::WORKSPACE_ACTOR;
45use limit::{Rate, RateLimiter};
46use secret::{PasswordCost, TokenKind};
47pub use superadmin::{Superadmin, SuperadminSource, SuperadminToken};
48
49/// What the identity store can fail with. [`AuthError::InvalidCredentials`]
50/// is deliberately vague: it is the answer to every failed login.
51#[derive(Debug, thiserror::Error)]
52pub enum AuthError {
53    #[error("invalid email or password")]
54    InvalidCredentials,
55    /// A presented invitation or reset token that is unknown, used or expired.
56    #[error("{0} is invalid or has expired")]
57    InvalidToken(&'static str),
58    #[error("too many attempts; try again in {retry_after}s")]
59    RateLimited { retry_after: u64 },
60    #[error("{0}")]
61    Forbidden(String),
62    #[error("{0} not found")]
63    NotFound(String),
64    #[error("{0}")]
65    Conflict(String),
66    #[error("{0}")]
67    Invalid(String),
68    #[error("{0}")]
69    Internal(String),
70    /// An external sign-in that proved who the user is, but may not go on
71    /// (no verified email, no invitation, a disabled account). `code` is
72    /// stable, for the login page.
73    #[error("{message}")]
74    Refused { code: &'static str, message: String },
75    /// A passkey assertion or registration that did not verify.
76    #[error("passkey rejected: {0}")]
77    PasskeyRejected(String),
78    #[error("identity database: {0}")]
79    Db(#[from] rusqlite::Error),
80}
81
82impl AuthError {
83    pub(crate) fn io(step: String, e: std::io::Error) -> Self {
84        AuthError::Internal(format!("{step}: {e}"))
85    }
86}
87
88impl From<AuthError> for crate::Error {
89    fn from(e: AuthError) -> Self {
90        match e {
91            AuthError::NotFound(s) => crate::Error::NotFound(s),
92            e => crate::Error::Invalid(e.to_string()),
93        }
94    }
95}
96
97pub type AuthResult<T> = std::result::Result<T, AuthError>;
98
99/// A role in an org. Ordered by reach: an actor may grant roles up to its own.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum Role {
103    /// Reads the org: lists and inspects, no secret values, no deploys, no
104    /// exec or terminal.
105    Viewer,
106    Member,
107    Admin,
108    Owner,
109}
110
111/// Something a role allows within its org.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum Permission {
114    /// List and inspect what is in the org (read-only tools), never secret
115    /// values.
116    ReadOrg,
117    /// Apps, stacks, sandboxes, secrets (read included), deploys, exec.
118    AdminOrg,
119    /// Add, change and remove members; invitations; every token in the org.
120    ManageMembers,
121    /// Delete the org.
122    DeleteOrg,
123}
124
125impl Role {
126    pub const ALL: [Role; 4] = [Role::Viewer, Role::Member, Role::Admin, Role::Owner];
127
128    /// What each role may do. The org is the boundary, so every member
129    /// administers what is in it; a viewer only reads.
130    pub fn permissions(self) -> &'static [Permission] {
131        use Permission::*;
132        match self {
133            Role::Viewer => &[ReadOrg],
134            Role::Member => &[ReadOrg, AdminOrg],
135            Role::Admin => &[ReadOrg, AdminOrg, ManageMembers],
136            Role::Owner => &[ReadOrg, AdminOrg, ManageMembers, DeleteOrg],
137        }
138    }
139
140    pub fn can(self, p: Permission) -> bool {
141        self.permissions().contains(&p)
142    }
143
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Role::Viewer => "viewer",
147            Role::Member => "member",
148            Role::Admin => "admin",
149            Role::Owner => "owner",
150        }
151    }
152
153    pub fn parse(s: &str) -> AuthResult<Role> {
154        Role::ALL
155            .into_iter()
156            .find(|r| r.as_str() == s)
157            .ok_or_else(|| {
158                AuthError::Invalid(format!("role {s:?}: owner, admin, member or viewer"))
159            })
160    }
161}
162
163impl std::fmt::Display for Role {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.as_str())
166    }
167}
168
169/// A user, without the password hash (which never leaves the store).
170#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
171pub struct User {
172    pub id: i64,
173    pub email: String,
174    pub name: String,
175    pub platform_admin: bool,
176    pub created_at: i64,
177    pub disabled: bool,
178    /// False for a user who signs in only through an external identity.
179    pub has_password: bool,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183pub struct Membership {
184    pub org: OrgId,
185    pub role: Role,
186}
187
188/// A browser session. `expires_at` is the absolute limit; it also ends when
189/// unused for the configured idle time ([`Session::idle_expires_at`]).
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
191pub struct Session {
192    pub id: i64,
193    pub user_id: i64,
194    pub created_at: i64,
195    pub last_seen: i64,
196    pub expires_at: i64,
197    pub idle_expires_at: i64,
198    pub user_agent: Option<String>,
199    pub ip: Option<String>,
200}
201
202/// A session just created: the token is in hand only now.
203#[derive(Debug, Clone)]
204pub struct NewSession {
205    pub token: String,
206    pub session: Session,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210pub struct Invitation {
211    pub id: i64,
212    pub org: OrgId,
213    pub email: String,
214    pub role: Role,
215    /// `None` when made by the local CLI (or the inviter was deleted).
216    pub invited_by: Option<i64>,
217    pub created_at: i64,
218    pub expires_at: i64,
219    pub accepted_at: Option<i64>,
220}
221
222#[derive(Debug, Clone)]
223pub struct NewInvitation {
224    pub token: String,
225    pub invitation: Invitation,
226}
227
228/// What accepting an invitation did.
229#[derive(Debug, Clone)]
230pub struct Accepted {
231    pub user: User,
232    pub membership: Membership,
233    /// True when the invitation created the account.
234    pub created: bool,
235}
236
237/// An API token's metadata.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
239pub struct ApiToken {
240    pub id: i64,
241    pub name: String,
242    pub user_id: i64,
243    /// `None`: a platform token, acting with all of its (platform admin)
244    /// owner's access. `Some`: confined to that org.
245    pub org: Option<OrgId>,
246    pub created_at: i64,
247    pub last_used: Option<i64>,
248    pub expires_at: Option<i64>,
249    /// Restrictions on top of the role ([`Scope`]); empty: the role's
250    /// whole reach.
251    pub scopes: Vec<String>,
252}
253
254#[derive(Debug, Clone)]
255pub struct NewApiToken {
256    pub token: String,
257    pub info: ApiToken,
258}
259
260/// How a principal authenticated.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
262#[serde(tag = "kind", rename_all = "snake_case")]
263pub enum PrincipalKind {
264    Session {
265        id: i64,
266    },
267    ApiToken {
268        id: i64,
269        org: Option<OrgId>,
270        /// The token's name, for audit rows.
271        #[serde(default)]
272        name: String,
273        /// Empty: the role's whole reach.
274        #[serde(default, skip_serializing_if = "Vec::is_empty")]
275        scopes: Vec<String>,
276    },
277    /// A Cloudflare Access identity whose email is this user's.
278    Access,
279    /// A superadmin ([`superadmin`]): a superadmin token or a tailnet
280    /// identity, acting as this (possibly synthetic) user.
281    Superadmin {
282        source: SuperadminSource,
283    },
284    /// An org's workspace (docs/concepts/workspaces.md): its token (`isb_ws_...`),
285    /// held by the agents that live in it. Nobody's account: a synthetic
286    /// principal confined to `org` with the role the workspace was given.
287    Workspace {
288        org: OrgId,
289        /// The workspace's name in its org.
290        name: String,
291    },
292    /// A tailnet or Cloudflare Access caller an org mapped to a role
293    /// ([`agent_identities`]): `label` is `tailnet:<login or node>` or
294    /// `access:<name>`. Nobody's account, confined to the orgs that mapped it.
295    Agent {
296        label: String,
297    },
298}
299
300/// An authenticated caller: who, how, and what they may reach. For an
301/// org-scoped token, `orgs` is that one org and `platform_admin` is false
302/// whatever the user is.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct Principal {
305    pub user: User,
306    pub kind: PrincipalKind,
307    pub orgs: Vec<(OrgId, Role)>,
308    pub platform_admin: bool,
309    /// Set on an org-bound endpoint for a superadmin or platform admin: an admin of this org only.
310    pub downscoped: Option<OrgId>,
311}
312
313impl Principal {
314    pub fn is_platform_admin(&self) -> bool {
315        self.platform_admin
316    }
317
318    pub fn role_in(&self, org: &OrgId) -> Option<Role> {
319        self.orgs.iter().find(|(o, _)| o == org).map(|(_, r)| *r)
320    }
321
322    fn can(&self, org: &OrgId, p: Permission) -> bool {
323        self.platform_admin || self.role_in(org).is_some_and(|r| r.can(p))
324    }
325
326    /// Apps, stacks, sandboxes and secrets in `org`.
327    pub fn can_admin_org(&self, org: &OrgId) -> bool {
328        self.can(org, Permission::AdminOrg)
329    }
330
331    /// Read-only tools in `org`.
332    pub fn can_read_org(&self, org: &OrgId) -> bool {
333        self.can(org, Permission::ReadOrg)
334    }
335
336    /// Members, invitations and every token in `org`.
337    pub fn can_manage_members(&self, org: &OrgId) -> bool {
338        self.can(org, Permission::ManageMembers)
339    }
340
341    pub fn can_delete_org(&self, org: &OrgId) -> bool {
342        self.can(org, Permission::DeleteOrg)
343    }
344
345    /// The highest role this principal may hand out in `org`.
346    pub fn max_grant(&self, org: &OrgId) -> Option<Role> {
347        if self.platform_admin {
348            return Some(Role::Owner);
349        }
350        self.role_in(org)
351            .filter(|r| r.can(Permission::ManageMembers))
352    }
353
354    pub fn session_id(&self) -> Option<i64> {
355        match self.kind {
356            PrincipalKind::Session { id } => Some(id),
357            PrincipalKind::ApiToken { .. }
358            | PrincipalKind::Access
359            | PrincipalKind::Superadmin { .. }
360            | PrincipalKind::Agent { .. }
361            | PrincipalKind::Workspace { .. } => None,
362        }
363    }
364}
365
366/// Where a login came from, kept on the session for the user to review.
367#[derive(Debug, Clone, Default)]
368pub struct LoginMeta {
369    pub user_agent: Option<String>,
370    pub ip: Option<String>,
371}
372
373/// Lifetimes, cost and rate limits.
374#[derive(Debug, Clone)]
375pub struct AuthConfig {
376    /// A session ends this long after login, used or not.
377    pub session_max_age: Duration,
378    /// A session ends after this long unused.
379    pub session_idle: Duration,
380    pub invitation_ttl: Duration,
381    pub reset_ttl: Duration,
382    pub password_cost: PasswordCost,
383    /// Login attempts per email.
384    pub login_per_email: Rate,
385    /// Unauthenticated attempts (login, setup, invitations, resets) per IP.
386    pub attempts_per_ip: Rate,
387    /// Password reset requests per email.
388    pub resets_per_email: Rate,
389}
390
391impl Default for AuthConfig {
392    fn default() -> Self {
393        AuthConfig {
394            session_max_age: Duration::from_secs(30 * 86400),
395            session_idle: Duration::from_secs(7 * 86400),
396            invitation_ttl: Duration::from_secs(7 * 86400),
397            reset_ttl: Duration::from_secs(3600),
398            password_cost: PasswordCost::default(),
399            login_per_email: Rate::new(5, 60),
400            attempts_per_ip: Rate::new(20, 6),
401            resets_per_email: Rate::new(3, 900),
402        }
403    }
404}
405
406/// `last_seen`/`last_used` are written at most this often per session or token.
407const TOUCH_EVERY: i64 = 60;
408
409pub type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
410
411/// The identity store. Cheap to share behind an `Arc`; one connection behind
412/// a mutex (requests are short, and argon2 runs outside the lock).
413pub struct AuthStore {
414    conn: Mutex<Connection>,
415    path: Option<PathBuf>,
416    cfg: AuthConfig,
417    clock: Clock,
418    per_email: RateLimiter,
419    per_ip: RateLimiter,
420    resets: RateLimiter,
421    dummy: OnceLock<String>,
422}
423
424impl std::fmt::Debug for AuthStore {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        f.debug_struct("AuthStore")
427            .field("path", &self.path)
428            .finish()
429    }
430}
431
432/// The identity database under a daemon state directory.
433pub fn db_path(state_dir: &Path) -> PathBuf {
434    state_dir.join("isb.db")
435}
436
437fn unix_now() -> i64 {
438    std::time::SystemTime::now()
439        .duration_since(std::time::UNIX_EPOCH)
440        .map(|d| d.as_secs() as i64)
441        .unwrap_or(0)
442}
443
444fn secs(d: Duration) -> i64 {
445    d.as_secs().min(i64::MAX as u64 / 2) as i64
446}
447
448/// Trimmed, lowercased, and plausibly an address. Not RFC 5322: the address
449/// is a login name and an invitation target, not something isb parses.
450pub fn normalize_email(email: &str) -> AuthResult<String> {
451    let e = email.trim().to_lowercase();
452    let ok = e.len() <= 254
453        && e.split_once('@').is_some_and(|(l, d)| {
454            !l.is_empty() && !d.is_empty() && !d.contains('@') && !d.starts_with('.')
455        })
456        && !e.chars().any(|c| c.is_whitespace() || c.is_control());
457    if ok {
458        Ok(e)
459    } else {
460        Err(AuthError::Invalid(format!(
461            "{email:?} is not an email address"
462        )))
463    }
464}
465
466pub(crate) fn clean_name(name: &str) -> AuthResult<String> {
467    let n = name.trim();
468    if n.chars().count() > 100 || n.chars().any(char::is_control) {
469        return Err(AuthError::Invalid(
470            "name: at most 100 characters, no control characters".into(),
471        ));
472    }
473    Ok(n.to_string())
474}
475
476fn clip(s: Option<String>, n: usize) -> Option<String> {
477    s.map(|s| s.chars().filter(|c| !c.is_control()).take(n).collect())
478}
479
480pub(crate) const USER_COLS: &str = "u.id, u.email, u.name, u.platform_admin, u.created_at, u.disabled, u.password_hash IS NOT NULL";
481
482pub(crate) fn user_row(r: &Row, at: usize) -> rusqlite::Result<User> {
483    Ok(User {
484        id: r.get(at)?,
485        email: r.get(at + 1)?,
486        name: r.get(at + 2)?,
487        platform_admin: r.get(at + 3)?,
488        created_at: r.get(at + 4)?,
489        disabled: r.get(at + 5)?,
490        has_password: r.get(at + 6)?,
491    })
492}
493
494pub(crate) fn org_col(r: &Row, i: usize) -> rusqlite::Result<OrgId> {
495    let s: String = r.get(i)?;
496    OrgId::new(s).map_err(|e| {
497        rusqlite::Error::FromSqlConversionFailure(i, rusqlite::types::Type::Text, Box::new(e))
498    })
499}
500
501fn opt_org_col(r: &Row, i: usize) -> rusqlite::Result<Option<OrgId>> {
502    match r.get::<_, Option<String>>(i)? {
503        None => Ok(None),
504        Some(_) => org_col(r, i).map(Some),
505    }
506}
507
508pub(crate) fn role_col(r: &Row, i: usize) -> rusqlite::Result<Role> {
509    let s: String = r.get(i)?;
510    Role::parse(&s).map_err(|e| {
511        rusqlite::Error::FromSqlConversionFailure(i, rusqlite::types::Type::Text, Box::new(e))
512    })
513}
514
515const INVITATION_COLS: &str =
516    "id, org, email, role, invited_by, created_at, expires_at, accepted_at";
517
518fn invitation_row(r: &Row) -> rusqlite::Result<Invitation> {
519    Ok(Invitation {
520        id: r.get(0)?,
521        org: org_col(r, 1)?,
522        email: r.get(2)?,
523        role: role_col(r, 3)?,
524        invited_by: r.get(4)?,
525        created_at: r.get(5)?,
526        expires_at: r.get(6)?,
527        accepted_at: r.get(7)?,
528    })
529}
530
531const TOKEN_COLS: &str = "id, name, user_id, org, created_at, last_used, expires_at, scopes";
532
533fn token_row(r: &Row) -> rusqlite::Result<ApiToken> {
534    Ok(ApiToken {
535        id: r.get(0)?,
536        name: r.get(1)?,
537        user_id: r.get(2)?,
538        org: opt_org_col(r, 3)?,
539        created_at: r.get(4)?,
540        last_used: r.get(5)?,
541        expires_at: r.get(6)?,
542        scopes: scopes_col(r.get(7)?),
543    })
544}
545
546fn scopes_col(v: Option<String>) -> Vec<String> {
547    v.and_then(|s| serde_json::from_str(&s).ok())
548        .unwrap_or_default()
549}
550
551/// What an API token may do on top of its role. A token with no scopes has
552/// the role's whole reach (agents administer their org by default); scopes
553/// only ever narrow it.
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub enum Scope {
556    /// Read-only tools, never secret values.
557    Read,
558    /// `read`, plus deploys, redeploys, rollbacks, scaling and builds.
559    Deploy,
560    /// Everything the role allows, including tokens and members.
561    Admin,
562    /// Tools whose name matches a glob: `tool:app_*`.
563    Tools(String),
564}
565
566impl Scope {
567    pub fn parse(s: &str) -> AuthResult<Scope> {
568        let s = s.trim();
569        match s {
570            "read" => Ok(Scope::Read),
571            "deploy" => Ok(Scope::Deploy),
572            "admin" => Ok(Scope::Admin),
573            _ => match s.strip_prefix("tool:") {
574                Some(g)
575                    if !g.is_empty()
576                        && g.len() <= 128
577                        && g.bytes()
578                            .all(|b| b.is_ascii_alphanumeric() || b"_.-*?[]!^".contains(&b)) =>
579                {
580                    Ok(Scope::Tools(g.to_string()))
581                }
582                _ => Err(AuthError::Invalid(format!(
583                    "scope {s:?}: read, deploy, admin or tool:GLOB"
584                ))),
585            },
586        }
587    }
588
589    pub fn as_string(&self) -> String {
590        match self {
591            Scope::Read => "read".into(),
592            Scope::Deploy => "deploy".into(),
593            Scope::Admin => "admin".into(),
594            Scope::Tools(g) => format!("tool:{g}"),
595        }
596    }
597
598    /// Check a list, normalized and without duplicates.
599    pub fn normalize(v: &[String]) -> AuthResult<Vec<String>> {
600        if v.len() > 32 {
601            return Err(AuthError::Invalid("at most 32 scopes".into()));
602        }
603        let mut out: Vec<String> = Vec::new();
604        for s in v {
605            let s = Scope::parse(s)?.as_string();
606            if !out.contains(&s) {
607                out.push(s);
608            }
609        }
610        Ok(out)
611    }
612}
613
614impl AuthStore {
615    /// Open (creating and migrating) the database at `path`, default config.
616    pub fn open(path: impl AsRef<Path>) -> AuthResult<AuthStore> {
617        Self::open_with(path, AuthConfig::default())
618    }
619
620    pub fn open_with(path: impl AsRef<Path>, cfg: AuthConfig) -> AuthResult<AuthStore> {
621        let path = path.as_ref();
622        let conn = db::open(path)?;
623        Ok(Self::build(conn, Some(path.to_path_buf()), cfg))
624    }
625
626    /// A throwaway in-memory store (tests, previews).
627    pub fn in_memory(cfg: AuthConfig) -> AuthResult<AuthStore> {
628        Ok(Self::build(db::open_in_memory()?, None, cfg))
629    }
630
631    fn build(conn: Connection, path: Option<PathBuf>, cfg: AuthConfig) -> AuthStore {
632        AuthStore {
633            conn: Mutex::new(conn),
634            path,
635            per_email: RateLimiter::new(cfg.login_per_email),
636            per_ip: RateLimiter::new(cfg.attempts_per_ip),
637            resets: RateLimiter::new(cfg.resets_per_email),
638            cfg,
639            clock: Arc::new(unix_now),
640            dummy: OnceLock::new(),
641        }
642    }
643
644    /// Replace the clock (unix seconds), for tests of expiry.
645    pub fn with_clock(mut self, clock: Clock) -> Self {
646        self.clock = clock;
647        self
648    }
649
650    pub fn config(&self) -> &AuthConfig {
651        &self.cfg
652    }
653
654    pub fn path(&self) -> Option<&Path> {
655        self.path.as_deref()
656    }
657
658    pub fn now(&self) -> i64 {
659        (self.clock)()
660    }
661
662    pub(crate) fn db(&self) -> MutexGuard<'_, Connection> {
663        self.conn.lock().unwrap_or_else(|e| e.into_inner())
664    }
665
666    fn hash_pw(&self, pw: &str) -> AuthResult<String> {
667        secret::check_password_policy(pw)?;
668        secret::hash_password(pw, self.cfg.password_cost)
669    }
670
671    fn rate(&self, l: &RateLimiter, key: &str) -> AuthResult<()> {
672        l.take(key, self.now() as f64)
673            .map_err(|retry_after| AuthError::RateLimited { retry_after })
674    }
675
676    /// Count one unauthenticated attempt from `ip` (login, setup, invitation
677    /// acceptance, password resets).
678    pub fn limit_ip(&self, ip: Option<&str>) -> AuthResult<()> {
679        match ip {
680            Some(ip) => self.rate(&self.per_ip, ip),
681            None => Ok(()),
682        }
683    }
684
685    // ---- users ----
686
687    /// Create a user. `password` may be `None` for an account that will sign
688    /// in through an external identity or reset its password.
689    pub fn create_user(
690        &self,
691        email: &str,
692        name: &str,
693        password: Option<&str>,
694        platform_admin: bool,
695    ) -> AuthResult<User> {
696        let email = normalize_email(email)?;
697        let name = clean_name(name)?;
698        let hash = password.map(|p| self.hash_pw(p)).transpose()?;
699        let now = self.now();
700        let db = self.db();
701        let r = db.execute(
702            "INSERT INTO users (email, name, password_hash, platform_admin, created_at)
703             VALUES (?1, ?2, ?3, ?4, ?5)",
704            params![email, name, hash, platform_admin, now],
705        );
706        match r {
707            Ok(_) => {
708                let id = db.last_insert_rowid();
709                drop(db);
710                self.user(id)
711            }
712            Err(rusqlite::Error::SqliteFailure(e, _))
713                if e.code == rusqlite::ErrorCode::ConstraintViolation =>
714            {
715                Err(AuthError::Conflict(format!(
716                    "a user with email {email} exists"
717                )))
718            }
719            Err(e) => Err(e.into()),
720        }
721    }
722
723    pub fn user(&self, id: i64) -> AuthResult<User> {
724        self.db()
725            .query_row(
726                &format!("SELECT {USER_COLS} FROM users u WHERE u.id = ?1"),
727                [id],
728                |r| user_row(r, 0),
729            )
730            .optional()?
731            .ok_or_else(|| AuthError::NotFound(format!("user {id}")))
732    }
733
734    pub fn user_by_email(&self, email: &str) -> AuthResult<Option<User>> {
735        let email = normalize_email(email)?;
736        Ok(self
737            .db()
738            .query_row(
739                &format!("SELECT {USER_COLS} FROM users u WHERE u.email = ?1"),
740                [email],
741                |r| user_row(r, 0),
742            )
743            .optional()?)
744    }
745
746    pub fn list_users(&self) -> AuthResult<Vec<User>> {
747        let db = self.db();
748        let mut st = db.prepare(&format!("SELECT {USER_COLS} FROM users u ORDER BY u.id"))?;
749        let rows = st.query_map([], |r| user_row(r, 0))?;
750        Ok(rows.collect::<rusqlite::Result<_>>()?)
751    }
752
753    /// Disable (or re-enable) a user. Disabling ends their sessions; their
754    /// tokens stop working while disabled.
755    pub fn set_disabled(&self, user_id: i64, disabled: bool) -> AuthResult<()> {
756        let db = self.db();
757        let n = db.execute(
758            "UPDATE users SET disabled = ?2 WHERE id = ?1",
759            params![user_id, disabled],
760        )?;
761        if n == 0 {
762            return Err(AuthError::NotFound(format!("user {user_id}")));
763        }
764        if disabled {
765            db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
766        }
767        Ok(())
768    }
769
770    /// When a user last did anything: the latest of their sessions' last use
771    /// and their tokens' last use (`None`: never, or nothing left to tell).
772    pub fn last_active(&self, user_id: i64) -> AuthResult<Option<i64>> {
773        Ok(self.db().query_row(
774            "SELECT MAX(t) FROM (
775                 SELECT MAX(last_seen) AS t FROM sessions WHERE user_id = ?1
776                 UNION ALL
777                 SELECT MAX(last_used) FROM api_tokens WHERE user_id = ?1)",
778            [user_id],
779            |r| r.get(0),
780        )?)
781    }
782
783    /// Enabled platform admins other than `except`.
784    pub fn other_platform_admins(&self, except: i64) -> AuthResult<i64> {
785        Ok(self.db().query_row(
786            "SELECT COUNT(*) FROM users WHERE platform_admin = 1 AND disabled = 0 AND id != ?1",
787            [except],
788            |r| r.get(0),
789        )?)
790    }
791
792    pub fn set_platform_admin(&self, user_id: i64, admin: bool) -> AuthResult<()> {
793        let n = self.db().execute(
794            "UPDATE users SET platform_admin = ?2 WHERE id = ?1",
795            params![user_id, admin],
796        )?;
797        if n == 0 {
798            return Err(AuthError::NotFound(format!("user {user_id}")));
799        }
800        Ok(())
801    }
802
803    /// Set a password without the old one (the local CLI, an admin). Ends
804    /// every session of the user.
805    pub fn set_password(&self, user_id: i64, password: &str) -> AuthResult<()> {
806        let hash = self.hash_pw(password)?;
807        let db = self.db();
808        let n = db.execute(
809            "UPDATE users SET password_hash = ?2 WHERE id = ?1",
810            params![user_id, hash],
811        )?;
812        if n == 0 {
813            return Err(AuthError::NotFound(format!("user {user_id}")));
814        }
815        db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
816        Ok(())
817    }
818
819    /// Change a password, proving the current one. Ends every other session
820    /// (`keep` is the caller's own).
821    pub fn change_password(
822        &self,
823        user_id: i64,
824        current: &str,
825        new: &str,
826        keep: Option<i64>,
827    ) -> AuthResult<()> {
828        let stored: Option<String> = self
829            .db()
830            .query_row(
831                "SELECT password_hash FROM users WHERE id = ?1",
832                [user_id],
833                |r| r.get(0),
834            )
835            .optional()?
836            .flatten();
837        let ok = match &stored {
838            Some(h) => secret::verify_password(current, h),
839            None => {
840                secret::verify_password(current, self.dummy());
841                false
842            }
843        };
844        if !ok {
845            return Err(AuthError::Forbidden("current password is wrong".into()));
846        }
847        let hash = self.hash_pw(new)?;
848        let db = self.db();
849        db.execute(
850            "UPDATE users SET password_hash = ?2 WHERE id = ?1",
851            params![user_id, hash],
852        )?;
853        db.execute(
854            "DELETE FROM sessions WHERE user_id = ?1 AND id IS NOT ?2",
855            params![user_id, keep],
856        )?;
857        Ok(())
858    }
859
860    fn dummy(&self) -> &str {
861        secret::dummy_hash(&self.dummy, self.cfg.password_cost)
862    }
863
864    // ---- sessions ----
865
866    /// Check an email and password and start a session. Every failure (no
867    /// such user, wrong password, disabled, no password set) is
868    /// [`AuthError::InvalidCredentials`] and costs one argon2 verification.
869    pub fn login(&self, email: &str, password: &str, meta: LoginMeta) -> AuthResult<NewSession> {
870        let key = email.trim().to_lowercase();
871        self.limit_ip(meta.ip.as_deref())?;
872        self.rate(&self.per_email, &key)?;
873        let found: Option<(i64, Option<String>, bool)> = self
874            .db()
875            .query_row(
876                "SELECT id, password_hash, disabled FROM users WHERE email = ?1",
877                [&key],
878                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
879            )
880            .optional()?;
881        let user_id = match found {
882            Some((id, Some(h), disabled)) => {
883                let ok = secret::verify_password(password, &h);
884                (ok && !disabled).then_some(id)
885            }
886            _ => {
887                secret::verify_password(password, self.dummy());
888                None
889            }
890        };
891        let user_id = user_id.ok_or(AuthError::InvalidCredentials)?;
892        self.prune()?;
893        self.start_session(user_id, meta)
894    }
895
896    /// Start a session for a user already proven by other means (an
897    /// accepted invitation, a password reset, an external identity).
898    pub fn start_session(&self, user_id: i64, meta: LoginMeta) -> AuthResult<NewSession> {
899        let (token, hash) = secret::new_token(TokenKind::Session)?;
900        let now = self.now();
901        let expires = now + secs(self.cfg.session_max_age);
902        let (ua, ip) = (clip(meta.user_agent, 256), clip(meta.ip, 64));
903        let db = self.db();
904        db.execute(
905            "INSERT INTO sessions (token_hash, user_id, created_at, last_seen, expires_at, user_agent, ip)
906             VALUES (?1, ?2, ?3, ?3, ?4, ?5, ?6)",
907            params![hash, user_id, now, expires, ua, ip],
908        )?;
909        let id = db.last_insert_rowid();
910        Ok(NewSession {
911            token,
912            session: Session {
913                id,
914                user_id,
915                created_at: now,
916                last_seen: now,
917                expires_at: expires,
918                idle_expires_at: self.idle_end(now, expires),
919                user_agent: ua,
920                ip,
921            },
922        })
923    }
924
925    fn idle_end(&self, last_seen: i64, expires: i64) -> i64 {
926        (last_seen + secs(self.cfg.session_idle)).min(expires)
927    }
928
929    fn session_row(&self, r: &Row, at: usize) -> rusqlite::Result<Session> {
930        let last_seen: i64 = r.get(at + 3)?;
931        let expires: i64 = r.get(at + 4)?;
932        Ok(Session {
933            id: r.get(at)?,
934            user_id: r.get(at + 1)?,
935            created_at: r.get(at + 2)?,
936            last_seen,
937            expires_at: expires,
938            idle_expires_at: self.idle_end(last_seen, expires),
939            user_agent: r.get(at + 5)?,
940            ip: r.get(at + 6)?,
941        })
942    }
943
944    /// The live session for `token`, sliding its idle expiry. An expired one
945    /// is deleted; a disabled user has none.
946    pub fn session(&self, token: &str) -> AuthResult<Option<(User, Session)>> {
947        if !secret::well_formed(token, TokenKind::Session) {
948            return Ok(None);
949        }
950        let hash = secret::hash_token(token);
951        let now = self.now();
952        let db = self.db();
953        let found = db
954            .query_row(
955                &format!(
956                    "SELECT s.token_hash, s.id, s.user_id, s.created_at, s.last_seen, s.expires_at,
957                            s.user_agent, s.ip, {USER_COLS}
958                     FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?1"
959                ),
960                [&hash],
961                |r| {
962                    Ok((
963                        r.get::<_, Vec<u8>>(0)?,
964                        self.session_row(r, 1)?,
965                        user_row(r, 8)?,
966                    ))
967                },
968            )
969            .optional()?;
970        let Some((stored, mut s, user)) = found else {
971            return Ok(None);
972        };
973        if !secret::ct_eq(&stored, &hash) {
974            return Ok(None);
975        }
976        if now >= s.expires_at || now >= s.idle_expires_at {
977            db.execute("DELETE FROM sessions WHERE id = ?1", [s.id])?;
978            return Ok(None);
979        }
980        if user.disabled {
981            return Ok(None);
982        }
983        if now - s.last_seen >= TOUCH_EVERY {
984            db.execute(
985                "UPDATE sessions SET last_seen = ?2 WHERE id = ?1",
986                params![s.id, now],
987            )?;
988            s.last_seen = now;
989            s.idle_expires_at = self.idle_end(now, s.expires_at);
990        }
991        Ok(Some((user, s)))
992    }
993
994    /// The principal for a Cloudflare Access identity: the enabled user with
995    /// that email, with all their memberships.
996    pub fn principal_for_email(&self, email: &str) -> AuthResult<Option<Principal>> {
997        let Some(user) = self.user_by_email(email)? else {
998            return Ok(None);
999        };
1000        if user.disabled {
1001            return Ok(None);
1002        }
1003        let orgs = self
1004            .memberships(user.id)?
1005            .into_iter()
1006            .map(|m| (m.org, m.role))
1007            .collect();
1008        Ok(Some(Principal {
1009            platform_admin: user.platform_admin,
1010            user,
1011            kind: PrincipalKind::Access,
1012            orgs,
1013            downscoped: None,
1014        }))
1015    }
1016
1017    /// The principal behind a session token.
1018    pub fn authenticate_session(&self, token: &str) -> AuthResult<Option<Principal>> {
1019        let Some((user, s)) = self.session(token)? else {
1020            return Ok(None);
1021        };
1022        let orgs = self
1023            .memberships(user.id)?
1024            .into_iter()
1025            .map(|m| (m.org, m.role))
1026            .collect();
1027        Ok(Some(Principal {
1028            platform_admin: user.platform_admin,
1029            user,
1030            kind: PrincipalKind::Session { id: s.id },
1031            orgs,
1032            downscoped: None,
1033        }))
1034    }
1035
1036    /// End the session holding `token`. True if there was one.
1037    pub fn logout(&self, token: &str) -> AuthResult<bool> {
1038        if !secret::well_formed(token, TokenKind::Session) {
1039            return Ok(false);
1040        }
1041        let n = self.db().execute(
1042            "DELETE FROM sessions WHERE token_hash = ?1",
1043            [secret::hash_token(token)],
1044        )?;
1045        Ok(n > 0)
1046    }
1047
1048    /// A user's live sessions, newest first.
1049    pub fn list_sessions(&self, user_id: i64) -> AuthResult<Vec<Session>> {
1050        let now = self.now();
1051        let db = self.db();
1052        let mut st = db.prepare(
1053            "SELECT id, user_id, created_at, last_seen, expires_at, user_agent, ip
1054             FROM sessions WHERE user_id = ?1 ORDER BY id DESC",
1055        )?;
1056        let rows = st.query_map([user_id], |r| self.session_row(r, 0))?;
1057        let all: Vec<Session> = rows.collect::<rusqlite::Result<_>>()?;
1058        Ok(all
1059            .into_iter()
1060            .filter(|s| now < s.expires_at && now < s.idle_expires_at)
1061            .collect())
1062    }
1063
1064    /// End one of a user's sessions. True if it existed.
1065    pub fn revoke_session(&self, user_id: i64, session_id: i64) -> AuthResult<bool> {
1066        let n = self.db().execute(
1067            "DELETE FROM sessions WHERE id = ?1 AND user_id = ?2",
1068            params![session_id, user_id],
1069        )?;
1070        Ok(n > 0)
1071    }
1072
1073    /// End all of a user's sessions but `keep`. Returns how many ended.
1074    pub fn revoke_sessions(&self, user_id: i64, keep: Option<i64>) -> AuthResult<usize> {
1075        Ok(self.db().execute(
1076            "DELETE FROM sessions WHERE user_id = ?1 AND id IS NOT ?2",
1077            params![user_id, keep],
1078        )?)
1079    }
1080
1081    /// Delete expired sessions, invitations and resets.
1082    pub fn prune(&self) -> AuthResult<()> {
1083        let now = self.now();
1084        let idle = secs(self.cfg.session_idle);
1085        self.db().execute_batch(&format!(
1086            "DELETE FROM sessions WHERE expires_at <= {now} OR last_seen + {idle} <= {now};
1087             DELETE FROM invitations WHERE accepted_at IS NULL AND expires_at <= {now};
1088             DELETE FROM password_resets WHERE expires_at <= {now} OR used_at IS NOT NULL;"
1089        ))?;
1090        Ok(())
1091    }
1092
1093    // ---- orgs and memberships ----
1094
1095    /// Record an org (idempotent). The org's runtime is not this module's;
1096    /// this row anchors memberships, invitations and tokens.
1097    pub fn ensure_org(&self, org: &OrgId) -> AuthResult<()> {
1098        ensure_org_tx(&self.db(), org, self.now())
1099    }
1100
1101    /// Forget an org: its memberships, invitations and tokens go with it.
1102    pub fn delete_org(&self, org: &OrgId) -> AuthResult<bool> {
1103        let n = self
1104            .db()
1105            .execute("DELETE FROM orgs WHERE name = ?1", [org.as_str()])?;
1106        Ok(n > 0)
1107    }
1108
1109    pub fn list_orgs(&self) -> AuthResult<Vec<OrgId>> {
1110        let db = self.db();
1111        let mut st = db.prepare("SELECT name FROM orgs ORDER BY name")?;
1112        let rows = st.query_map([], |r| org_col(r, 0))?;
1113        Ok(rows.collect::<rusqlite::Result<_>>()?)
1114    }
1115
1116    pub fn memberships(&self, user_id: i64) -> AuthResult<Vec<Membership>> {
1117        let db = self.db();
1118        let mut st =
1119            db.prepare("SELECT org, role FROM memberships WHERE user_id = ?1 ORDER BY org")?;
1120        let rows = st.query_map([user_id], |r| {
1121            Ok(Membership {
1122                org: org_col(r, 0)?,
1123                role: role_col(r, 1)?,
1124            })
1125        })?;
1126        Ok(rows.collect::<rusqlite::Result<_>>()?)
1127    }
1128
1129    pub fn list_members(&self, org: &OrgId) -> AuthResult<Vec<(User, Role)>> {
1130        let db = self.db();
1131        let mut st = db.prepare(&format!(
1132            "SELECT {USER_COLS}, m.role FROM memberships m JOIN users u ON u.id = m.user_id
1133             WHERE m.org = ?1 ORDER BY u.email"
1134        ))?;
1135        let rows = st.query_map([org.as_str()], |r| Ok((user_row(r, 0)?, role_col(r, 7)?)))?;
1136        Ok(rows.collect::<rusqlite::Result<_>>()?)
1137    }
1138
1139    /// Add `user` to `org` with `role`, or change their role. Refuses to
1140    /// demote the org's last owner.
1141    pub fn set_member(&self, org: &OrgId, user_id: i64, role: Role) -> AuthResult<()> {
1142        let now = self.now();
1143        let mut db = self.db();
1144        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1145        ensure_org_tx(&tx, org, now)?;
1146        if role != Role::Owner {
1147            refuse_last_owner(&tx, org, user_id, "demote")?;
1148        }
1149        tx.execute(
1150            "INSERT INTO memberships (user_id, org, role, created_at) VALUES (?1, ?2, ?3, ?4)
1151             ON CONFLICT (user_id, org) DO UPDATE SET role = excluded.role",
1152            params![user_id, org.as_str(), role.as_str(), now],
1153        )
1154        .map_err(|e| match e {
1155            rusqlite::Error::SqliteFailure(f, _)
1156                if f.code == rusqlite::ErrorCode::ConstraintViolation =>
1157            {
1158                AuthError::NotFound(format!("user {user_id}"))
1159            }
1160            e => e.into(),
1161        })?;
1162        tx.commit()?;
1163        Ok(())
1164    }
1165
1166    /// Remove `user` from `org`, and their tokens confined to it. Refuses to
1167    /// remove the last owner. True if they were a member.
1168    pub fn remove_member(&self, org: &OrgId, user_id: i64) -> AuthResult<bool> {
1169        let mut db = self.db();
1170        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1171        refuse_last_owner(&tx, org, user_id, "remove")?;
1172        let n = tx.execute(
1173            "DELETE FROM memberships WHERE org = ?1 AND user_id = ?2",
1174            params![org.as_str(), user_id],
1175        )?;
1176        tx.execute(
1177            "DELETE FROM api_tokens WHERE org = ?1 AND user_id = ?2",
1178            params![org.as_str(), user_id],
1179        )?;
1180        tx.commit()?;
1181        Ok(n > 0)
1182    }
1183
1184    // ---- invitations ----
1185
1186    /// Invite `email` to `org` as `role`. Replaces any pending invitation for
1187    /// the same address and org. Authorization is the caller's
1188    /// ([`Principal::max_grant`]); `invited_by` is `None` for the local CLI.
1189    pub fn create_invitation(
1190        &self,
1191        invited_by: Option<i64>,
1192        org: &OrgId,
1193        email: &str,
1194        role: Role,
1195    ) -> AuthResult<NewInvitation> {
1196        let email = normalize_email(email)?;
1197        let (token, hash) = secret::new_token(TokenKind::Invitation)?;
1198        let now = self.now();
1199        let expires = now + secs(self.cfg.invitation_ttl);
1200        let mut db = self.db();
1201        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1202        ensure_org_tx(&tx, org, now)?;
1203        tx.execute(
1204            "DELETE FROM invitations WHERE org = ?1 AND email = ?2 AND accepted_at IS NULL",
1205            params![org.as_str(), email],
1206        )?;
1207        tx.execute(
1208            "INSERT INTO invitations (token_hash, org, email, role, invited_by, created_at, expires_at)
1209             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1210            params![hash, org.as_str(), email, role.as_str(), invited_by, now, expires],
1211        )?;
1212        let id = tx.last_insert_rowid();
1213        tx.commit()?;
1214        Ok(NewInvitation {
1215            token,
1216            invitation: Invitation {
1217                id,
1218                org: org.clone(),
1219                email,
1220                role,
1221                invited_by,
1222                created_at: now,
1223                expires_at: expires,
1224                accepted_at: None,
1225            },
1226        })
1227    }
1228
1229    /// Pending (unaccepted, unexpired) invitations to `org`.
1230    pub fn list_invitations(&self, org: &OrgId) -> AuthResult<Vec<Invitation>> {
1231        let db = self.db();
1232        let mut st = db.prepare(&format!(
1233            "SELECT {INVITATION_COLS} FROM invitations
1234             WHERE org = ?1 AND accepted_at IS NULL AND expires_at > ?2 ORDER BY id"
1235        ))?;
1236        let rows = st.query_map(params![org.as_str(), self.now()], invitation_row)?;
1237        Ok(rows.collect::<rusqlite::Result<_>>()?)
1238    }
1239
1240    /// Withdraw a pending invitation. True if there was one.
1241    pub fn revoke_invitation(&self, org: &OrgId, id: i64) -> AuthResult<bool> {
1242        let n = self.db().execute(
1243            "DELETE FROM invitations WHERE id = ?1 AND org = ?2 AND accepted_at IS NULL",
1244            params![id, org.as_str()],
1245        )?;
1246        Ok(n > 0)
1247    }
1248
1249    /// The pending invitation for `token`, if it is valid.
1250    pub fn invitation(&self, token: &str) -> AuthResult<Option<Invitation>> {
1251        if !secret::well_formed(token, TokenKind::Invitation) {
1252            return Ok(None);
1253        }
1254        let hash = secret::hash_token(token);
1255        let found = self
1256            .db()
1257            .query_row(
1258                &format!(
1259                    "SELECT token_hash, {INVITATION_COLS} FROM invitations WHERE token_hash = ?1"
1260                ),
1261                [&hash],
1262                |r| {
1263                    let stored: Vec<u8> = r.get(0)?;
1264                    let inv = Invitation {
1265                        id: r.get(1)?,
1266                        org: org_col(r, 2)?,
1267                        email: r.get(3)?,
1268                        role: role_col(r, 4)?,
1269                        invited_by: r.get(5)?,
1270                        created_at: r.get(6)?,
1271                        expires_at: r.get(7)?,
1272                        accepted_at: r.get(8)?,
1273                    };
1274                    Ok((stored, inv))
1275                },
1276            )
1277            .optional()?;
1278        Ok(found.and_then(|(stored, inv)| {
1279            (secret::ct_eq(&stored, &hash)
1280                && inv.accepted_at.is_none()
1281                && self.now() < inv.expires_at)
1282                .then_some(inv)
1283        }))
1284    }
1285
1286    /// Accept an invitation without a session. A new address gets an account
1287    /// with `name` and `password`; an existing account must prove `password`
1288    /// (the invitation alone does not let anyone in as someone else).
1289    pub fn accept_invitation(
1290        &self,
1291        token: &str,
1292        name: &str,
1293        password: &str,
1294    ) -> AuthResult<Accepted> {
1295        let inv = self
1296            .invitation(token)?
1297            .ok_or(AuthError::InvalidToken("invitation"))?;
1298        let existing: Option<(i64, Option<String>, bool)> = self
1299            .db()
1300            .query_row(
1301                "SELECT id, password_hash, disabled FROM users WHERE email = ?1",
1302                [&inv.email],
1303                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1304            )
1305            .optional()?;
1306        let new_user = match &existing {
1307            Some((_, Some(h), disabled)) => {
1308                if !secret::verify_password(password, h) || *disabled {
1309                    return Err(AuthError::InvalidCredentials);
1310                }
1311                None
1312            }
1313            Some((_, None, _)) => {
1314                secret::verify_password(password, self.dummy());
1315                return Err(AuthError::InvalidCredentials);
1316            }
1317            None => Some((clean_name(name)?, self.hash_pw(password)?)),
1318        };
1319        self.finish_accept(&inv, existing.map(|e| e.0), new_user)
1320    }
1321
1322    /// Accept an invitation as the signed-in user, whose email must match.
1323    pub fn accept_invitation_as(&self, token: &str, user_id: i64) -> AuthResult<Accepted> {
1324        let inv = self
1325            .invitation(token)?
1326            .ok_or(AuthError::InvalidToken("invitation"))?;
1327        let user = self.user(user_id)?;
1328        if user.email != inv.email {
1329            return Err(AuthError::Forbidden(format!(
1330                "this invitation is for {}, and you are signed in as {}",
1331                inv.email, user.email
1332            )));
1333        }
1334        self.finish_accept(&inv, Some(user_id), None)
1335    }
1336
1337    fn finish_accept(
1338        &self,
1339        inv: &Invitation,
1340        user_id: Option<i64>,
1341        new_user: Option<(String, String)>,
1342    ) -> AuthResult<Accepted> {
1343        let now = self.now();
1344        let mut db = self.db();
1345        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1346        // Single use: whoever marks it first wins.
1347        let n = tx.execute(
1348            "UPDATE invitations SET accepted_at = ?2 WHERE id = ?1 AND accepted_at IS NULL",
1349            params![inv.id, now],
1350        )?;
1351        if n == 0 {
1352            return Err(AuthError::InvalidToken("invitation"));
1353        }
1354        let (uid, created) = match (user_id, new_user) {
1355            (Some(id), _) => (id, false),
1356            (None, Some((name, hash))) => {
1357                tx.execute(
1358                    "INSERT INTO users (email, name, password_hash, created_at) VALUES (?1, ?2, ?3, ?4)",
1359                    params![inv.email, name, hash, now],
1360                )
1361                .map_err(|e| match e {
1362                    rusqlite::Error::SqliteFailure(f, _)
1363                        if f.code == rusqlite::ErrorCode::ConstraintViolation =>
1364                    {
1365                        AuthError::Conflict(format!("a user with email {} exists", inv.email))
1366                    }
1367                    e => e.into(),
1368                })?;
1369                (tx.last_insert_rowid(), true)
1370            }
1371            (None, None) => return Err(AuthError::Internal("accept: no user".into())),
1372        };
1373        tx.execute(
1374            "UPDATE invitations SET accepted_by = ?2 WHERE id = ?1",
1375            params![inv.id, uid],
1376        )?;
1377        // Never lower an existing role by accepting a lesser invitation.
1378        let current: Option<Role> = tx
1379            .query_row(
1380                "SELECT role FROM memberships WHERE user_id = ?1 AND org = ?2",
1381                params![uid, inv.org.as_str()],
1382                |r| role_col(r, 0),
1383            )
1384            .optional()?;
1385        let role = current.map_or(inv.role, |c| c.max(inv.role));
1386        tx.execute(
1387            "INSERT INTO memberships (user_id, org, role, created_at) VALUES (?1, ?2, ?3, ?4)
1388             ON CONFLICT (user_id, org) DO UPDATE SET role = excluded.role",
1389            params![uid, inv.org.as_str(), role.as_str(), now],
1390        )?;
1391        tx.commit()?;
1392        drop(db);
1393        Ok(Accepted {
1394            user: self.user(uid)?,
1395            membership: Membership {
1396                org: inv.org.clone(),
1397                role,
1398            },
1399            created,
1400        })
1401    }
1402
1403    // ---- API tokens ----
1404
1405    /// Make an API token for `user_id`. Confined to `org` when given (the
1406    /// user must belong to it, or be a platform admin); a platform token
1407    /// (`org: None`) is for platform admins only. `expires: None` never expires.
1408    pub fn create_api_token(
1409        &self,
1410        user_id: i64,
1411        org: Option<&OrgId>,
1412        name: &str,
1413        expires: Option<Duration>,
1414    ) -> AuthResult<NewApiToken> {
1415        self.create_api_token_scoped(user_id, org, name, expires, &[])
1416    }
1417
1418    /// [`Self::create_api_token`], narrowed to `scopes` ([`Scope`]).
1419    pub fn create_api_token_scoped(
1420        &self,
1421        user_id: i64,
1422        org: Option<&OrgId>,
1423        name: &str,
1424        expires: Option<Duration>,
1425        scopes: &[String],
1426    ) -> AuthResult<NewApiToken> {
1427        let scopes = Scope::normalize(scopes)?;
1428        let name = name.trim();
1429        if name.is_empty() || name.chars().count() > 100 || name.chars().any(char::is_control) {
1430            return Err(AuthError::Invalid(
1431                "token name: 1 to 100 characters, no control characters".into(),
1432            ));
1433        }
1434        let user = self.user(user_id)?;
1435        if user.disabled {
1436            return Err(AuthError::Forbidden(format!(
1437                "user {} is disabled",
1438                user.email
1439            )));
1440        }
1441        match org {
1442            None if !user.platform_admin => {
1443                return Err(AuthError::Forbidden(
1444                    "only a platform admin can make a token without an org".into(),
1445                ));
1446            }
1447            Some(o) if !user.platform_admin && self.role_of(user_id, o)?.is_none() => {
1448                return Err(AuthError::Forbidden(format!(
1449                    "{} is not a member of org {o}",
1450                    user.email
1451                )));
1452            }
1453            _ => {}
1454        }
1455        let (token, hash) = secret::new_token(TokenKind::Api)?;
1456        let now = self.now();
1457        let expires_at = expires.map(|d| now + secs(d));
1458        let db = self.db();
1459        if let Some(o) = org {
1460            ensure_org_tx(&db, o, now)?;
1461        }
1462        db.execute(
1463            "INSERT INTO api_tokens (token_hash, name, user_id, org, created_at, expires_at, scopes)
1464             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1465            params![
1466                hash,
1467                name,
1468                user_id,
1469                org.map(OrgId::as_str),
1470                now,
1471                expires_at,
1472                (!scopes.is_empty()).then(|| serde_json::to_string(&scopes).unwrap_or_default())
1473            ],
1474        )?;
1475        Ok(NewApiToken {
1476            token,
1477            info: ApiToken {
1478                id: db.last_insert_rowid(),
1479                name: name.to_string(),
1480                user_id,
1481                org: org.cloned(),
1482                created_at: now,
1483                last_used: None,
1484                expires_at,
1485                scopes,
1486            },
1487        })
1488    }
1489
1490    fn role_of(&self, user_id: i64, org: &OrgId) -> AuthResult<Option<Role>> {
1491        Ok(self
1492            .db()
1493            .query_row(
1494                "SELECT role FROM memberships WHERE user_id = ?1 AND org = ?2",
1495                params![user_id, org.as_str()],
1496                |r| role_col(r, 0),
1497            )
1498            .optional()?)
1499    }
1500
1501    /// The principal behind an API token. Expired tokens, disabled users, and
1502    /// org tokens whose user has left the org authenticate nobody.
1503    pub fn authenticate_token(&self, token: &str) -> AuthResult<Option<Principal>> {
1504        if !secret::well_formed(token, TokenKind::Api) {
1505            return Ok(None);
1506        }
1507        let hash = secret::hash_token(token);
1508        let now = self.now();
1509        let found = self
1510            .db()
1511            .query_row(
1512                &format!(
1513                    "SELECT t.token_hash, t.id, t.org, t.expires_at, t.last_used, t.name, t.scopes,
1514                     {USER_COLS} FROM api_tokens t JOIN users u ON u.id = t.user_id
1515                     WHERE t.token_hash = ?1"
1516                ),
1517                [&hash],
1518                |r| {
1519                    Ok((
1520                        r.get::<_, Vec<u8>>(0)?,
1521                        r.get::<_, i64>(1)?,
1522                        opt_org_col(r, 2)?,
1523                        r.get::<_, Option<i64>>(3)?,
1524                        r.get::<_, Option<i64>>(4)?,
1525                        r.get::<_, String>(5)?,
1526                        scopes_col(r.get(6)?),
1527                        user_row(r, 7)?,
1528                    ))
1529                },
1530            )
1531            .optional()?;
1532        let Some((stored, id, org, expires, last_used, name, scopes, user)) = found else {
1533            return Ok(None);
1534        };
1535        if !secret::ct_eq(&stored, &hash) || expires.is_some_and(|e| now >= e) || user.disabled {
1536            return Ok(None);
1537        }
1538        let (orgs, platform_admin) = match &org {
1539            Some(o) => {
1540                let role = match self.role_of(user.id, o)? {
1541                    Some(r) => r,
1542                    // A platform admin reaches every org; confined here.
1543                    None if user.platform_admin => Role::Owner,
1544                    None => return Ok(None),
1545                };
1546                (vec![(o.clone(), role)], false)
1547            }
1548            None if user.platform_admin => (
1549                self.memberships(user.id)?
1550                    .into_iter()
1551                    .map(|m| (m.org, m.role))
1552                    .collect(),
1553                true,
1554            ),
1555            // A platform token whose user is no longer a platform admin.
1556            None => return Ok(None),
1557        };
1558        if last_used.is_none_or(|l| now - l >= TOUCH_EVERY) {
1559            self.db().execute(
1560                "UPDATE api_tokens SET last_used = ?2 WHERE id = ?1",
1561                params![id, now],
1562            )?;
1563        }
1564        Ok(Some(Principal {
1565            user,
1566            kind: PrincipalKind::ApiToken {
1567                id,
1568                org,
1569                name,
1570                scopes,
1571            },
1572            orgs,
1573            platform_admin,
1574            downscoped: None,
1575        }))
1576    }
1577
1578    pub fn api_token(&self, id: i64) -> AuthResult<ApiToken> {
1579        self.db()
1580            .query_row(
1581                &format!("SELECT {TOKEN_COLS} FROM api_tokens WHERE id = ?1"),
1582                [id],
1583                token_row,
1584            )
1585            .optional()?
1586            .ok_or_else(|| AuthError::NotFound(format!("token {id}")))
1587    }
1588
1589    /// A user's tokens.
1590    pub fn list_api_tokens(&self, user_id: i64) -> AuthResult<Vec<ApiToken>> {
1591        let db = self.db();
1592        let mut st = db.prepare(&format!(
1593            "SELECT {TOKEN_COLS} FROM api_tokens WHERE user_id = ?1 ORDER BY id"
1594        ))?;
1595        let rows = st.query_map([user_id], token_row)?;
1596        Ok(rows.collect::<rusqlite::Result<_>>()?)
1597    }
1598
1599    /// Every token confined to `org`, whoever made it.
1600    pub fn list_org_api_tokens(&self, org: &OrgId) -> AuthResult<Vec<ApiToken>> {
1601        let db = self.db();
1602        let mut st = db.prepare(&format!(
1603            "SELECT {TOKEN_COLS} FROM api_tokens WHERE org = ?1 ORDER BY id"
1604        ))?;
1605        let rows = st.query_map([org.as_str()], token_row)?;
1606        Ok(rows.collect::<rusqlite::Result<_>>()?)
1607    }
1608
1609    /// Every token (the local CLI).
1610    pub fn list_all_api_tokens(&self) -> AuthResult<Vec<ApiToken>> {
1611        let db = self.db();
1612        let mut st = db.prepare(&format!("SELECT {TOKEN_COLS} FROM api_tokens ORDER BY id"))?;
1613        let rows = st.query_map([], token_row)?;
1614        Ok(rows.collect::<rusqlite::Result<_>>()?)
1615    }
1616
1617    /// Delete a token. True if it existed. Authorization is the caller's.
1618    pub fn revoke_api_token(&self, id: i64) -> AuthResult<bool> {
1619        let n = self
1620            .db()
1621            .execute("DELETE FROM api_tokens WHERE id = ?1", [id])?;
1622        Ok(n > 0)
1623    }
1624
1625    // ---- password resets ----
1626
1627    /// A one-hour reset token for `email`, or `None` when there is no such
1628    /// (enabled) user. The caller delivers it, and must answer the same way
1629    /// either way so the endpoint does not reveal who has an account.
1630    pub fn request_password_reset(&self, email: &str) -> AuthResult<Option<String>> {
1631        let key = email.trim().to_lowercase();
1632        self.rate(&self.resets, &key)?;
1633        let Some(user) = self.user_by_email(&key).ok().flatten() else {
1634            return Ok(None);
1635        };
1636        if user.disabled {
1637            return Ok(None);
1638        }
1639        let (token, hash) = secret::new_token(TokenKind::PasswordReset)?;
1640        let now = self.now();
1641        let db = self.db();
1642        // Only the newest link works.
1643        db.execute("DELETE FROM password_resets WHERE user_id = ?1", [user.id])?;
1644        db.execute(
1645            "INSERT INTO password_resets (token_hash, user_id, created_at, expires_at)
1646             VALUES (?1, ?2, ?3, ?4)",
1647            params![hash, user.id, now, now + secs(self.cfg.reset_ttl)],
1648        )?;
1649        Ok(Some(token))
1650    }
1651
1652    /// Set a new password with a reset token. Single use; ends every session.
1653    pub fn reset_password(&self, token: &str, password: &str) -> AuthResult<User> {
1654        if !secret::well_formed(token, TokenKind::PasswordReset) {
1655            return Err(AuthError::InvalidToken("reset link"));
1656        }
1657        let hash_pw = self.hash_pw(password)?;
1658        let hash = secret::hash_token(token);
1659        let now = self.now();
1660        let mut db = self.db();
1661        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1662        // (hash, id, user, expires, used)
1663        type ResetRow = (Vec<u8>, i64, i64, i64, Option<i64>);
1664        let found: Option<ResetRow> = tx
1665            .query_row(
1666                "SELECT token_hash, id, user_id, expires_at, used_at FROM password_resets
1667                 WHERE token_hash = ?1",
1668                [&hash],
1669                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
1670            )
1671            .optional()?;
1672        let Some((stored, id, user_id, expires, used)) = found else {
1673            return Err(AuthError::InvalidToken("reset link"));
1674        };
1675        if !secret::ct_eq(&stored, &hash) || used.is_some() || now >= expires {
1676            return Err(AuthError::InvalidToken("reset link"));
1677        }
1678        tx.execute(
1679            "UPDATE password_resets SET used_at = ?2 WHERE id = ?1",
1680            params![id, now],
1681        )?;
1682        tx.execute(
1683            "UPDATE users SET password_hash = ?2 WHERE id = ?1",
1684            params![user_id, hash_pw],
1685        )?;
1686        tx.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
1687        tx.commit()?;
1688        drop(db);
1689        self.user(user_id)
1690    }
1691}
1692
1693pub(crate) fn ensure_org_tx(conn: &Connection, org: &OrgId, now: i64) -> AuthResult<()> {
1694    conn.execute(
1695        "INSERT INTO orgs (name, created_at) VALUES (?1, ?2) ON CONFLICT (name) DO NOTHING",
1696        params![org.as_str(), now],
1697    )?;
1698    Ok(())
1699}
1700
1701/// An org keeps at least one owner while it has any.
1702fn refuse_last_owner(conn: &Connection, org: &OrgId, user_id: i64, verb: &str) -> AuthResult<()> {
1703    let is_owner: bool = conn
1704        .query_row(
1705            "SELECT role = 'owner' FROM memberships WHERE org = ?1 AND user_id = ?2",
1706            params![org.as_str(), user_id],
1707            |r| r.get(0),
1708        )
1709        .optional()?
1710        .unwrap_or(false);
1711    if !is_owner {
1712        return Ok(());
1713    }
1714    let owners: i64 = conn.query_row(
1715        "SELECT COUNT(*) FROM memberships WHERE org = ?1 AND role = 'owner'",
1716        [org.as_str()],
1717        |r| r.get(0),
1718    )?;
1719    if owners <= 1 {
1720        return Err(AuthError::Conflict(format!(
1721            "cannot {verb} the last owner of org {org}; make someone else owner first"
1722        )));
1723    }
1724    Ok(())
1725}
1726
1727#[cfg(test)]
1728mod tests;