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.

168 lines
5.8 KiB
Markdown

+++
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`:
```rust
/// 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:
```sql
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`:
```rust
/// 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:
```rust
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:
```rust
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`:
```rust
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.rs``CREATE_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.rs``MockRepo` stubs
- `src/bin/api/main.rs` — seed + print admin code on startup
- `infra/schema.sql``admin_config` table
## Validation
```sh
# 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`