From ab8b7acc346d81787eb11808a6dccbfd9bab0554 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 30 May 2026 21:26:26 +0200 Subject: [PATCH] feat(auth): admin role with cookie-only RequireAdmin extractor (0.37.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/Cargo.toml | 2 +- backend/migrations/0018_admin_role.sql | 5 + backend/src/app.rs | 7 + backend/src/auth/extractor.rs | 71 ++++++- backend/src/config.rs | 18 ++ backend/src/domain/user.rs | 1 + backend/src/repo/user.rs | 57 +++++- backend/tests/api_admin_role.rs | 257 +++++++++++++++++++++++++ frontend/package.json | 2 +- 9 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/0018_admin_role.sql create mode 100644 backend/tests/api_admin_role.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 20e3af9..a0fa23c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.36.7" +version = "0.37.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0018_admin_role.sql b/backend/migrations/0018_admin_role.sql new file mode 100644 index 0000000..22747dd --- /dev/null +++ b/backend/migrations/0018_admin_role.sql @@ -0,0 +1,5 @@ +-- Admin role flag on users. Booted from ADMIN_USERNAME / ADMIN_PASSWORD env at +-- startup (see app::build). Demotion is instant: the RequireAdmin extractor +-- re-reads the user row every request, so flipping this column takes effect on +-- the next call without a session purge. +ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/app.rs b/backend/src/app.rs index 4eeb2aa..f667586 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -60,6 +60,13 @@ pub async fn build(config: Config) -> anyhow::Result { .await?; sqlx::migrate!("./migrations").run(&db).await?; + if let Some((username, password)) = config.admin_bootstrap.as_ref() { + repo::user::bootstrap_admin(&db, username, password) + .await + .context("bootstrap_admin from ADMIN_USERNAME/ADMIN_PASSWORD env")?; + tracing::info!(admin_username = %username, "admin bootstrap ensured"); + } + let storage: Arc = Arc::new(LocalStorage::new(config.storage_dir.clone())); let daemon = if config.crawler.daemon_enabled { diff --git a/backend/src/auth/extractor.rs b/backend/src/auth/extractor.rs index 40d7bec..72b3755 100644 --- a/backend/src/auth/extractor.rs +++ b/backend/src/auth/extractor.rs @@ -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 ` 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 ` 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 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 for CurrentSessionUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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 for RequireAdmin { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let CurrentSessionUser(user) = + CurrentSessionUser::from_request_parts(parts, state).await?; + if !user.is_admin { + return Err(AppError::Forbidden); + } + Ok(RequireAdmin(user)) + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs index 28438d1..5851cb7 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -59,6 +59,13 @@ pub struct Config { pub upload: UploadConfig, pub cors_allowed_origins: Vec, pub crawler: CrawlerConfig, + /// `(username, password)` for the admin user provisioned at startup + /// when both `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set. `None` + /// skips the bootstrap entirely. See `repo::user::bootstrap_admin` + /// for the create-vs-promote semantics — notably the password here + /// is used only when creating a new row, never to overwrite an + /// existing one. + pub admin_bootstrap: Option<(String, String)>, } /// All crawler-daemon knobs read from env. Mirrors the env vars the @@ -158,10 +165,21 @@ impl Config { }) .unwrap_or_default(), crawler: CrawlerConfig::from_env()?, + admin_bootstrap: admin_bootstrap_from_env(), }) } } +/// Returns `Some((username, password))` only when BOTH `ADMIN_USERNAME` +/// and `ADMIN_PASSWORD` are set and non-empty. Half-set configuration is +/// treated as "no bootstrap" rather than a hard error, so an operator +/// can comment out one env var without crashing the server. +fn admin_bootstrap_from_env() -> Option<(String, String)> { + let username = std::env::var("ADMIN_USERNAME").ok().filter(|s| !s.is_empty())?; + let password = std::env::var("ADMIN_PASSWORD").ok().filter(|s| !s.is_empty())?; + Some((username, password)) +} + impl CrawlerConfig { pub fn from_env() -> anyhow::Result { // Parse CRAWLER_DAILY_AT (HH:MM, 24h). Invalid → fail fast. diff --git a/backend/src/domain/user.rs b/backend/src/domain/user.rs index 7a4bf6d..c81d1e3 100644 --- a/backend/src/domain/user.rs +++ b/backend/src/domain/user.rs @@ -10,4 +10,5 @@ pub struct User { #[serde(skip)] pub password_hash: String, pub created_at: DateTime, + pub is_admin: bool, } diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index 82d39c9..630c71a 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -11,7 +11,7 @@ pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppRe r#" INSERT INTO users (username, password_hash) VALUES ($1, $2) - RETURNING id, username, password_hash, created_at + RETURNING id, username, password_hash, created_at, is_admin "#, ) .bind(username) @@ -35,7 +35,7 @@ pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppRe pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult> { let row = sqlx::query_as::<_, User>( r#" - SELECT id, username, password_hash, created_at + SELECT id, username, password_hash, created_at, is_admin FROM users WHERE lower(username) = lower($1) "#, @@ -48,7 +48,7 @@ pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult