feat(auth): admin role with cookie-only RequireAdmin extractor (0.37.0)
Adds an `is_admin` flag on users plus the substrate every later PR in the admin feature builds on: - migration 0018 adds the column with default false - `repo::user::bootstrap_admin` creates or promotes the user named by `ADMIN_USERNAME` at startup, hashing `ADMIN_PASSWORD` only when the row is new — never overwriting an existing hash, so an operator can rotate the admin password via the UI without env-var conflict - `CurrentSessionUser` extractor accepts only the session cookie; `RequireAdmin` composes over it and additionally requires `user.is_admin`. Bearer tokens are intentionally excluded so an admin's bot token never inherits admin authority (privilege-escalation surface that bites every "API keys reuse user perms" auth design) - demotion is instant: `RequireAdmin` re-reads the user row each request `/api/v1/auth/me` now exposes `is_admin`; no other response embeds `User`, so no privacy fanout to audit.
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
//! `CurrentUser` axum extractor.
|
||||
//! Auth extractors.
|
||||
//!
|
||||
//! Resolves a request to a logged-in user by trying, in order:
|
||||
//! 1. a `mangalord_session` cookie (session lookup by `sha256(value)`);
|
||||
//! 2. an `Authorization: Bearer <token>` header (api_token lookup).
|
||||
//! Three extractors are available, in increasing strictness:
|
||||
//!
|
||||
//! Both paths look up by hash, never by raw value. Failure to resolve
|
||||
//! either way returns 401 via `AppError::Unauthenticated`.
|
||||
//! - [`CurrentUser`] — accepts either a session cookie or an
|
||||
//! `Authorization: Bearer <token>` header. Used by ordinary
|
||||
//! authenticated endpoints where bot tokens are first-class clients.
|
||||
//! - [`CurrentSessionUser`] — accepts only the session cookie. Used as
|
||||
//! the substrate for admin extraction so bot tokens cannot authenticate
|
||||
//! as the admin (see [`RequireAdmin`]).
|
||||
//! - [`RequireAdmin`] — composes over [`CurrentSessionUser`] and
|
||||
//! additionally requires `user.is_admin`. Returns 403 for
|
||||
//! authenticated-but-not-admin, 401 otherwise.
|
||||
//!
|
||||
//! All lookups go by `sha256(raw_token)` — the raw value is never stored
|
||||
//! in the database.
|
||||
|
||||
use axum::async_trait;
|
||||
use axum::extract::FromRequestParts;
|
||||
@@ -61,3 +69,54 @@ impl FromRequestParts<AppState> for CurrentUser {
|
||||
Err(AppError::Unauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted
|
||||
/// here — this is the substrate for [`RequireAdmin`] and exists precisely
|
||||
/// to keep admin authority out of bearer-token reach.
|
||||
pub struct CurrentSessionUser(pub User);
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for CurrentSessionUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let jar = CookieJar::from_headers(&parts.headers);
|
||||
let cookie = jar
|
||||
.get(SESSION_COOKIE_NAME)
|
||||
.ok_or(AppError::Unauthenticated)?;
|
||||
let hash = hash_token(cookie.value());
|
||||
let session = repo::session::find_active(&state.db, &hash)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthenticated)?;
|
||||
let user = repo::user::find_by_id(&state.db, session.user_id)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthenticated)?;
|
||||
Ok(CurrentSessionUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin-only. Composes over [`CurrentSessionUser`] so bot tokens are
|
||||
/// rejected at the auth step (401) rather than the role step (403).
|
||||
/// The user row is re-read every request, so demotion takes effect on
|
||||
/// the very next call without needing to purge sessions.
|
||||
pub struct RequireAdmin(pub User);
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for RequireAdmin {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let CurrentSessionUser(user) =
|
||||
CurrentSessionUser::from_request_parts(parts, state).await?;
|
||||
if !user.is_admin {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
Ok(RequireAdmin(user))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user