Skip to main content

heldar_kernel/
auth.rs

1//! Stage 4 authentication + RBAC.
2//!
3//! Two principal kinds carry a role: interactive **users** (password login → opaque bearer session)
4//! and machine **API keys** (worker ingest + external integration). Tokens are random 256-bit
5//! values; only their SHA-256 is stored, so a database leak does not expose usable credentials.
6//! Passwords are argon2id PHC hashes.
7//!
8//! The [`Principal`] extractor resolves the caller from the `Authorization: Bearer` (or `X-API-Key`)
9//! header. When `auth_enabled` is false (the default single-tenant LAN appliance mode) it yields a
10//! synthetic admin so the existing open API and tooling keep working; when true it requires a valid
11//! token and 401s otherwise. Handlers then assert capabilities with [`Principal::require`].
12
13use std::fmt::Write as _;
14
15use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier};
16use argon2::{password_hash::SaltString, Argon2};
17use axum::extract::FromRequestParts;
18use axum::http::header;
19use axum::http::request::Parts;
20use chrono::{DateTime, Duration, Utc};
21use rand_core::RngCore;
22use sha2::{Digest, Sha256};
23use sqlx::SqlitePool;
24
25use crate::config::Config;
26use crate::error::{AppError, AppResult};
27use crate::state::AppState;
28
29pub const SESSION_PREFIX: &str = "vos_";
30pub const APIKEY_PREFIX: &str = "vok_";
31
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum Role {
34    Admin,
35    Manager,
36    Guard,
37    Viewer,
38    Integration,
39}
40
41impl Role {
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            Role::Admin => "admin",
45            Role::Manager => "manager",
46            Role::Guard => "guard",
47            Role::Viewer => "viewer",
48            Role::Integration => "integration",
49        }
50    }
51    pub fn parse(s: &str) -> Option<Role> {
52        Some(match s {
53            "admin" => Role::Admin,
54            "manager" => Role::Manager,
55            "guard" => Role::Guard,
56            "viewer" => Role::Viewer,
57            "integration" => Role::Integration,
58            _ => return None,
59        })
60    }
61    pub fn is_valid(s: &str) -> bool {
62        Role::parse(s).is_some()
63    }
64}
65
66#[derive(Clone, Copy, PartialEq, Eq, Debug)]
67pub enum PrincipalKind {
68    User,
69    ApiKey,
70    System,
71}
72
73/// The resolved caller for a request.
74#[derive(Clone, Debug)]
75pub struct Principal {
76    pub id: String,
77    pub name: String,
78    pub role: Role,
79    pub kind: PrincipalKind,
80}
81
82impl Principal {
83    /// The implicit principal used when auth is disabled.
84    pub fn system_admin() -> Self {
85        Principal {
86            id: "system".into(),
87            name: "system".into(),
88            role: Role::Admin,
89            kind: PrincipalKind::System,
90        }
91    }
92
93    pub fn can_admin(&self) -> bool {
94        self.role == Role::Admin
95    }
96    /// Manage the registry: vehicles + watchlist.
97    pub fn can_manage_registry(&self) -> bool {
98        matches!(self.role, Role::Admin | Role::Manager)
99    }
100    /// Operate the gate: visitor check-in/out, create passes, confirm/reject entries.
101    pub fn can_operate_gate(&self) -> bool {
102        matches!(self.role, Role::Admin | Role::Manager | Role::Guard)
103    }
104    /// Post perception/ANPR events into the entry pipeline (machine clients + admins).
105    pub fn can_ingest(&self) -> bool {
106        matches!(self.role, Role::Admin | Role::Integration)
107    }
108    /// Read the entry surface. Every authenticated principal can read.
109    pub fn can_view(&self) -> bool {
110        true
111    }
112
113    /// Assert a capability, returning 403 with a useful message otherwise.
114    pub fn require(&self, allowed: bool, action: &str) -> AppResult<()> {
115        if allowed {
116            Ok(())
117        } else {
118            Err(AppError::Forbidden(format!(
119                "role `{}` is not permitted to {action}",
120                self.role.as_str()
121            )))
122        }
123    }
124}
125
126pub fn hex_encode(bytes: &[u8]) -> String {
127    let mut s = String::with_capacity(bytes.len() * 2);
128    for b in bytes {
129        let _ = write!(s, "{b:02x}");
130    }
131    s
132}
133
134/// SHA-256 hex of a token string — the at-rest representation of sessions / API keys.
135pub fn token_hash(token: &str) -> String {
136    let mut h = Sha256::new();
137    h.update(token.as_bytes());
138    hex_encode(&h.finalize())
139}
140
141/// Generate a prefixed 256-bit random token (the full secret returned to the caller once).
142pub fn random_token(prefix: &str) -> String {
143    let mut buf = [0u8; 32];
144    OsRng.fill_bytes(&mut buf);
145    format!("{prefix}{}", hex_encode(&buf))
146}
147
148pub fn hash_password(password: &str) -> anyhow::Result<String> {
149    let salt = SaltString::generate(&mut OsRng);
150    Argon2::default()
151        .hash_password(password.as_bytes(), &salt)
152        .map(|h| h.to_string())
153        .map_err(|e| anyhow::anyhow!("hashing password: {e}"))
154}
155
156pub fn verify_password(password: &str, phc: &str) -> bool {
157    match PasswordHash::new(phc) {
158        Ok(parsed) => Argon2::default()
159            .verify_password(password.as_bytes(), &parsed)
160            .is_ok(),
161        Err(_) => false,
162    }
163}
164
165/// A throwaway argon2id hash used to equalize login timing for unknown/disabled users (so the
166/// presence of an account cannot be inferred from response latency). Computed once, lazily.
167pub fn dummy_password_hash() -> &'static str {
168    static DUMMY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
169    DUMMY
170        .get_or_init(|| hash_password("timing-equalizer-not-a-real-credential").unwrap_or_default())
171}
172
173/// Issue a login session for a user, returning the bearer token (shown once) and its expiry.
174pub async fn issue_session(
175    pool: &SqlitePool,
176    cfg: &Config,
177    user_id: &str,
178) -> sqlx::Result<(String, DateTime<Utc>)> {
179    let token = random_token(SESSION_PREFIX);
180    let now = Utc::now();
181    let expires_at = now + Duration::hours(cfg.session_ttl_hours.max(1));
182    sqlx::query(
183        "INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at)
184         VALUES (?, ?, ?, ?, ?)",
185    )
186    .bind(token_hash(&token))
187    .bind(user_id)
188    .bind(now)
189    .bind(expires_at)
190    .bind(now)
191    .execute(pool)
192    .await?;
193    Ok((token, expires_at))
194}
195
196/// Revoke a session by its bearer token (idempotent).
197pub async fn revoke_session(pool: &SqlitePool, token: &str) -> sqlx::Result<()> {
198    sqlx::query("DELETE FROM sessions WHERE id = ?")
199        .bind(token_hash(token))
200        .execute(pool)
201        .await?;
202    Ok(())
203}
204
205/// Extract the bearer token from `Authorization: Bearer <t>` or the `X-API-Key` header.
206pub fn token_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
207    if let Some(h) = headers.get(header::AUTHORIZATION) {
208        if let Ok(s) = h.to_str() {
209            let s = s.trim();
210            if let Some(rest) = s
211                .strip_prefix("Bearer ")
212                .or_else(|| s.strip_prefix("bearer "))
213            {
214                let t = rest.trim();
215                if !t.is_empty() {
216                    return Some(t.to_string());
217                }
218            }
219        }
220    }
221    if let Some(h) = headers.get("x-api-key") {
222        if let Ok(s) = h.to_str() {
223            let t = s.trim();
224            if !t.is_empty() {
225                return Some(t.to_string());
226            }
227        }
228    }
229    // Browser session: the HttpOnly `heldar_session` cookie. Checked last so API clients/workers that
230    // present an explicit Bearer / X-API-Key header still take precedence.
231    if let Some(h) = headers.get(header::COOKIE) {
232        if let Ok(s) = h.to_str() {
233            let prefix = format!("{SESSION_COOKIE}=");
234            for part in s.split(';') {
235                if let Some(v) = part.trim().strip_prefix(&prefix) {
236                    let t = v.trim();
237                    if !t.is_empty() {
238                        return Some(t.to_string());
239                    }
240                }
241            }
242        }
243    }
244    None
245}
246
247/// Name of the HttpOnly session cookie set on login.
248pub const SESSION_COOKIE: &str = "heldar_session";
249
250/// Build the `Set-Cookie` value that stores a session token in an HttpOnly, SameSite=Strict cookie.
251/// HttpOnly keeps it unreadable to JS (no XSS exfiltration); SameSite=Strict blocks CSRF; the SPA is
252/// same-origin with the API so the cookie still reaches the media plane (`<img>`/`<video>`/HLS).
253pub fn session_cookie(token: &str, cfg: &Config) -> String {
254    let max_age = cfg.session_ttl_hours.max(1) * 3600;
255    let secure = if cfg.auth_cookie_secure {
256        "; Secure"
257    } else {
258        ""
259    };
260    format!(
261        "{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={max_age}{secure}"
262    )
263}
264
265/// Build the `Set-Cookie` value that clears the session cookie (logout).
266pub fn clear_session_cookie(cfg: &Config) -> String {
267    let secure = if cfg.auth_cookie_secure {
268        "; Secure"
269    } else {
270        ""
271    };
272    format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0{secure}")
273}
274
275/// Resolve a token to a principal, or None if it is unknown / expired / disabled.
276async fn resolve_token(pool: &SqlitePool, token: &str) -> AppResult<Option<Principal>> {
277    let hash = token_hash(token);
278    let now = Utc::now();
279    if token.starts_with(APIKEY_PREFIX) {
280        let row: Option<(String, String, String, bool)> =
281            sqlx::query_as("SELECT id, name, role, active FROM api_keys WHERE key_hash = ?")
282                .bind(&hash)
283                .fetch_optional(pool)
284                .await?;
285        if let Some((id, name, role, active)) = row {
286            if !active {
287                return Ok(None);
288            }
289            // An unparseable stored role means a corrupt/tampered row — deny rather than fail open
290            // to a capability-bearing default.
291            let Some(role) = Role::parse(&role) else {
292                tracing::error!(api_key = %id, role = %role, "auth: api key has unparseable role; denying");
293                return Ok(None);
294            };
295            // Best-effort last-used stamp (does not gate the request).
296            let _ = sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?")
297                .bind(now)
298                .bind(&id)
299                .execute(pool)
300                .await;
301            return Ok(Some(Principal {
302                id,
303                name,
304                role,
305                kind: PrincipalKind::ApiKey,
306            }));
307        }
308        return Ok(None);
309    }
310    // Otherwise treat as a session token.
311    let row: Option<SessionRow> = sqlx::query_as(
312        "SELECT s.id AS sid, s.expires_at, u.id AS uid, u.display_name, u.role, u.active
313           FROM sessions s JOIN users u ON u.id = s.user_id
314          WHERE s.id = ?",
315    )
316    .bind(&hash)
317    .fetch_optional(pool)
318    .await?;
319    if let Some(r) = row {
320        if r.expires_at <= now {
321            let _ = sqlx::query("DELETE FROM sessions WHERE id = ?")
322                .bind(&r.sid)
323                .execute(pool)
324                .await;
325            return Ok(None);
326        }
327        if !r.active {
328            return Ok(None);
329        }
330        let Some(role) = Role::parse(&r.role) else {
331            tracing::error!(user = %r.uid, role = %r.role, "auth: user has unparseable role; denying");
332            return Ok(None);
333        };
334        let _ = sqlx::query("UPDATE sessions SET last_used_at = ? WHERE id = ?")
335            .bind(now)
336            .bind(&r.sid)
337            .execute(pool)
338            .await;
339        return Ok(Some(Principal {
340            id: r.uid,
341            name: r.display_name.unwrap_or_default(),
342            role,
343            kind: PrincipalKind::User,
344        }));
345    }
346    Ok(None)
347}
348
349/// A session joined to its user, for token resolution.
350#[derive(sqlx::FromRow)]
351struct SessionRow {
352    sid: String,
353    expires_at: DateTime<Utc>,
354    uid: String,
355    display_name: Option<String>,
356    role: String,
357    active: bool,
358}
359
360impl FromRequestParts<AppState> for Principal {
361    type Rejection = AppError;
362
363    async fn from_request_parts(parts: &mut Parts, st: &AppState) -> Result<Self, Self::Rejection> {
364        match token_from_headers(&parts.headers) {
365            Some(tok) => match resolve_token(&st.pool, &tok).await? {
366                Some(p) => Ok(p),
367                None => {
368                    if st.cfg.auth_enabled {
369                        Err(AppError::Unauthorized(
370                            "invalid or expired credentials".into(),
371                        ))
372                    } else {
373                        Ok(Principal::system_admin())
374                    }
375                }
376            },
377            None => {
378                if st.cfg.auth_enabled {
379                    Err(AppError::Unauthorized("authentication required".into()))
380                } else {
381                    Ok(Principal::system_admin())
382                }
383            }
384        }
385    }
386}
387
388/// First-run bootstrap: when auth is enabled and no users exist yet, seed an admin from env.
389pub async fn ensure_bootstrap(pool: &SqlitePool, cfg: &Config) -> anyhow::Result<()> {
390    if !cfg.auth_enabled {
391        return Ok(());
392    }
393    let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
394        .fetch_one(pool)
395        .await?;
396    if count > 0 {
397        return Ok(());
398    }
399    match (&cfg.bootstrap_admin_user, &cfg.bootstrap_admin_password) {
400        (Some(user), Some(pass)) if !user.trim().is_empty() && pass.len() >= 8 => {
401            let hash = hash_password(pass)?;
402            let now = Utc::now();
403            sqlx::query(
404                "INSERT INTO users (id, username, password_hash, role, display_name, active, created_at, updated_at)
405                 VALUES (?, ?, ?, 'admin', ?, 1, ?, ?)",
406            )
407            .bind(format!("usr_{}", uuid::Uuid::new_v4().simple()))
408            .bind(user.trim())
409            .bind(hash)
410            .bind(user.trim())
411            .bind(now)
412            .bind(now)
413            .execute(pool)
414            .await?;
415            tracing::warn!(user = %user.trim(), "auth: bootstrapped initial admin user from env");
416        }
417        (Some(_), Some(_)) => {
418            tracing::error!(
419                "auth: HELDAR_BOOTSTRAP_ADMIN_PASSWORD must be >= 8 chars; no admin created"
420            );
421        }
422        _ => {
423            tracing::warn!(
424                "auth: enabled but no users exist and HELDAR_BOOTSTRAP_ADMIN_USER/PASSWORD not set; \
425                 login is impossible until a user is created (seed one via env then restart)"
426            );
427        }
428    }
429    Ok(())
430}
431
432/// Append an immutable audit-log entry (best-effort; never fails the caller).
433pub async fn audit(
434    pool: &SqlitePool,
435    actor: &Principal,
436    action: &str,
437    target_type: &str,
438    target_id: &str,
439    detail: serde_json::Value,
440) {
441    let res = sqlx::query(
442        "INSERT INTO audit_log (id, actor, actor_name, role, action, target_type, target_id, detail, created_at)
443         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
444    )
445    .bind(format!("aud_{}", uuid::Uuid::new_v4().simple()))
446    .bind(&actor.id)
447    .bind(&actor.name)
448    .bind(actor.role.as_str())
449    .bind(action)
450    .bind(target_type)
451    .bind(target_id)
452    .bind(sqlx::types::Json(detail))
453    .bind(Utc::now())
454    .execute(pool)
455    .await;
456    if let Err(e) = res {
457        tracing::error!(error = %e, action, "audit: failed to write audit log entry");
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn password_hash_roundtrip() {
467        let h = hash_password("correct-horse-battery-staple").unwrap();
468        assert!(verify_password("correct-horse-battery-staple", &h));
469        assert!(!verify_password("wrong", &h));
470    }
471
472    #[test]
473    fn token_hash_is_stable_and_distinct() {
474        assert_eq!(token_hash("abc"), token_hash("abc"));
475        assert_ne!(token_hash("abc"), token_hash("abd"));
476        assert_eq!(token_hash("abc").len(), 64);
477    }
478
479    #[test]
480    fn random_tokens_are_unique_and_prefixed() {
481        let a = random_token(SESSION_PREFIX);
482        let b = random_token(SESSION_PREFIX);
483        assert_ne!(a, b);
484        assert!(a.starts_with(SESSION_PREFIX));
485        assert_eq!(a.len(), SESSION_PREFIX.len() + 64);
486    }
487
488    #[test]
489    fn role_parse_roundtrip() {
490        for r in ["admin", "manager", "guard", "viewer", "integration"] {
491            assert_eq!(Role::parse(r).unwrap().as_str(), r);
492        }
493        assert!(Role::parse("root").is_none());
494    }
495
496    #[test]
497    fn capability_matrix() {
498        let admin = Principal {
499            role: Role::Admin,
500            ..Principal::system_admin()
501        };
502        let guard = Principal {
503            role: Role::Guard,
504            ..Principal::system_admin()
505        };
506        let integ = Principal {
507            role: Role::Integration,
508            ..Principal::system_admin()
509        };
510        assert!(admin.can_admin() && admin.can_ingest() && admin.can_manage_registry());
511        assert!(guard.can_operate_gate() && !guard.can_manage_registry() && !guard.can_admin());
512        assert!(integ.can_ingest() && !integ.can_operate_gate());
513    }
514}