Commit fe9ca02e by PLN (Algolia)

feat(douanier): SQLite bearer-token auth + scopes + douanier CLI (#23)

Replace the walking skeleton's hardcoded dev-token stub with a real,
self-hosted token store — the customs gate's papers check — keeping the dev
token as a documented local-only bootstrap.

WHY: hexa needs a proper 31-day scoped token (#33), and every future engine
endpoint needs per-engine authorization. Self-hosted SQLite (no vendor, same DB
that'll hold jobs/cache/usage) matches the 'self-host all' decision.

WHAT:
- db.py — stdlib sqlite3, WAL mode so the API + worker daemon read/write
  concurrently. Tables: accounts, tokens (token_hash, prefix, scopes,
  quota_json, expires_at, revoked). The DB is the one thing worth backing up;
  the artifact cache is rebuildable (noted in SRE.md).
- auth.py — mint() returns the plaintext ONCE and stores only sha256(token), so
  a DB leak isn't replayable. verify() is an indexed hash lookup → a Principal
  (account, scopes, expiry); checks revoked + expiry. has_scope() honors the '*'
  wildcard. Scope-gated dependency require_scope(scope) in app.py: 401
  unknown/expired/revoked, 403 scoped-out; scope=None = any valid token.
- douanier.py — admin CLI (token mint/list/revoke, db init); the only way tokens
  are created (no public signup). list masks to a 6-char prefix.
- /v1/me echoes the caller's identity (onboarding smoke test); /v1/analyze/emotion
  now requires the 'emotion' scope.

VALIDATION:
- 15/15 tests green (8 auth: mint→verify, wildcard, unknown→None, revoke,
  expiry, never-expires, only-hash-stored, dev-bootstrap; 7 smoke incl. /me
  guarded). No CLAP/torch needed; DB points at a temp file via conftest.
- CLI smoke end-to-end: mint prints secret once + masks in list, expiry lands
  exactly 31 days out (2026-07-26), revoke flips state to REVOKED.
parent 7f558a76
...@@ -33,10 +33,27 @@ DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \ ...@@ -33,10 +33,27 @@ DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \
``` ```
- `GET /v1/healthz` — status + engine availability - `GET /v1/healthz` — status + engine availability
- `GET /v1/me` — echo the calling token's account + scopes (onboarding check)
- `POST /v1/analyze/emotion``multipart` audio file → `{valence, arousal, top…}` - `POST /v1/analyze/emotion``multipart` audio file → `{valence, arousal, top…}`
(Bearer `DOUANIER_DEV_TOKEN` required) (Bearer token with the `emotion` or `*` scope)
- `GET /v1/docs` — Swagger UI - `GET /v1/docs` — Swagger UI
### Tokens (`douanier` CLI)
Real tokens live in SQLite (only their sha256 hash is stored). The dev one-liner
above uses `DOUANIER_DEV_TOKEN` as a local-only wildcard bootstrap; for anything
real, mint:
```bash
~/.virtualenvs/douanier/bin/python douanier.py token mint --account hexa --scopes '*' --ttl 31
~/.virtualenvs/douanier/bin/python douanier.py token list
~/.virtualenvs/douanier/bin/python douanier.py token revoke <id>
```
Scopes are space-separated engine names (`emotion features samples separate loops`)
or `*`. `--ttl never` for no expiry; `--metered` to enforce quota (default
unlimited; quota enforcement lands in #24).
```bash ```bash
curl -s -H "Authorization: Bearer $DOUANIER_DEV_TOKEN" \ curl -s -H "Authorization: Bearer $DOUANIER_DEV_TOKEN" \
-F file=@clip.wav http://127.0.0.1:8780/v1/analyze/emotion | jq -F file=@clip.wav http://127.0.0.1:8780/v1/analyze/emotion | jq
...@@ -50,12 +67,12 @@ cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q ...@@ -50,12 +67,12 @@ cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
## Status ## Status
Walking skeleton (tasks #22 + #36): scaffold + synchronous emotion endpoint with Done: scaffold + emotion endpoint (#22/#36), **SQLite bearer-token auth + scopes
stub auth. Queued, building *under* this without changing the public shape: + `douanier` CLI (#23)**. Queued, building *under* this without changing the
SQLite auth/quota (#23/#24), async jobs + worker (#25), content-addressed cache public shape: per-token quota (#24), async jobs + worker (#25),
(#26), signed artifact URLs (#27), `/features`+`/samples` hardened (#28), content-addressed cache (#26), signed artifact URLs (#27), `/features`+`/samples`
`/sources``/separate``/loops` heavy chain (#29–#31), OpenAPI client (#32), hardened (#28), `/sources``/separate``/loops` heavy chain (#29–#31), OpenAPI
ops/runbook (#34). client (#32), ops/runbook (#34).
> **Why a venv with `--system-site-packages`?** The ears/Foundry engines need the > **Why a venv with `--system-site-packages`?** The ears/Foundry engines need the
> multi-GB ML stack (torch/CLAP/librosa/demucs) already installed system-wide on > multi-GB ML stack (torch/CLAP/librosa/demucs) already installed system-wide on
......
...@@ -8,13 +8,13 @@ SQLite auth/quota/jobs/cache machinery (#23–#27) that hardens it later. ...@@ -8,13 +8,13 @@ SQLite auth/quota/jobs/cache machinery (#23–#27) that hardens it later.
run: cd armada/api && ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780 run: cd armada/api && ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
""" """
import secrets
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, APIRouter, UploadFile, File, Header, HTTPException, Depends from fastapi import FastAPI, APIRouter, UploadFile, File, Header, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import auth
import config import config
from engines import ears from engines import ears
...@@ -36,18 +36,23 @@ app.add_middleware( ...@@ -36,18 +36,23 @@ app.add_middleware(
v1 = APIRouter(prefix=f"/{config.VERSION}") v1 = APIRouter(prefix=f"/{config.VERSION}")
# ── stub auth (#36) — swapped for the SQLite token store in #23 ───────────────── # ── auth (#23) — SQLite bearer tokens + per-engine scopes ───────────────────────
def require_token(authorization: str = Header(default="")) -> str: def require_scope(scope: str | None):
"""Hardcoded bearer check. No DEV_TOKEN configured → 503 (fail closed, never """Dependency factory: resolve the bearer token to a Principal and (if `scope`
accidentally serve the engine unauthenticated).""" is given) assert it carries that scope or the '*' wildcard. scope=None means
if not config.DEV_TOKEN: "any valid token". 401 unknown/expired/revoked, 403 scoped out. The dev
raise HTTPException(503, "auth not configured (set DOUANIER_DEV_TOKEN)") bootstrap (DOUANIER_DEV_TOKEN) is a local-only wildcard."""
scheme, _, token = authorization.partition(" ") def dep(authorization: str = Header(default="")) -> auth.Principal:
if scheme.lower() != "bearer" or not token: kind, _, token = authorization.partition(" ")
raise HTTPException(401, "missing bearer token") if kind.lower() != "bearer" or not token:
if not secrets.compare_digest(token, config.DEV_TOKEN): raise HTTPException(401, "missing bearer token")
raise HTTPException(401, "invalid token") principal = auth.verify(token)
return token if principal is None:
raise HTTPException(401, "invalid, expired, or revoked token")
if scope is not None and not principal.has_scope(scope):
raise HTTPException(403, f"token lacks scope '{scope}'")
return principal
return dep
# ── health ────────────────────────────────────────────────────────────────── # ── health ──────────────────────────────────────────────────────────────────
...@@ -58,14 +63,22 @@ def healthz(): ...@@ -58,14 +63,22 @@ def healthz():
"service": config.SERVICE, "service": config.SERVICE,
"version": config.VERSION, "version": config.VERSION,
"status": "ok", "status": "ok",
"auth_configured": bool(config.DEV_TOKEN), "dev_token_set": bool(config.DEV_TOKEN),
"engines": {"emotion": {"available": ok, "hint": hint}}, "engines": {"emotion": {"available": ok, "hint": hint}},
} }
@v1.get("/me")
def whoami(principal: auth.Principal = Depends(require_scope("*"))):
"""Echo the calling token's identity — handy for onboarding smoke tests."""
return {"account": principal.account, "scopes": sorted(principal.scopes),
"expires_at": principal.expires_at}
# ── #36 walking-skeleton endpoint: synchronous emotion read ───────────────────── # ── #36 walking-skeleton endpoint: synchronous emotion read ─────────────────────
@v1.post("/analyze/emotion") @v1.post("/analyze/emotion")
async def analyze_emotion(file: UploadFile = File(...), _tok: str = Depends(require_token)): async def analyze_emotion(file: UploadFile = File(...),
_p: auth.Principal = Depends(require_scope("emotion"))):
"""Upload a short audio clip → valence/arousal + top emotions (CLAP). """Upload a short audio clip → valence/arousal + top emotions (CLAP).
Synchronous: no job queue, no cache yet (repeats recompute — fine for one Synchronous: no job queue, no cache yet (repeats recompute — fine for one
......
"""Auth — opaque bearer tokens in SQLite. The customs gate's papers check.
A token is a random URL-safe string handed out ONCE at mint time; we store only
its sha256 hash, so a DB leak can't be replayed as credentials. Verification is
an indexed hash lookup (the hash is the secret-derived key — no timing side
channel on the plaintext). Scopes gate per-engine access; '*' is the wildcard.
The DEV_TOKEN env stays a recognized wildcard bootstrap for local dev ONLY
(prod never sets it) so `uvicorn` + curl works without minting first.
"""
import hashlib
import secrets
import time
from dataclasses import dataclass
import config
import db
TTL_RE_DAYS = 86400.0
def hash_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode()).hexdigest()
@dataclass
class Principal:
"""Who's calling, resolved from a valid token."""
account: str
scopes: frozenset[str]
token_id: int | None # None for the dev bootstrap
expires_at: float | None
def has_scope(self, scope: str) -> bool:
return "*" in self.scopes or scope in self.scopes
# ── minting / management (used by the douanier CLI) ─────────────────────────────
def ensure_account(name: str, notes: str = "") -> int:
with db.cursor() as c:
row = c.execute("SELECT id FROM accounts WHERE name=?", (name,)).fetchone()
if row:
return row["id"]
cur = c.execute("INSERT INTO accounts(name, created, notes) VALUES(?,?,?)",
(name, time.time(), notes))
return cur.lastrowid
def mint(account: str, scopes: str = "*", ttl_days: float | None = 31,
unlimited: bool = True, notes: str = "") -> str:
"""Create a token; return the PLAINTEXT once (never recoverable after).
ttl_days=None → never expires. unlimited=True → quota_json NULL (no cap)."""
acc_id = ensure_account(account, notes)
plaintext = secrets.token_urlsafe(32)
expires = None if ttl_days is None else time.time() + ttl_days * TTL_RE_DAYS
quota = None if unlimited else "{}"
with db.cursor() as c:
c.execute(
"INSERT INTO tokens(token_hash, prefix, account_id, scopes, quota_json, "
"created, expires_at, revoked) VALUES(?,?,?,?,?,?,?,0)",
(hash_token(plaintext), plaintext[:6], acc_id, scopes.strip(), quota,
time.time(), expires))
return plaintext
def revoke(token_id: int) -> bool:
with db.cursor() as c:
cur = c.execute("UPDATE tokens SET revoked=1 WHERE id=?", (token_id,))
return cur.rowcount > 0
def list_tokens() -> list[dict]:
with db.cursor() as c:
rows = c.execute(
"SELECT t.id, t.prefix, a.name AS account, t.scopes, t.quota_json, "
"t.created, t.expires_at, t.revoked FROM tokens t "
"JOIN accounts a ON a.id=t.account_id ORDER BY t.id").fetchall()
return [dict(r) for r in rows]
# ── verification (the hot path) ─────────────────────────────────────────────────
def verify(plaintext: str) -> Principal | None:
"""Resolve a bearer token to a Principal, or None if invalid/expired/revoked."""
if not plaintext:
return None
# dev bootstrap: env wildcard, local only
if config.DEV_TOKEN and secrets.compare_digest(plaintext, config.DEV_TOKEN):
return Principal("dev", frozenset("*"), None, None)
with db.cursor() as c:
row = c.execute(
"SELECT t.id, a.name AS account, t.scopes, t.expires_at, t.revoked "
"FROM tokens t JOIN accounts a ON a.id=t.account_id WHERE t.token_hash=?",
(hash_token(plaintext),)).fetchone()
if not row or row["revoked"]:
return None
if row["expires_at"] is not None and row["expires_at"] < time.time():
return None
return Principal(row["account"], frozenset(row["scopes"].split()),
row["id"], row["expires_at"])
"""SQLite — the one local store for accounts, tokens (and later jobs, cache, usage).
Dependency-light by design (stdlib sqlite3). WAL mode so the API process and the
worker daemon can read/write concurrently without locking each other out. The DB
file (config.DB_PATH) is the ONE thing worth backing up — everything else
(artifact cache) is rebuildable. See SRE.md.
"""
import sqlite3
from contextlib import contextmanager
import config
SCHEMA = """
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
created REAL NOT NULL,
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS tokens (
id INTEGER PRIMARY KEY,
token_hash TEXT UNIQUE NOT NULL, -- sha256(plaintext); plaintext never stored
prefix TEXT NOT NULL, -- first chars of plaintext, for display only
account_id INTEGER NOT NULL REFERENCES accounts(id),
scopes TEXT NOT NULL DEFAULT '', -- space-separated; '*' = all engines
quota_json TEXT, -- NULL = unlimited (used by #24)
created REAL NOT NULL,
expires_at REAL, -- NULL = never
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash);
"""
def connect() -> sqlite3.Connection:
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(config.DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init() -> None:
with connect() as conn:
conn.executescript(SCHEMA)
@contextmanager
def cursor():
"""Auto-init + commit/rollback context."""
conn = connect()
try:
conn.executescript(SCHEMA) # idempotent; cheap (IF NOT EXISTS)
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
#!/usr/bin/env python3
"""douanier — admin CLI for the Audio-Intelligence API.
The ONLY way tokens are created (no public signup in MVP). Run with the service
venv so it shares config/db:
~/.virtualenvs/douanier/bin/python douanier.py token mint --account hexa --scopes '*' --ttl 31
~/.virtualenvs/douanier/bin/python douanier.py token list
~/.virtualenvs/douanier/bin/python douanier.py token revoke 3
~/.virtualenvs/douanier/bin/python douanier.py db init
"""
import argparse
import datetime as dt
import sys
import auth
import db
def _fmt_ts(ts):
if ts is None:
return "never"
return dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def cmd_token_mint(a):
ttl = None if a.ttl is None else float(a.ttl)
plaintext = auth.mint(a.account, scopes=a.scopes, ttl_days=ttl,
unlimited=not a.metered, notes=a.notes)
exp = "never" if ttl is None else f"{int(ttl)} days"
print(f"⛵ minted token for account '{a.account}' (scopes: {a.scopes}, "
f"expires: {exp}, quota: {'metered' if a.metered else 'unlimited'})")
print(f"\n {plaintext}\n")
print(" ↑ shown ONCE — store it now; only its hash is kept.")
def cmd_token_list(_a):
rows = auth.list_tokens()
if not rows:
print("(no tokens — mint one with: token mint --account <name>)")
return
print(f"{'id':>3} {'account':<12} {'prefix':<8} {'scopes':<10} "
f"{'expires':<11} {'quota':<9} state")
for r in rows:
state = "REVOKED" if r["revoked"] else "active"
quota = "unlimited" if r["quota_json"] is None else "metered"
print(f"{r['id']:>3} {r['account']:<12} {r['prefix']+'…':<8} "
f"{r['scopes']:<10} {_fmt_ts(r['expires_at']):<11} {quota:<9} {state}")
def cmd_token_revoke(a):
print("revoked" if auth.revoke(a.id) else f"no token #{a.id}")
def cmd_db_init(_a):
db.init()
print(f"initialized {db.config.DB_PATH}")
def main(argv=None):
p = argparse.ArgumentParser(prog="douanier", description=__doc__)
sub = p.add_subparsers(dest="group", required=True)
tok = sub.add_parser("token", help="manage API tokens").add_subparsers(
dest="cmd", required=True)
m = tok.add_parser("mint", help="create a token (prints plaintext once)")
m.add_argument("--account", required=True)
m.add_argument("--scopes", default="*", help="space-separated, or '*' (default)")
m.add_argument("--ttl", default="31", help="days until expiry, or 'never'")
m.add_argument("--metered", action="store_true", help="enforce quota (default: unlimited)")
m.add_argument("--notes", default="")
m.set_defaults(func=cmd_token_mint)
tok.add_parser("list", help="list tokens").set_defaults(func=cmd_token_list)
rv = tok.add_parser("revoke", help="revoke a token by id")
rv.add_argument("id", type=int)
rv.set_defaults(func=cmd_token_revoke)
d = sub.add_parser("db", help="database admin").add_subparsers(dest="cmd", required=True)
d.add_parser("init", help="create tables").set_defaults(func=cmd_db_init)
args = p.parse_args(argv)
if getattr(args, "ttl", None) in ("never", "none", "None"):
args.ttl = None
args.func(args)
if __name__ == "__main__":
sys.exit(main())
"""Test bootstrap — runs before any test module imports config.
Points the SQLite DB at a throwaway temp file and sets a known dev token, so
tests never touch the real _cache/douanier.db. Starts from a clean slate.
"""
import os
import sys
import tempfile
from pathlib import Path
_DB = Path(tempfile.gettempdir()) / "douanier_test.db"
os.environ["DOUANIER_DB"] = str(_DB)
os.environ.setdefault("DOUANIER_DEV_TOKEN", "test-secret")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# fresh DB each run (drop WAL/shm siblings too)
for p in (_DB, Path(str(_DB) + "-wal"), Path(str(_DB) + "-shm")):
if p.exists():
p.unlink()
"""Auth unit tests — mint/verify/revoke/expiry/scopes over a temp SQLite DB."""
import auth
def test_mint_returns_plaintext_and_verifies():
tok = auth.mint("acme", scopes="emotion features", ttl_days=31)
assert isinstance(tok, str) and len(tok) > 20
p = auth.verify(tok)
assert p is not None
assert p.account == "acme"
assert p.scopes == frozenset({"emotion", "features"})
assert p.has_scope("emotion") and not p.has_scope("separate")
def test_wildcard_scope():
tok = auth.mint("star", scopes="*", ttl_days=31)
p = auth.verify(tok)
assert p.has_scope("anything")
def test_unknown_token_is_none():
assert auth.verify("definitely-not-a-real-token") is None
assert auth.verify("") is None
def test_revoke_invalidates():
tok = auth.mint("rev", scopes="*", ttl_days=31)
tid = next(r["id"] for r in auth.list_tokens() if r["account"] == "rev")
assert auth.verify(tok) is not None
assert auth.revoke(tid) is True
assert auth.verify(tok) is None
def test_expired_token_is_none():
tok = auth.mint("expired", scopes="*", ttl_days=-1) # already in the past
assert auth.verify(tok) is None
def test_never_expires():
tok = auth.mint("forever", scopes="*", ttl_days=None)
p = auth.verify(tok)
assert p is not None and p.expires_at is None
def test_only_hash_is_stored():
tok = auth.mint("secrethash", scopes="*", ttl_days=31)
import db
with db.cursor() as c:
rows = [dict(r) for r in c.execute("SELECT token_hash, prefix FROM tokens")]
# the plaintext must never appear verbatim in storage
assert all(tok != r["token_hash"] for r in rows)
assert any(r["prefix"] == tok[:6] for r in rows)
def test_dev_bootstrap_token():
# conftest sets DOUANIER_DEV_TOKEN=test-secret → recognized as wildcard
p = auth.verify("test-secret")
assert p is not None and p.account == "dev" and p.has_scope("emotion")
"""Walking-skeleton smoke tests — no CLAP/torch required. """Walking-skeleton + API smoke tests — no CLAP/torch required.
Cover the seams that must hold before anything else: the app boots, /v1/healthz Cover the seams that must hold: the app boots, /v1/healthz answers, /me echoes
answers, and the stub auth fails closed / opens with the dev token. The actual identity, and auth fails closed / opens with a valid token. The actual emotion
emotion inference is exercised separately (slow, needs CLAP) — here we only inference is exercised separately (slow, needs CLAP) — here we only assert the
assert the route is wired & guarded, by monkeypatching the engine call. route is wired & guarded, by monkeypatching the engine call. Env/DB/path setup
is in conftest.py.
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
""" """
import os from fastapi.testclient import TestClient
import sys
from pathlib import Path
os.environ.setdefault("DOUANIER_DEV_TOKEN", "test-secret") import app as APP
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from engines import ears
from fastapi.testclient import TestClient # noqa: E402
import app as APP # noqa: E402
from engines import ears # noqa: E402
client = TestClient(APP.app) client = TestClient(APP.app)
AUTH = {"Authorization": "Bearer test-secret"} AUTH = {"Authorization": "Bearer test-secret"}
...@@ -29,7 +23,16 @@ def test_healthz_ok(): ...@@ -29,7 +23,16 @@ def test_healthz_ok():
body = r.json() body = r.json()
assert body["service"] == "douanier" and body["version"] == "v1" assert body["service"] == "douanier" and body["version"] == "v1"
assert "emotion" in body["engines"] assert "emotion" in body["engines"]
assert body["auth_configured"] is True
def test_me_echoes_identity():
r = client.get("/v1/me", headers=AUTH)
assert r.status_code == 200
assert r.json()["account"] == "dev"
def test_me_requires_token():
assert client.get("/v1/me").status_code == 401
def test_emotion_requires_token(): def test_emotion_requires_token():
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment