1use 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#[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 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 pub fn can_manage_registry(&self) -> bool {
98 matches!(self.role, Role::Admin | Role::Manager)
99 }
100 pub fn can_operate_gate(&self) -> bool {
102 matches!(self.role, Role::Admin | Role::Manager | Role::Guard)
103 }
104 pub fn can_ingest(&self) -> bool {
106 matches!(self.role, Role::Admin | Role::Integration)
107 }
108 pub fn can_view(&self) -> bool {
110 true
111 }
112
113 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
134pub 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
141pub 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
165pub 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
173pub 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
196pub 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
205pub 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 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
247pub const SESSION_COOKIE: &str = "heldar_session";
249
250pub 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
265pub 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
275async 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 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 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 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 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#[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
399pub 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
443pub 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}