feat(quotesdb): admin super auth code for quote moderation

Add an admin_config table storing a single admin auth code that
bypasses per-quote auth checks for update and delete operations.
The code is auto-generated on first startup and printed to stderr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent bdf99b32c4
commit 267a95aa13

@ -24,3 +24,9 @@ CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE);
-- Index for efficient tag lookups by quote ID. -- Index for efficient tag lookups by quote ID.
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id); CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id);
-- Stores global configuration values, including the admin auth code.
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

@ -102,9 +102,11 @@ impl D1Repository {
#[async_trait::async_trait(?Send)] #[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository { impl QuoteRepository for D1Repository {
/// Run the four DDL migration statements from [`super::migrations`]. /// Run the five DDL migration statements from [`super::migrations`].
/// ///
/// Safe to call multiple times — all statements use `IF NOT EXISTS`. /// Covers `quotes`, `quote_tags`, tag index, author index, and
/// `admin_config`. Safe to call multiple times — all statements use
/// `IF NOT EXISTS`.
async fn run_migrations(&self) -> Result<(), DbError> { async fn run_migrations(&self) -> Result<(), DbError> {
use super::migrations::*; use super::migrations::*;
for sql in &[ for sql in &[
@ -112,6 +114,7 @@ impl QuoteRepository for D1Repository {
CREATE_QUOTE_TAGS, CREATE_QUOTE_TAGS,
CREATE_TAG_INDEX, CREATE_TAG_INDEX,
CREATE_AUTHOR_INDEX, CREATE_AUTHOR_INDEX,
CREATE_ADMIN_CONFIG,
] { ] {
self.db self.db
.exec(sql) .exec(sql)
@ -358,8 +361,14 @@ impl QuoteRepository for D1Repository {
match auth_row { match auth_row {
None => return Err(DbError::NotFound), None => return Err(DbError::NotFound),
Some(ref r) if r.auth_code != auth_code => return Err(DbError::Forbidden), Some(ref r) if r.auth_code == auth_code => {} // exact match, proceed
Some(_) => {} Some(_) => {
// Check admin code fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Err(DbError::Forbidden);
}
}
} }
// Phase 2: build dynamic SET clause with positional params // Phase 2: build dynamic SET clause with positional params
@ -465,9 +474,10 @@ impl QuoteRepository for D1Repository {
/// Delete a quote by ID after verifying the auth code. /// Delete a quote by ID after verifying the auth code.
/// ///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID, /// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if the auth code does not match, /// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// or [`DeleteResult::Deleted`] on success. Tags are removed automatically /// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// by the `ON DELETE CASCADE` constraint on `quote_tags`. /// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> { async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
// Fetch stored auth_code // Fetch stored auth_code
let auth_row = self let auth_row = self
@ -481,8 +491,16 @@ impl QuoteRepository for D1Repository {
match auth_row { match auth_row {
None => return Ok(DeleteResult::NotFound), None => return Ok(DeleteResult::NotFound),
Some(ref r) if r.auth_code != auth_code => return Ok(DeleteResult::Forbidden), Some(ref r) if r.auth_code == auth_code => {
Some(_) => {} // Per-quote auth matches — fall through to delete
}
Some(_) => {
// Check admin code as fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Ok(DeleteResult::Forbidden);
}
}
} }
self.db self.db
@ -495,4 +513,37 @@ impl QuoteRepository for D1Repository {
Ok(DeleteResult::Deleted) Ok(DeleteResult::Deleted)
} }
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
self.db
.prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'")
.first::<ValueRow>(None)
.await
.map(|opt| opt.map(|r| r.value))
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> {
self.db
.prepare(
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)",
)
.bind(&[JsValue::from_str(code)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
} }

@ -38,3 +38,12 @@ CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id)";
/// Creates a case-insensitive index on `quotes.author` for filter queries. /// Creates a case-insensitive index on `quotes.author` for filter queries.
pub const CREATE_AUTHOR_INDEX: &str = "\ pub const CREATE_AUTHOR_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)"; CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)";
/// Creates the `admin_config` key/value table for global configuration.
///
/// Stores a single row for the admin auth code under key `admin_auth_code`.
pub const CREATE_ADMIN_CONFIG: &str = "\
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)";

@ -85,6 +85,7 @@ pub enum DbError {
pub trait QuoteRepository { pub trait QuoteRepository {
/// Run `CREATE TABLE IF NOT EXISTS` migrations. /// Run `CREATE TABLE IF NOT EXISTS` migrations.
/// ///
/// Covers the `quotes`, `quote_tags`, index, and `admin_config` tables.
/// Must be called once on startup before any other operations. /// Must be called once on startup before any other operations.
async fn run_migrations(&self) -> Result<(), DbError>; async fn run_migrations(&self) -> Result<(), DbError>;
@ -132,7 +133,18 @@ pub trait QuoteRepository {
/// Delete a quote by ID. /// Delete a quote by ID.
/// ///
/// The `auth_code` header value must match `quotes.auth_code`. /// The `auth_code` header value must match `quotes.auth_code` or the
/// admin super auth code stored in `admin_config`.
/// Tags are removed automatically via `ON DELETE CASCADE`. /// Tags are removed automatically via `ON DELETE CASCADE`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>; async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
/// Store the admin auth code in `admin_config` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
} }

@ -56,16 +56,18 @@ fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec<String>) -> Result<Quote, rus
#[async_trait::async_trait] #[async_trait::async_trait]
impl QuoteRepository for NativeRepository { impl QuoteRepository for NativeRepository {
/// Run the four DDL migration statements from [`super::migrations`]. /// Run the five DDL migration statements from [`super::migrations`].
/// ///
/// Safe to call multiple times — all statements use `IF NOT EXISTS`. /// Covers `quotes`, `quote_tags`, tag index, author index, and
/// `admin_config`. Safe to call multiple times — all statements use
/// `IF NOT EXISTS`.
async fn run_migrations(&self) -> Result<(), DbError> { async fn run_migrations(&self) -> Result<(), DbError> {
self.conn self.conn
.call(|conn| { .call(|conn| {
use super::migrations::*; use super::migrations::*;
conn.execute_batch(&format!( conn.execute_batch(&format!(
"{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \ "{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \
{CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX};" {CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG};"
))?; ))?;
Ok(()) Ok(())
}) })
@ -324,8 +326,14 @@ impl QuoteRepository for NativeRepository {
match stored { match stored {
None => return Err(DbError::NotFound), None => return Err(DbError::NotFound),
Some(ref s) if s.as_str() != auth_code.as_str() => return Err(DbError::Forbidden), Some(ref s) if s.as_str() == auth_code.as_str() => {} // exact match, proceed
Some(_) => {} Some(_) => {
// Check admin code fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code.as_str()) {
return Err(DbError::Forbidden);
}
}
} }
// Phase 2: apply the update // Phase 2: apply the update
@ -390,9 +398,10 @@ impl QuoteRepository for NativeRepository {
/// Delete a quote by ID after verifying the auth code. /// Delete a quote by ID after verifying the auth code.
/// ///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID, /// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if the auth code does not match, /// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// or [`DeleteResult::Deleted`] on success. Tags are removed automatically /// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// by the `ON DELETE CASCADE` constraint on `quote_tags`. /// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> { async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
let id = id.to_owned(); let id = id.to_owned();
let auth_code = auth_code.to_owned(); let auth_code = auth_code.to_owned();
@ -408,13 +417,65 @@ impl QuoteRepository for NativeRepository {
.optional()?; .optional()?;
match stored { match stored {
None => Ok(DeleteResult::NotFound), None => return Ok(DeleteResult::NotFound),
Some(s) if s != auth_code => Ok(DeleteResult::Forbidden), Some(ref s) if s == &auth_code => {
Some(_) => { conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?;
return Ok(DeleteResult::Deleted);
}
Some(_) => {}
}
// Check admin code as fallback
let admin: Option<String> = conn
.query_row(
"SELECT value FROM admin_config WHERE key = 'admin_auth_code'",
[],
|row| row.get(0),
)
.optional()?;
if admin.as_deref() == Some(auth_code.as_str()) {
conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?; conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?;
Ok(DeleteResult::Deleted) Ok(DeleteResult::Deleted)
} else {
Ok(DeleteResult::Forbidden)
}
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
} }
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
self.conn
.call(|conn| {
let result: Option<String> = conn
.query_row(
"SELECT value FROM admin_config WHERE key = 'admin_auth_code'",
[],
|row| row.get(0),
)
.optional()?;
Ok(result)
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
} }
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> {
let code = code.to_owned();
self.conn
.call(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)",
rusqlite::params![code],
)?;
Ok(())
}) })
.await .await
.map_err(|e| DbError::Internal(e.to_string())) .map_err(|e| DbError::Internal(e.to_string()))

@ -382,6 +382,14 @@ mod tests {
} }
} }
} }
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
Ok(None)
}
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
Ok(())
}
} }
fn sample_quote() -> Quote { fn sample_quote() -> Quote {

@ -17,6 +17,9 @@ use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use db::QuoteRepository as _; use db::QuoteRepository as _;
#[cfg(not(target_arch = "wasm32"))]
use quotesdb::generate_auth_code;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@ -28,6 +31,27 @@ async fn main() {
repo.run_migrations().await.expect("migrations failed"); repo.run_migrations().await.expect("migrations failed");
// Seed admin auth code on first startup
if repo
.get_admin_auth_code()
.await
.expect("failed to check admin code")
.is_none()
{
let code = generate_auth_code();
repo.seed_admin_auth_code(&code)
.await
.expect("failed to seed admin code");
}
let admin_code = repo
.get_admin_auth_code()
.await
.expect("failed to read admin code")
.unwrap();
eprintln!("╔══════════════════════════════════════════════╗");
eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}║");
eprintln!("╚══════════════════════════════════════════════╝");
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo); let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);
let app = handlers::router(repo); let app = handlers::router(repo);
@ -75,6 +99,19 @@ pub async fn fetch(
.await .await
.map_err(|e| worker::Error::RustError(e.to_string()))?; .map_err(|e| worker::Error::RustError(e.to_string()))?;
// Seed admin auth code on first startup (no-op if already present).
if repo
.get_admin_auth_code()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?
.is_none()
{
let code = quotesdb::generate_auth_code();
repo.seed_admin_auth_code(&code)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
}
// Wrap in Arc so it can be shared across handlers via Axum state. // Wrap in Arc so it can be shared across handlers via Axum state.
// D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32. // D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32.
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo); let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);

Loading…
Cancel
Save