A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: multi-admin system with API key management and bootstrap

Trezy f2ae8226 66552c7b

+185 -27
+1
Cargo.lock
··· 668 668 "chrono", 669 669 "dotenvy", 670 670 "futures-util", 671 + "hex", 671 672 "jsonwebtoken", 672 673 "p256", 673 674 "reqwest",
+1
Cargo.toml
··· 23 23 tower-http = { version = "0.6", features = ["cors", "trace"] } 24 24 tracing = "0.1" 25 25 tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 26 + hex = "0.4.3"
+7
migrations/20260217000000_create_admins.sql
··· 1 + CREATE TABLE IF NOT EXISTS admins ( 2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 + name TEXT NOT NULL, 4 + api_key_hash TEXT NOT NULL, 5 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 6 + last_used_at TIMESTAMPTZ 7 + );
+174 -27
src/admin.rs
··· 1 1 use axum::extract::{Path, State}; 2 2 use axum::http::StatusCode; 3 - use axum::routing::{get, post}; 3 + use axum::routing::{delete, get, post}; 4 4 use axum::{Json, Router}; 5 5 use serde::{Deserialize, Serialize}; 6 6 use serde_json::Value; 7 + use sha2::{Digest, Sha256}; 7 8 8 9 use crate::error::AppError; 9 10 use crate::lexicon::{LexiconType, ParsedLexicon}; 10 11 use crate::AppState; 11 12 12 13 // --------------------------------------------------------------------------- 14 + // Helpers 15 + // --------------------------------------------------------------------------- 16 + 17 + /// SHA-256 hash a plaintext API key for storage/comparison. 18 + fn hash_api_key(key: &str) -> String { 19 + let hash = Sha256::digest(key.as_bytes()); 20 + hex::encode(hash) 21 + } 22 + 23 + // --------------------------------------------------------------------------- 13 24 // Admin auth middleware 14 25 // --------------------------------------------------------------------------- 15 26 16 - /// Axum middleware layer that rejects requests without a valid admin secret. 17 27 pub fn admin_routes(_state: AppState) -> Router<AppState> { 18 28 Router::new() 19 29 .route("/lexicons", post(upload_lexicon).get(list_lexicons)) ··· 21 31 .route("/stats", get(stats)) 22 32 .route("/backfill", post(create_backfill)) 23 33 .route("/backfill/status", get(backfill_status)) 24 - } 25 - 26 - /// Extract and validate the admin Bearer token from request headers. 27 - fn extract_admin_token(headers: &axum::http::HeaderMap, secret: &Option<String>) -> Result<(), AppError> { 28 - let secret = secret 29 - .as_ref() 30 - .ok_or_else(|| AppError::Auth("admin API is not configured".into()))?; 31 - 32 - let header = headers 33 - .get("authorization") 34 - .and_then(|v| v.to_str().ok()) 35 - .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?; 36 - 37 - let token = header 38 - .strip_prefix("Bearer ") 39 - .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?; 40 - 41 - if token != secret { 42 - return Err(AppError::Auth("invalid admin secret".into())); 43 - } 44 - 45 - Ok(()) 34 + .route("/admins", post(create_admin).get(list_admins)) 35 + .route("/admins/{id}", delete(delete_admin)) 46 36 } 47 37 48 - /// Axum extractor for admin auth — validates Bearer token against ADMIN_SECRET. 38 + /// Axum extractor for admin auth. Checks the Bearer token against: 39 + /// 1. The `admins` table (hashed key lookup) 40 + /// 2. Falls back to `ADMIN_SECRET` env var for bootstrap 49 41 pub struct AdminAuth; 50 42 51 43 impl axum::extract::FromRequestParts<AppState> for AdminAuth { ··· 55 47 parts: &mut axum::http::request::Parts, 56 48 state: &AppState, 57 49 ) -> Result<Self, Self::Rejection> { 58 - extract_admin_token(&parts.headers, &state.config.admin_secret)?; 59 - Ok(AdminAuth) 50 + let header = parts 51 + .headers 52 + .get("authorization") 53 + .and_then(|v| v.to_str().ok()) 54 + .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?; 55 + 56 + let token = header 57 + .strip_prefix("Bearer ") 58 + .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?; 59 + 60 + // Check admins table first 61 + let key_hash = hash_api_key(token); 62 + let found: Option<(String,)> = sqlx::query_as( 63 + "SELECT id::text FROM admins WHERE api_key_hash = $1", 64 + ) 65 + .bind(&key_hash) 66 + .fetch_optional(&state.db) 67 + .await 68 + .map_err(|e| AppError::Internal(format!("admin auth query failed: {e}")))?; 69 + 70 + if let Some((admin_id,)) = found { 71 + // Update last_used_at in the background 72 + let db = state.db.clone(); 73 + let admin_id = admin_id.clone(); 74 + tokio::spawn(async move { 75 + let _ = sqlx::query( 76 + "UPDATE admins SET last_used_at = NOW() WHERE id::text = $1", 77 + ) 78 + .bind(&admin_id) 79 + .execute(&db) 80 + .await; 81 + }); 82 + return Ok(AdminAuth); 83 + } 84 + 85 + // Fall back to ADMIN_SECRET env var 86 + if let Some(ref secret) = state.config.admin_secret { 87 + if token == secret { 88 + return Ok(AdminAuth); 89 + } 90 + } 91 + 92 + Err(AppError::Auth("invalid admin credentials".into())) 93 + } 94 + } 95 + 96 + /// Bootstrap: if no admins exist and ADMIN_SECRET is set, create a bootstrap admin. 97 + pub async fn bootstrap(db: &sqlx::PgPool, admin_secret: &Option<String>) { 98 + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM admins") 99 + .fetch_one(db) 100 + .await 101 + .unwrap_or((0,)); 102 + 103 + if count.0 > 0 { 104 + return; 105 + } 106 + 107 + if let Some(secret) = admin_secret { 108 + let key_hash = hash_api_key(secret); 109 + let _ = sqlx::query( 110 + "INSERT INTO admins (name, api_key_hash) VALUES ($1, $2) ON CONFLICT DO NOTHING", 111 + ) 112 + .bind("bootstrap") 113 + .bind(&key_hash) 114 + .execute(db) 115 + .await; 116 + tracing::info!("created bootstrap admin from ADMIN_SECRET"); 60 117 } 61 118 } 62 119 ··· 398 455 399 456 Ok(Json(jobs)) 400 457 } 458 + 459 + // --------------------------------------------------------------------------- 460 + // Admin management endpoints 461 + // --------------------------------------------------------------------------- 462 + 463 + #[derive(Deserialize)] 464 + struct CreateAdminBody { 465 + name: String, 466 + } 467 + 468 + #[derive(Serialize)] 469 + struct AdminSummary { 470 + id: String, 471 + name: String, 472 + created_at: chrono::DateTime<chrono::Utc>, 473 + last_used_at: Option<chrono::DateTime<chrono::Utc>>, 474 + } 475 + 476 + /// POST /admin/admins — create a new admin. Returns the API key once. 477 + async fn create_admin( 478 + State(state): State<AppState>, 479 + _admin: AdminAuth, 480 + Json(body): Json<CreateAdminBody>, 481 + ) -> Result<(StatusCode, Json<Value>), AppError> { 482 + let api_key = uuid::Uuid::new_v4().to_string(); 483 + let key_hash = hash_api_key(&api_key); 484 + 485 + let row: (String,) = sqlx::query_as( 486 + "INSERT INTO admins (name, api_key_hash) VALUES ($1, $2) RETURNING id::text", 487 + ) 488 + .bind(&body.name) 489 + .bind(&key_hash) 490 + .fetch_one(&state.db) 491 + .await 492 + .map_err(|e| AppError::Internal(format!("failed to create admin: {e}")))?; 493 + 494 + Ok(( 495 + StatusCode::CREATED, 496 + Json(serde_json::json!({ 497 + "id": row.0, 498 + "name": body.name, 499 + "api_key": api_key, 500 + })), 501 + )) 502 + } 503 + 504 + /// GET /admin/admins — list all admins (without keys). 505 + async fn list_admins( 506 + State(state): State<AppState>, 507 + _admin: AdminAuth, 508 + ) -> Result<Json<Vec<AdminSummary>>, AppError> { 509 + let rows: Vec<(String, String, chrono::DateTime<chrono::Utc>, Option<chrono::DateTime<chrono::Utc>>)> = 510 + sqlx::query_as( 511 + "SELECT id::text, name, created_at, last_used_at FROM admins ORDER BY created_at", 512 + ) 513 + .fetch_all(&state.db) 514 + .await 515 + .map_err(|e| AppError::Internal(format!("failed to list admins: {e}")))?; 516 + 517 + let admins: Vec<AdminSummary> = rows 518 + .into_iter() 519 + .map(|(id, name, created_at, last_used_at)| AdminSummary { 520 + id, 521 + name, 522 + created_at, 523 + last_used_at, 524 + }) 525 + .collect(); 526 + 527 + Ok(Json(admins)) 528 + } 529 + 530 + /// DELETE /admin/admins/:id — remove an admin. 531 + async fn delete_admin( 532 + State(state): State<AppState>, 533 + _admin: AdminAuth, 534 + Path(id): Path<String>, 535 + ) -> Result<StatusCode, AppError> { 536 + let result = sqlx::query("DELETE FROM admins WHERE id::text = $1") 537 + .bind(&id) 538 + .execute(&state.db) 539 + .await 540 + .map_err(|e| AppError::Internal(format!("failed to delete admin: {e}")))?; 541 + 542 + if result.rows_affected() == 0 { 543 + return Err(AppError::NotFound(format!("admin '{id}' not found"))); 544 + } 545 + 546 + Ok(StatusCode::NO_CONTENT) 547 + }
+2
src/main.rs
··· 49 49 .await 50 50 .expect("failed to run migrations"); 51 51 52 + admin::bootstrap(&db, &config.admin_secret).await; 53 + 52 54 let lexicons = LexiconRegistry::new(); 53 55 lexicons 54 56 .load_from_db(&db)