Skip to main content

heldar_kernel/routes/
auth.rs

1//! Stage 4 auth + user/API-key administration.
2//!
3//! `/auth/login` exchanges username+password for a bearer session token; `/auth/logout` revokes it;
4//! `/auth/me` reports the caller. `/users` and `/api-keys` are admin-only management surfaces. All
5//! mutations are written to the immutable audit log.
6
7use axum::extract::{Path, State};
8use axum::http::{header, HeaderMap, StatusCode};
9use axum::response::{AppendHeaders, IntoResponse};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use chrono::Utc;
13use serde_json::{json, Value};
14use uuid::Uuid;
15
16use crate::auth::{self, Principal, Role};
17use crate::error::{AppError, AppResult};
18use crate::models::{
19    ApiKey, ApiKeyCreate, ApiKeyView, LoginRequest, User, UserCreate, UserUpdate, UserView,
20};
21use crate::state::AppState;
22
23pub fn router() -> Router<AppState> {
24    Router::new()
25        .route("/api/v1/auth/login", post(login))
26        .route("/api/v1/auth/logout", post(logout))
27        .route("/api/v1/auth/me", get(me))
28        .route("/api/v1/users", get(list_users).post(create_user))
29        .route(
30            "/api/v1/users/{id}",
31            axum::routing::patch(update_user).delete(delete_user),
32        )
33        .route("/api/v1/api-keys", get(list_api_keys).post(create_api_key))
34        .route(
35            "/api/v1/api-keys/{id}",
36            axum::routing::delete(delete_api_key),
37        )
38}
39
40const MIN_PASSWORD_LEN: usize = 8;
41
42async fn login(
43    State(st): State<AppState>,
44    Json(body): Json<LoginRequest>,
45) -> AppResult<impl IntoResponse> {
46    let candidate = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
47        .bind(body.username.trim())
48        .fetch_optional(&st.pool)
49        .await?;
50    // Always run argon2 verification (against a dummy hash when the user is missing/disabled) so
51    // login latency does not reveal whether an account exists. The error is uniform too.
52    let phc = candidate
53        .as_ref()
54        .map(|u| u.password_hash.as_str())
55        .unwrap_or_else(|| auth::dummy_password_hash());
56    let password_ok = auth::verify_password(&body.password, phc);
57    let user = match candidate {
58        Some(u) if u.active && password_ok => u,
59        _ => return Err(AppError::Unauthorized("invalid credentials".into())),
60    };
61    let (token, expires_at) = auth::issue_session(&st.pool, &st.cfg, &user.id).await?;
62    let principal = Principal {
63        id: user.id.clone(),
64        name: user
65            .display_name
66            .clone()
67            .unwrap_or_else(|| user.username.clone()),
68        role: Role::parse(&user.role).unwrap_or(Role::Viewer),
69        kind: crate::auth::PrincipalKind::User,
70    };
71    auth::audit(&st.pool, &principal, "login", "user", &user.id, json!({})).await;
72    // Set the session as an HttpOnly cookie (browser auth: not JS-readable, so XSS can't exfiltrate
73    // it; the media plane gets it automatically since the SPA is same-origin). The token is still in
74    // the body for non-browser clients; browsers should ignore it and rely on the cookie.
75    let cookie = auth::session_cookie(&token, &st.cfg);
76    let body = Json(json!({
77        "token": token,
78        "expires_at": expires_at,
79        "user": UserView::from(user),
80    }));
81    Ok((AppendHeaders([(header::SET_COOKIE, cookie)]), body))
82}
83
84async fn logout(State(st): State<AppState>, headers: HeaderMap) -> AppResult<impl IntoResponse> {
85    if let Some(tok) = auth::token_from_headers(&headers) {
86        auth::revoke_session(&st.pool, &tok).await?;
87    }
88    // Clear the session cookie regardless (idempotent logout).
89    let cookie = auth::clear_session_cookie(&st.cfg);
90    Ok((
91        StatusCode::NO_CONTENT,
92        AppendHeaders([(header::SET_COOKIE, cookie)]),
93    ))
94}
95
96async fn me(principal: Principal) -> AppResult<Json<Value>> {
97    Ok(Json(json!({
98        "id": principal.id,
99        "name": principal.name,
100        "role": principal.role.as_str(),
101        "kind": match principal.kind {
102            crate::auth::PrincipalKind::User => "user",
103            crate::auth::PrincipalKind::ApiKey => "api_key",
104            crate::auth::PrincipalKind::System => "system",
105        },
106    })))
107}
108
109async fn list_users(
110    State(st): State<AppState>,
111    principal: Principal,
112) -> AppResult<Json<Vec<UserView>>> {
113    principal.require(principal.can_admin(), "manage users")?;
114    let users = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY username ASC")
115        .fetch_all(&st.pool)
116        .await?;
117    Ok(Json(users.into_iter().map(UserView::from).collect()))
118}
119
120async fn create_user(
121    State(st): State<AppState>,
122    principal: Principal,
123    Json(body): Json<UserCreate>,
124) -> AppResult<(StatusCode, Json<UserView>)> {
125    principal.require(principal.can_admin(), "create users")?;
126    let username = body.username.trim();
127    if username.is_empty() {
128        return Err(AppError::BadRequest("`username` is required".into()));
129    }
130    if body.password.len() < MIN_PASSWORD_LEN {
131        return Err(AppError::BadRequest(format!(
132            "`password` must be at least {MIN_PASSWORD_LEN} characters"
133        )));
134    }
135    let role = body.role.as_deref().unwrap_or("viewer");
136    if !Role::is_valid(role) {
137        return Err(AppError::BadRequest(
138            "`role` must be admin|manager|guard|viewer|integration".into(),
139        ));
140    }
141    let hash = auth::hash_password(&body.password)?;
142    let id = format!("usr_{}", Uuid::new_v4().simple());
143    let now = Utc::now();
144    sqlx::query(
145        "INSERT INTO users (id, username, password_hash, role, display_name, active, created_at, updated_at)
146         VALUES (?,?,?,?,?,?,?,?)",
147    )
148    .bind(&id)
149    .bind(username)
150    .bind(hash)
151    .bind(role)
152    .bind(&body.display_name)
153    .bind(body.active.unwrap_or(true))
154    .bind(now)
155    .bind(now)
156    .execute(&st.pool)
157    .await?;
158    auth::audit(
159        &st.pool,
160        &principal,
161        "create_user",
162        "user",
163        &id,
164        json!({ "role": role }),
165    )
166    .await;
167    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
168        .bind(&id)
169        .fetch_one(&st.pool)
170        .await?;
171    Ok((StatusCode::CREATED, Json(UserView::from(user))))
172}
173
174async fn update_user(
175    State(st): State<AppState>,
176    principal: Principal,
177    Path(id): Path<String>,
178    Json(body): Json<UserUpdate>,
179) -> AppResult<Json<UserView>> {
180    principal.require(principal.can_admin(), "modify users")?;
181    let cur = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
182        .bind(&id)
183        .fetch_optional(&st.pool)
184        .await?
185        .ok_or_else(|| AppError::NotFound(format!("user {id} not found")))?;
186
187    let role = body.role.unwrap_or_else(|| cur.role.clone());
188    if !Role::is_valid(&role) {
189        return Err(AppError::BadRequest(
190            "`role` must be admin|manager|guard|viewer|integration".into(),
191        ));
192    }
193    let active = body.active.unwrap_or(cur.active);
194    // Guard against locking everyone out: do not let the last active admin be demoted/disabled.
195    if cur.role == "admin" && (role != "admin" || !active) {
196        let other_admins: i64 = sqlx::query_scalar(
197            "SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1 AND id != ?",
198        )
199        .bind(&id)
200        .fetch_one(&st.pool)
201        .await?;
202        if other_admins == 0 {
203            return Err(AppError::BadRequest(
204                "cannot demote or disable the last active admin".into(),
205            ));
206        }
207    }
208    let display_name = body.display_name.or(cur.display_name);
209    let password_hash = match body.password {
210        Some(p) if p.len() >= MIN_PASSWORD_LEN => auth::hash_password(&p)?,
211        Some(_) => {
212            return Err(AppError::BadRequest(format!(
213                "`password` must be at least {MIN_PASSWORD_LEN} characters"
214            )))
215        }
216        None => cur.password_hash,
217    };
218    sqlx::query(
219        "UPDATE users SET password_hash=?, role=?, display_name=?, active=?, updated_at=? WHERE id=?",
220    )
221    .bind(&password_hash)
222    .bind(&role)
223    .bind(&display_name)
224    .bind(active)
225    .bind(Utc::now())
226    .bind(&id)
227    .execute(&st.pool)
228    .await?;
229    // Revoke sessions if the account was disabled.
230    if !active {
231        let _ = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
232            .bind(&id)
233            .execute(&st.pool)
234            .await;
235    }
236    auth::audit(
237        &st.pool,
238        &principal,
239        "update_user",
240        "user",
241        &id,
242        json!({ "role": role, "active": active }),
243    )
244    .await;
245    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
246        .bind(&id)
247        .fetch_one(&st.pool)
248        .await?;
249    Ok(Json(UserView::from(user)))
250}
251
252async fn delete_user(
253    State(st): State<AppState>,
254    principal: Principal,
255    Path(id): Path<String>,
256) -> AppResult<StatusCode> {
257    principal.require(principal.can_admin(), "delete users")?;
258    if principal.id == id {
259        return Err(AppError::BadRequest(
260            "cannot delete your own account".into(),
261        ));
262    }
263    let cur = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
264        .bind(&id)
265        .fetch_optional(&st.pool)
266        .await?
267        .ok_or_else(|| AppError::NotFound(format!("user {id} not found")))?;
268    if cur.role == "admin" {
269        let other_admins: i64 = sqlx::query_scalar(
270            "SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1 AND id != ?",
271        )
272        .bind(&id)
273        .fetch_one(&st.pool)
274        .await?;
275        if other_admins == 0 {
276            return Err(AppError::BadRequest(
277                "cannot delete the last active admin".into(),
278            ));
279        }
280    }
281    sqlx::query("DELETE FROM users WHERE id = ?")
282        .bind(&id)
283        .execute(&st.pool)
284        .await?;
285    auth::audit(&st.pool, &principal, "delete_user", "user", &id, json!({})).await;
286    Ok(StatusCode::NO_CONTENT)
287}
288
289async fn list_api_keys(
290    State(st): State<AppState>,
291    principal: Principal,
292) -> AppResult<Json<Vec<ApiKeyView>>> {
293    principal.require(principal.can_admin(), "manage API keys")?;
294    let keys = sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys ORDER BY created_at DESC")
295        .fetch_all(&st.pool)
296        .await?;
297    Ok(Json(keys.into_iter().map(ApiKeyView::from).collect()))
298}
299
300async fn create_api_key(
301    State(st): State<AppState>,
302    principal: Principal,
303    Json(body): Json<ApiKeyCreate>,
304) -> AppResult<(StatusCode, Json<Value>)> {
305    principal.require(principal.can_admin(), "create API keys")?;
306    if body.name.trim().is_empty() {
307        return Err(AppError::BadRequest("`name` is required".into()));
308    }
309    let role = body.role.as_deref().unwrap_or("integration");
310    if !Role::is_valid(role) {
311        return Err(AppError::BadRequest(
312            "`role` must be admin|manager|guard|viewer|integration".into(),
313        ));
314    }
315    let key = auth::random_token(auth::APIKEY_PREFIX);
316    let prefix: String = key.chars().take(12).collect();
317    let id = format!("key_{}", Uuid::new_v4().simple());
318    sqlx::query(
319        "INSERT INTO api_keys (id, name, key_hash, key_prefix, role, active, created_at)
320         VALUES (?,?,?,?,?,1,?)",
321    )
322    .bind(&id)
323    .bind(body.name.trim())
324    .bind(auth::token_hash(&key))
325    .bind(&prefix)
326    .bind(role)
327    .bind(Utc::now())
328    .execute(&st.pool)
329    .await?;
330    auth::audit(
331        &st.pool,
332        &principal,
333        "create_api_key",
334        "api_key",
335        &id,
336        json!({ "role": role }),
337    )
338    .await;
339    // The full key is returned exactly once; only its hash is stored.
340    Ok((
341        StatusCode::CREATED,
342        Json(json!({ "id": id, "name": body.name.trim(), "role": role, "key": key })),
343    ))
344}
345
346async fn delete_api_key(
347    State(st): State<AppState>,
348    principal: Principal,
349    Path(id): Path<String>,
350) -> AppResult<StatusCode> {
351    principal.require(principal.can_admin(), "delete API keys")?;
352    let res = sqlx::query("DELETE FROM api_keys WHERE id = ?")
353        .bind(&id)
354        .execute(&st.pool)
355        .await?;
356    if res.rows_affected() == 0 {
357        return Err(AppError::NotFound(format!("api key {id} not found")));
358    }
359    auth::audit(
360        &st.pool,
361        &principal,
362        "delete_api_key",
363        "api_key",
364        &id,
365        json!({}),
366    )
367    .await;
368    Ok(StatusCode::NO_CONTENT)
369}