···2323tower-http = { version = "0.6", features = ["cors", "trace"] }
2424tracing = "0.1"
2525tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
2626+hex = "0.4.3"
+7
migrations/20260217000000_create_admins.sql
···11+CREATE TABLE IF NOT EXISTS admins (
22+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33+ name TEXT NOT NULL,
44+ api_key_hash TEXT NOT NULL,
55+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66+ last_used_at TIMESTAMPTZ
77+);
+174-27
src/admin.rs
···11use axum::extract::{Path, State};
22use axum::http::StatusCode;
33-use axum::routing::{get, post};
33+use axum::routing::{delete, get, post};
44use axum::{Json, Router};
55use serde::{Deserialize, Serialize};
66use serde_json::Value;
77+use sha2::{Digest, Sha256};
7889use crate::error::AppError;
910use crate::lexicon::{LexiconType, ParsedLexicon};
1011use crate::AppState;
11121213// ---------------------------------------------------------------------------
1414+// Helpers
1515+// ---------------------------------------------------------------------------
1616+1717+/// SHA-256 hash a plaintext API key for storage/comparison.
1818+fn hash_api_key(key: &str) -> String {
1919+ let hash = Sha256::digest(key.as_bytes());
2020+ hex::encode(hash)
2121+}
2222+2323+// ---------------------------------------------------------------------------
1324// Admin auth middleware
1425// ---------------------------------------------------------------------------
15261616-/// Axum middleware layer that rejects requests without a valid admin secret.
1727pub fn admin_routes(_state: AppState) -> Router<AppState> {
1828 Router::new()
1929 .route("/lexicons", post(upload_lexicon).get(list_lexicons))
···2131 .route("/stats", get(stats))
2232 .route("/backfill", post(create_backfill))
2333 .route("/backfill/status", get(backfill_status))
2424-}
2525-2626-/// Extract and validate the admin Bearer token from request headers.
2727-fn extract_admin_token(headers: &axum::http::HeaderMap, secret: &Option<String>) -> Result<(), AppError> {
2828- let secret = secret
2929- .as_ref()
3030- .ok_or_else(|| AppError::Auth("admin API is not configured".into()))?;
3131-3232- let header = headers
3333- .get("authorization")
3434- .and_then(|v| v.to_str().ok())
3535- .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?;
3636-3737- let token = header
3838- .strip_prefix("Bearer ")
3939- .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?;
4040-4141- if token != secret {
4242- return Err(AppError::Auth("invalid admin secret".into()));
4343- }
4444-4545- Ok(())
3434+ .route("/admins", post(create_admin).get(list_admins))
3535+ .route("/admins/{id}", delete(delete_admin))
4636}
47374848-/// Axum extractor for admin auth — validates Bearer token against ADMIN_SECRET.
3838+/// Axum extractor for admin auth. Checks the Bearer token against:
3939+/// 1. The `admins` table (hashed key lookup)
4040+/// 2. Falls back to `ADMIN_SECRET` env var for bootstrap
4941pub struct AdminAuth;
50425143impl axum::extract::FromRequestParts<AppState> for AdminAuth {
···5547 parts: &mut axum::http::request::Parts,
5648 state: &AppState,
5749 ) -> Result<Self, Self::Rejection> {
5858- extract_admin_token(&parts.headers, &state.config.admin_secret)?;
5959- Ok(AdminAuth)
5050+ let header = parts
5151+ .headers
5252+ .get("authorization")
5353+ .and_then(|v| v.to_str().ok())
5454+ .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?;
5555+5656+ let token = header
5757+ .strip_prefix("Bearer ")
5858+ .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?;
5959+6060+ // Check admins table first
6161+ let key_hash = hash_api_key(token);
6262+ let found: Option<(String,)> = sqlx::query_as(
6363+ "SELECT id::text FROM admins WHERE api_key_hash = $1",
6464+ )
6565+ .bind(&key_hash)
6666+ .fetch_optional(&state.db)
6767+ .await
6868+ .map_err(|e| AppError::Internal(format!("admin auth query failed: {e}")))?;
6969+7070+ if let Some((admin_id,)) = found {
7171+ // Update last_used_at in the background
7272+ let db = state.db.clone();
7373+ let admin_id = admin_id.clone();
7474+ tokio::spawn(async move {
7575+ let _ = sqlx::query(
7676+ "UPDATE admins SET last_used_at = NOW() WHERE id::text = $1",
7777+ )
7878+ .bind(&admin_id)
7979+ .execute(&db)
8080+ .await;
8181+ });
8282+ return Ok(AdminAuth);
8383+ }
8484+8585+ // Fall back to ADMIN_SECRET env var
8686+ if let Some(ref secret) = state.config.admin_secret {
8787+ if token == secret {
8888+ return Ok(AdminAuth);
8989+ }
9090+ }
9191+9292+ Err(AppError::Auth("invalid admin credentials".into()))
9393+ }
9494+}
9595+9696+/// Bootstrap: if no admins exist and ADMIN_SECRET is set, create a bootstrap admin.
9797+pub async fn bootstrap(db: &sqlx::PgPool, admin_secret: &Option<String>) {
9898+ let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM admins")
9999+ .fetch_one(db)
100100+ .await
101101+ .unwrap_or((0,));
102102+103103+ if count.0 > 0 {
104104+ return;
105105+ }
106106+107107+ if let Some(secret) = admin_secret {
108108+ let key_hash = hash_api_key(secret);
109109+ let _ = sqlx::query(
110110+ "INSERT INTO admins (name, api_key_hash) VALUES ($1, $2) ON CONFLICT DO NOTHING",
111111+ )
112112+ .bind("bootstrap")
113113+ .bind(&key_hash)
114114+ .execute(db)
115115+ .await;
116116+ tracing::info!("created bootstrap admin from ADMIN_SECRET");
60117 }
61118}
62119···398455399456 Ok(Json(jobs))
400457}
458458+459459+// ---------------------------------------------------------------------------
460460+// Admin management endpoints
461461+// ---------------------------------------------------------------------------
462462+463463+#[derive(Deserialize)]
464464+struct CreateAdminBody {
465465+ name: String,
466466+}
467467+468468+#[derive(Serialize)]
469469+struct AdminSummary {
470470+ id: String,
471471+ name: String,
472472+ created_at: chrono::DateTime<chrono::Utc>,
473473+ last_used_at: Option<chrono::DateTime<chrono::Utc>>,
474474+}
475475+476476+/// POST /admin/admins — create a new admin. Returns the API key once.
477477+async fn create_admin(
478478+ State(state): State<AppState>,
479479+ _admin: AdminAuth,
480480+ Json(body): Json<CreateAdminBody>,
481481+) -> Result<(StatusCode, Json<Value>), AppError> {
482482+ let api_key = uuid::Uuid::new_v4().to_string();
483483+ let key_hash = hash_api_key(&api_key);
484484+485485+ let row: (String,) = sqlx::query_as(
486486+ "INSERT INTO admins (name, api_key_hash) VALUES ($1, $2) RETURNING id::text",
487487+ )
488488+ .bind(&body.name)
489489+ .bind(&key_hash)
490490+ .fetch_one(&state.db)
491491+ .await
492492+ .map_err(|e| AppError::Internal(format!("failed to create admin: {e}")))?;
493493+494494+ Ok((
495495+ StatusCode::CREATED,
496496+ Json(serde_json::json!({
497497+ "id": row.0,
498498+ "name": body.name,
499499+ "api_key": api_key,
500500+ })),
501501+ ))
502502+}
503503+504504+/// GET /admin/admins — list all admins (without keys).
505505+async fn list_admins(
506506+ State(state): State<AppState>,
507507+ _admin: AdminAuth,
508508+) -> Result<Json<Vec<AdminSummary>>, AppError> {
509509+ let rows: Vec<(String, String, chrono::DateTime<chrono::Utc>, Option<chrono::DateTime<chrono::Utc>>)> =
510510+ sqlx::query_as(
511511+ "SELECT id::text, name, created_at, last_used_at FROM admins ORDER BY created_at",
512512+ )
513513+ .fetch_all(&state.db)
514514+ .await
515515+ .map_err(|e| AppError::Internal(format!("failed to list admins: {e}")))?;
516516+517517+ let admins: Vec<AdminSummary> = rows
518518+ .into_iter()
519519+ .map(|(id, name, created_at, last_used_at)| AdminSummary {
520520+ id,
521521+ name,
522522+ created_at,
523523+ last_used_at,
524524+ })
525525+ .collect();
526526+527527+ Ok(Json(admins))
528528+}
529529+530530+/// DELETE /admin/admins/:id — remove an admin.
531531+async fn delete_admin(
532532+ State(state): State<AppState>,
533533+ _admin: AdminAuth,
534534+ Path(id): Path<String>,
535535+) -> Result<StatusCode, AppError> {
536536+ let result = sqlx::query("DELETE FROM admins WHERE id::text = $1")
537537+ .bind(&id)
538538+ .execute(&state.db)
539539+ .await
540540+ .map_err(|e| AppError::Internal(format!("failed to delete admin: {e}")))?;
541541+542542+ if result.rows_affected() == 0 {
543543+ return Err(AppError::NotFound(format!("admin '{id}' not found")));
544544+ }
545545+546546+ Ok(StatusCode::NO_CONTENT)
547547+}
+2
src/main.rs
···4949 .await
5050 .expect("failed to run migrations");
51515252+ admin::bootstrap(&db, &config.admin_secret).await;
5353+5254 let lexicons = LexiconRegistry::new();
5355 lexicons
5456 .load_from_db(&db)