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(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 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 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 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#[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
388pub 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
432pub 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("SohHikVision123").unwrap();
468 assert!(verify_password("SohHikVision123", &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}