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 / idle-timed-out / disabled.
276/// `idle_minutes > 0` rejects a session unused for longer than that (independent of its absolute TTL).
277async fn resolve_token(
278    pool: &SqlitePool,
279    token: &str,
280    idle_minutes: i64,
281) -> AppResult<Option<Principal>> {
282    let hash = token_hash(token);
283    let now = Utc::now();
284    if token.starts_with(APIKEY_PREFIX) {
285        let row: Option<(String, String, String, bool)> =
286            sqlx::query_as("SELECT id, name, role, active FROM api_keys WHERE key_hash = ?")
287                .bind(&hash)
288                .fetch_optional(pool)
289                .await?;
290        if let Some((id, name, role, active)) = row {
291            if !active {
292                return Ok(None);
293            }
294            // An unparseable stored role means a corrupt/tampered row — deny rather than fail open
295            // to a capability-bearing default.
296            let Some(role) = Role::parse(&role) else {
297                tracing::error!(api_key = %id, role = %role, "auth: api key has unparseable role; denying");
298                return Ok(None);
299            };
300            // Best-effort last-used stamp (does not gate the request).
301            let _ = sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?")
302                .bind(now)
303                .bind(&id)
304                .execute(pool)
305                .await;
306            return Ok(Some(Principal {
307                id,
308                name,
309                role,
310                kind: PrincipalKind::ApiKey,
311            }));
312        }
313        return Ok(None);
314    }
315    // Otherwise treat as a session token.
316    let row: Option<SessionRow> = sqlx::query_as(
317        "SELECT s.id AS sid, s.expires_at, s.last_used_at, u.id AS uid, u.display_name, u.role, u.active
318           FROM sessions s JOIN users u ON u.id = s.user_id
319          WHERE s.id = ?",
320    )
321    .bind(&hash)
322    .fetch_optional(pool)
323    .await?;
324    if let Some(r) = row {
325        // Absolute TTL, then idle timeout — either drops the session.
326        let idle_expired =
327            idle_minutes > 0 && r.last_used_at < now - Duration::minutes(idle_minutes);
328        if r.expires_at <= now || idle_expired {
329            let _ = sqlx::query("DELETE FROM sessions WHERE id = ?")
330                .bind(&r.sid)
331                .execute(pool)
332                .await;
333            return Ok(None);
334        }
335        if !r.active {
336            return Ok(None);
337        }
338        let Some(role) = Role::parse(&r.role) else {
339            tracing::error!(user = %r.uid, role = %r.role, "auth: user has unparseable role; denying");
340            return Ok(None);
341        };
342        let _ = sqlx::query("UPDATE sessions SET last_used_at = ? WHERE id = ?")
343            .bind(now)
344            .bind(&r.sid)
345            .execute(pool)
346            .await;
347        return Ok(Some(Principal {
348            id: r.uid,
349            name: r.display_name.unwrap_or_default(),
350            role,
351            kind: PrincipalKind::User,
352        }));
353    }
354    Ok(None)
355}
356
357/// A session joined to its user, for token resolution.
358#[derive(sqlx::FromRow)]
359struct SessionRow {
360    sid: String,
361    expires_at: DateTime<Utc>,
362    last_used_at: DateTime<Utc>,
363    uid: String,
364    display_name: Option<String>,
365    role: String,
366    active: bool,
367}
368
369impl FromRequestParts<AppState> for Principal {
370    type Rejection = AppError;
371
372    async fn from_request_parts(parts: &mut Parts, st: &AppState) -> Result<Self, Self::Rejection> {
373        match token_from_headers(&parts.headers) {
374            Some(tok) => {
375                match resolve_token(&st.pool, &tok, st.cfg.session_idle_timeout_minutes).await? {
376                    Some(p) => Ok(p),
377                    None => {
378                        if st.cfg.auth_enabled {
379                            Err(AppError::Unauthorized(
380                                "invalid or expired credentials".into(),
381                            ))
382                        } else {
383                            Ok(Principal::system_admin())
384                        }
385                    }
386                }
387            }
388            None => {
389                if st.cfg.auth_enabled {
390                    Err(AppError::Unauthorized("authentication required".into()))
391                } else {
392                    Ok(Principal::system_admin())
393                }
394            }
395        }
396    }
397}
398
399/// First-run bootstrap: when auth is enabled and no users exist yet, seed an admin from env.
400pub async fn ensure_bootstrap(pool: &SqlitePool, cfg: &Config) -> anyhow::Result<()> {
401    if !cfg.auth_enabled {
402        return Ok(());
403    }
404    let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
405        .fetch_one(pool)
406        .await?;
407    if count > 0 {
408        return Ok(());
409    }
410    match (&cfg.bootstrap_admin_user, &cfg.bootstrap_admin_password) {
411        (Some(user), Some(pass)) if !user.trim().is_empty() && pass.len() >= 8 => {
412            let hash = hash_password(pass)?;
413            let now = Utc::now();
414            sqlx::query(
415                "INSERT INTO users (id, username, password_hash, role, display_name, active, created_at, updated_at)
416                 VALUES (?, ?, ?, 'admin', ?, 1, ?, ?)",
417            )
418            .bind(format!("usr_{}", uuid::Uuid::new_v4().simple()))
419            .bind(user.trim())
420            .bind(hash)
421            .bind(user.trim())
422            .bind(now)
423            .bind(now)
424            .execute(pool)
425            .await?;
426            tracing::warn!(user = %user.trim(), "auth: bootstrapped initial admin user from env");
427        }
428        (Some(_), Some(_)) => {
429            tracing::error!(
430                "auth: HELDAR_BOOTSTRAP_ADMIN_PASSWORD must be >= 8 chars; no admin created"
431            );
432        }
433        _ => {
434            tracing::warn!(
435                "auth: enabled but no users exist and HELDAR_BOOTSTRAP_ADMIN_USER/PASSWORD not set; \
436                 login is impossible until a user is created (seed one via env then restart)"
437            );
438        }
439    }
440    Ok(())
441}
442
443/// Append an immutable audit-log entry (best-effort; never fails the caller).
444pub async fn audit(
445    pool: &SqlitePool,
446    actor: &Principal,
447    action: &str,
448    target_type: &str,
449    target_id: &str,
450    detail: serde_json::Value,
451) {
452    let res = sqlx::query(
453        "INSERT INTO audit_log (id, actor, actor_name, role, action, target_type, target_id, detail, created_at)
454         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
455    )
456    .bind(format!("aud_{}", uuid::Uuid::new_v4().simple()))
457    .bind(&actor.id)
458    .bind(&actor.name)
459    .bind(actor.role.as_str())
460    .bind(action)
461    .bind(target_type)
462    .bind(target_id)
463    .bind(sqlx::types::Json(detail))
464    .bind(Utc::now())
465    .execute(pool)
466    .await;
467    if let Err(e) = res {
468        tracing::error!(error = %e, action, "audit: failed to write audit log entry");
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn password_hash_roundtrip() {
478        let h = hash_password("correct-horse-battery-staple").unwrap();
479        assert!(verify_password("correct-horse-battery-staple", &h));
480        assert!(!verify_password("wrong", &h));
481    }
482
483    #[test]
484    fn token_hash_is_stable_and_distinct() {
485        assert_eq!(token_hash("abc"), token_hash("abc"));
486        assert_ne!(token_hash("abc"), token_hash("abd"));
487        assert_eq!(token_hash("abc").len(), 64);
488    }
489
490    #[test]
491    fn random_tokens_are_unique_and_prefixed() {
492        let a = random_token(SESSION_PREFIX);
493        let b = random_token(SESSION_PREFIX);
494        assert_ne!(a, b);
495        assert!(a.starts_with(SESSION_PREFIX));
496        assert_eq!(a.len(), SESSION_PREFIX.len() + 64);
497    }
498
499    #[test]
500    fn role_parse_roundtrip() {
501        for r in ["admin", "manager", "guard", "viewer", "integration"] {
502            assert_eq!(Role::parse(r).unwrap().as_str(), r);
503        }
504        assert!(Role::parse("root").is_none());
505    }
506
507    #[test]
508    fn capability_matrix() {
509        let admin = Principal {
510            role: Role::Admin,
511            ..Principal::system_admin()
512        };
513        let guard = Principal {
514            role: Role::Guard,
515            ..Principal::system_admin()
516        };
517        let integ = Principal {
518            role: Role::Integration,
519            ..Principal::system_admin()
520        };
521        assert!(admin.can_admin() && admin.can_ingest() && admin.can_manage_registry());
522        assert!(guard.can_operate_gate() && !guard.can_manage_registry() && !guard.can_admin());
523        assert!(integ.can_ingest() && !integ.can_operate_gate());
524    }
525}