You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

5.8 KiB

+++ title = "Admin super auth code: delete any quote regardless of per-quote auth" priority = 7 status = "done" ticket_type = "feature" dependencies = [] +++

Feature

Add an admin super auth code — a single global passphrase that can delete (and update) any quote, bypassing the per-quote auth_code check. This allows the operator to moderate content without needing the original submitter's code.

The admin code is:

  • Generated once on first startup using the same 4-word passphrase generator (generate_auth_code in src/lib.rs).
  • Stored in the database in a new admin_config table.
  • Printed prominently to stderr on every startup so the operator can note it.
  • Never exposed via the API.

Part 1 — Database: new migration

Add a new migration constant in src/bin/api/db/migrations.rs:

/// Creates the admin_config key/value table for storing global configuration.
///
/// Stores a single row for the admin auth code under the 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
)";

Run this migration in QuoteRepository::run_migrations after the existing migrations. The implementation then seeds the admin auth code if absent (see Part 2).

Update infra/schema.sql to include:

CREATE TABLE IF NOT EXISTS admin_config (
  key   TEXT PRIMARY KEY,
  value TEXT NOT NULL
);

Part 2 — Repository trait: db/mod.rs

Add two new methods to QuoteRepository:

/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the table is empty (should not happen after migrations).
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;

/// Insert the admin auth code into `admin_config` if it is not already set.
///
/// Called once during startup, after `run_migrations`.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;

The startup sequence (in main.rs) becomes:

repo.run_migrations().await?;
// Seed admin code on first run
if repo.get_admin_auth_code().await?.is_none() {
    let code = quotesdb::generate_auth_code();
    repo.seed_admin_auth_code(&code).await?;
}
// Always print the admin code at startup
let admin_code = repo.get_admin_auth_code().await?.unwrap();
eprintln!("╔══════════════════════════════════════════════╗");
eprintln!("║  ADMIN AUTH CODE: {admin_code:<28}║");
eprintln!("╚══════════════════════════════════════════════╝");

Part 3 — Native implementation: db/native.rs

Implement get_admin_auth_code and seed_admin_auth_code using rusqlite.

Extend delete_quote to accept the admin code as a fallback:

async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
    // ... existing logic ...
    // Before returning Forbidden, check admin auth code
    let admin_code = self.get_admin_auth_code().await?;
    if Some(auth_code) == admin_code.as_deref() {
        // Admin code matches — delete unconditionally
        // ... execute DELETE without checking quotes.auth_code ...
        return Ok(DeleteResult::Deleted);
    }
    Ok(DeleteResult::Forbidden)
}

Similarly extend update_quote to allow admin override.

The cleanest approach is to refactor delete_quote and update_quote to first attempt the per-quote auth check, and if it fails, check against the admin code.


Part 4 — D1 implementation: db/d1.rs

Apply the same changes as Part 3 for the WASM/Cloudflare D1 path.


Part 5 — API startup: src/bin/api/main.rs

Update the startup sequence as shown in Part 2. The admin code print must be clearly visible in logs.


Part 6 — Mock repo in tests: handlers/mod.rs

Add stub implementations of get_admin_auth_code and seed_admin_auth_code to MockRepo:

async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
    Ok(None) // no admin code in tests by default
}
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
    Ok(())
}

Design notes

  • The admin code is never returned by any API endpoint — there is no way to discover it via HTTP.
  • The admin code is stored plaintext in admin_config, consistent with per-quote auth codes. This is acceptable given the stated security model (simple passphrase, no user accounts).
  • Only delete_quote and update_quote check the admin code. Read operations are unaffected.
  • The admin code is not rotatable via the API — an operator who needs to rotate it must manually update the database row.

Files touched

  • src/bin/api/db/migrations.rsCREATE_ADMIN_CONFIG constant
  • src/bin/api/db/mod.rs — two new trait methods + updated docstrings for delete_quote/update_quote
  • src/bin/api/db/native.rs — implementations + admin fallback logic
  • src/bin/api/db/d1.rs — same for D1
  • src/bin/api/handlers/mod.rsMockRepo stubs
  • src/bin/api/main.rs — seed + print admin code on startup
  • infra/schema.sqladmin_config table

Validation

# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test

Manual test:

  1. Start the server: cargo run
  2. Observe admin code printed to stderr.
  3. Create a quote: curl -X PUT http://localhost:3000/api/quotes -H 'Content-Type: application/json' -d '{"text":"Test","author":"A","tags":[]}'
  4. Try deleting with wrong code: should return 403.
  5. Try deleting with admin code: should return 204.
  6. Restart the server: same admin code should be printed (not regenerated).

Commit scope

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