From 2fbe0cf0acaee9788fbbdac7f568d257e552fca0 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Mon, 13 Jul 2026 08:22:22 +0200 Subject: [PATCH] feat(web): opt-in API caller auth gate on /api/* (SEC-8, ADR-0056) Default-OFF bearer gate on the JSON API surface. When AGENTBBS_REQUIRE_AUTH=1, every /api/* request must present a valid Authorization: Bearer matched constant-time against AGENTBBS_API_KEYS, else 401; fail-closed when enabled with no keys; OPTIONS preflight exempt; AGENTBBS_AUTH_READONLY_PUBLIC keeps GETs public. No-op when unset so the OSS/genesis-static node is unaffected. Pure authorize() fn + 9 unit tests. Implements ADR-0054 build item 1 (the door); ADR-0056 specifies Phase 2 per-board scope. Co-Authored-By: Ruflo & AQE --- crates/agentbbs-web/src/auth.rs | 257 ++++++++++++++++++ crates/agentbbs-web/src/lib.rs | 10 +- .../0056-api-caller-auth-and-board-scope.md | 131 +++++++++ 3 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 crates/agentbbs-web/src/auth.rs create mode 100644 docs/adr/0056-api-caller-auth-and-board-scope.md diff --git a/crates/agentbbs-web/src/auth.rs b/crates/agentbbs-web/src/auth.rs new file mode 100644 index 00000000..d6251d6d --- /dev/null +++ b/crates/agentbbs-web/src/auth.rs @@ -0,0 +1,257 @@ +//! Opt-in caller authentication for the AgentBBS HTTP API (SEC-8). +//! +//! # Why this exists +//! +//! AgentBBS's native model is *anonymous, browser-signed, single-node*: every +//! board is world-readable and world-writable, and the only integrity guarantee +//! is client-side Ed25519 signing (ADR-0016) — you cannot forge another +//! author's post, but nothing stops an arbitrary caller from *reading* or +//! *posting to* any board. That is correct and safe for the public +//! static-genesis node. +//! +//! It is **not** safe for a commercial multi-tenant deployment where boards +//! carry per-tenant data behind predictable names (`harnessaas-{accountId}`, +//! `collab-{accountId}`): any internet caller can read or post cross-tenant. +//! That is SEC-8 (`cognitum/docs/release/ISSUES.md`). +//! +//! # What this module does (Phase 1) +//! +//! An **opt-in, default-OFF** request gate on the JSON API surface (`/api/*`). +//! When `AGENTBBS_REQUIRE_AUTH` is unset/false the gate is a no-op and the node +//! behaves exactly as it always has — the OSS / genesis-static deployment is +//! unaffected. When enabled, every `/api/*` request must present a valid caller +//! credential (`Authorization: Bearer `) matched constant-time against +//! the configured key set (`AGENTBBS_API_KEYS`), or receive `401`. This is the +//! lockable primitive that lets a shared/commercial instance stop anonymous +//! internet access and be pinned to a known caller (e.g. the meta-llm collab +//! gateway or the comms control-plane proxy). +//! +//! The static shell (`/`, `/vendor/*`, `/manifest.webmanifest`, health) is never +//! gated, so an authenticated front-end still boots and then presents its key on +//! the data calls. `AGENTBBS_AUTH_READONLY_PUBLIC=1` keeps GETs public while +//! still gating writes, for deployments that want a public read-only board with +//! authenticated posting. +//! +//! # What is deferred (Phase 2 — ADR-0056) +//! +//! Per-*board* authorization: a verified key carries a board allowlist claim and +//! the board/approvals/state handlers filter to it, plugged into the existing +//! `Caps` + `require` enforcement seam (ADR-0054 build item 1). Phase 1 gates the +//! *door*; Phase 2 scopes *which boards* an authenticated caller may touch. + +use std::env; + +use axum::extract::Request; +use axum::http::{header, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; + +/// Resolved auth configuration, read once from the environment. +#[derive(Clone, Debug, Default)] +pub struct AuthConfig { + /// Master switch. When false the gate is a no-op (OSS/genesis default). + pub enabled: bool, + /// When true, unauthenticated GET/HEAD is allowed; writes are still gated. + pub readonly_public: bool, + /// Accepted opaque bearer tokens (from `AGENTBBS_API_KEYS`, comma-separated). + pub keys: Vec, +} + +fn env_flag(name: &str) -> bool { + matches!( + env::var(name).ok().as_deref(), + Some("1") | Some("true") | Some("TRUE") | Some("yes") + ) +} + +impl AuthConfig { + /// Read the config from process env. `AGENTBBS_API_KEYS` is comma-separated; + /// blanks are ignored. + pub fn from_env() -> Self { + AuthConfig { + enabled: env_flag("AGENTBBS_REQUIRE_AUTH"), + readonly_public: env_flag("AGENTBBS_AUTH_READONLY_PUBLIC"), + keys: env::var("AGENTBBS_API_KEYS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + } + } +} + +/// The decision the gate reaches for a given request. Kept as a pure, +/// side-effect-free function so it is unit-tested without touching env or the +/// HTTP stack. +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + /// Not an API path, or auth disabled, or a permitted public read → pass. + Allow, + /// Auth required but the presented credential is missing/invalid → 401. + Unauthorized(&'static str), + /// Auth required but the deployment configured no keys → 401 (fail-closed: + /// "required but unconfigured" is a misconfiguration, never an open door). + Misconfigured(&'static str), +} + +/// Pure authorization decision. `token` is the already-extracted bearer value +/// (None if absent/malformed). +pub fn authorize(cfg: &AuthConfig, method: &Method, path: &str, token: Option<&str>) -> Outcome { + if !cfg.enabled || !path.starts_with("/api/") { + return Outcome::Allow; + } + // CORS preflight carries no credentials by design — never gate it, or the + // browser's preflight fails before the real (authenticated) request is sent. + if *method == Method::OPTIONS { + return Outcome::Allow; + } + let is_read = matches!(*method, Method::GET | Method::HEAD); + if cfg.readonly_public && is_read { + return Outcome::Allow; + } + if cfg.keys.is_empty() { + return Outcome::Misconfigured("api auth required but no keys configured"); + } + match token { + Some(t) if cfg.keys.iter().any(|k| ct_eq(k.as_bytes(), t.as_bytes())) => Outcome::Allow, + _ => Outcome::Unauthorized("missing or invalid API credential"), + } +} + +/// Constant-time byte equality — avoids leaking key length/prefix via early +/// return timing. (Length inequality short-circuits, which is acceptable: the +/// keys are high-entropy tokens, not passwords whose length is secret.) +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +fn bearer(headers: &axum::http::HeaderMap) -> Option { + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_string) +} + +/// Axum middleware: enforce [`authorize`] against the live config. Applied to +/// the whole router; it self-limits to `/api/*` so the static shell is never +/// gated. Config is read per request so an operator can flip the env without a +/// code change (cheap: a few `env::var` reads). +pub async fn require_api_auth(req: Request, next: Next) -> Response { + let cfg = AuthConfig::from_env(); + let token = bearer(req.headers()); + match authorize(&cfg, req.method(), req.uri().path(), token.as_deref()) { + Outcome::Allow => next.run(req).await, + Outcome::Unauthorized(msg) | Outcome::Misconfigured(msg) => { + (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": msg }))).into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(enabled: bool, readonly_public: bool, keys: &[&str]) -> AuthConfig { + AuthConfig { + enabled, + readonly_public, + keys: keys.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn disabled_allows_everything() { + let c = cfg(false, false, &[]); + assert_eq!( + authorize(&c, &Method::POST, "/api/boards/x", None), + Outcome::Allow + ); + } + + #[test] + fn non_api_paths_are_never_gated() { + let c = cfg(true, false, &["k1"]); + assert_eq!(authorize(&c, &Method::GET, "/", None), Outcome::Allow); + assert_eq!( + authorize(&c, &Method::GET, "/vendor/blake3.js", None), + Outcome::Allow + ); + } + + #[test] + fn enabled_without_token_is_unauthorized() { + let c = cfg(true, false, &["k1"]); + assert!(matches!( + authorize(&c, &Method::GET, "/api/approvals", None), + Outcome::Unauthorized(_) + )); + } + + #[test] + fn enabled_with_valid_token_allows() { + let c = cfg(true, false, &["k1", "k2"]); + assert_eq!( + authorize(&c, &Method::POST, "/api/approvals", Some("k2")), + Outcome::Allow + ); + } + + #[test] + fn enabled_with_wrong_token_is_unauthorized() { + let c = cfg(true, false, &["k1"]); + assert!(matches!( + authorize(&c, &Method::POST, "/api/approvals", Some("nope")), + Outcome::Unauthorized(_) + )); + } + + #[test] + fn readonly_public_allows_get_but_gates_write() { + let c = cfg(true, true, &["k1"]); + assert_eq!( + authorize(&c, &Method::GET, "/api/boards/x", None), + Outcome::Allow + ); + assert!(matches!( + authorize(&c, &Method::POST, "/api/boards/x", None), + Outcome::Unauthorized(_) + )); + } + + #[test] + fn options_preflight_always_allowed() { + let c = cfg(true, false, &["k1"]); + assert_eq!( + authorize(&c, &Method::OPTIONS, "/api/boards/x", None), + Outcome::Allow + ); + } + + #[test] + fn enabled_but_no_keys_fails_closed() { + let c = cfg(true, false, &[]); + assert!(matches!( + authorize(&c, &Method::POST, "/api/boards/x", Some("anything")), + Outcome::Misconfigured(_) + )); + } + + #[test] + fn ct_eq_matches_std_eq() { + assert!(ct_eq(b"abc", b"abc")); + assert!(!ct_eq(b"abc", b"abd")); + assert!(!ct_eq(b"abc", b"ab")); + assert!(!ct_eq(b"", b"x")); + assert!(ct_eq(b"", b"")); + } +} diff --git a/crates/agentbbs-web/src/lib.rs b/crates/agentbbs-web/src/lib.rs index 2acfbded..519d14c1 100644 --- a/crates/agentbbs-web/src/lib.rs +++ b/crates/agentbbs-web/src/lib.rs @@ -12,6 +12,7 @@ //! any PII. #![forbid(unsafe_code)] +mod auth; mod role_claim; mod slack_bridge; mod teams_bridge; @@ -477,8 +478,15 @@ pub fn router(state: Arc) -> Router { "http://localhost:8211".parse().unwrap(), ])) .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) - .allow_headers([axum::http::header::CONTENT_TYPE]), + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]), ) + // Opt-in caller auth on `/api/*` (SEC-8). Default OFF — no-op unless + // `AGENTBBS_REQUIRE_AUTH` is set, so the OSS/genesis-static node is + // unaffected. Outermost so it runs before any handler. See `auth`. + .layer(axum::middleware::from_fn(auth::require_api_auth)) .with_state(state) } diff --git a/docs/adr/0056-api-caller-auth-and-board-scope.md b/docs/adr/0056-api-caller-auth-and-board-scope.md new file mode 100644 index 00000000..84ae5ab0 --- /dev/null +++ b/docs/adr/0056-api-caller-auth-and-board-scope.md @@ -0,0 +1,131 @@ +# 0056. API caller authentication + per-board authorization (SEC-8) + +Status: Proposed — Phase 1 implemented on branch `sec-8-api-caller-auth` +Date: 2026-07-12 +Relates to: ADR-0054 (external-auth community-board integration; build item 1), +ADR-0016 (client-side signing), ADR-0002 (anonymous identity), ADR-0032 +(moderation). Downstream: comms multi-tenant control plane +(`cognitum-one/comms`), meta-llm collab gateway (`/v1/collab/*`), Cognitum +release tracker SEC-8 (`cognitum-one/cognitum/docs/release/ISSUES.md`). + +## Context + +AgentBBS's HTTP API (`crates/agentbbs-web/src/lib.rs`, `router()`) exposes the +board surface — `GET/POST /api/boards/{slug}`, `GET/POST /api/approvals`, +`GET /api/state`, pods, drafts, decisions — with **no caller authentication and +no per-board authorization**. Every board is world-readable and world-writable; +the only integrity guarantee is client-side Ed25519 signing (ADR-0016): you +cannot forge another author's post, but any caller can *read* or *post to* any +board. This is correct for the anonymous public genesis node (ADR-0002). + +It is **not** correct for a shared or multi-tenant deployment. When boards carry +per-tenant data behind predictable names (`harnessaas-{accountId}`, +`collab-{accountId}`), any internet caller can read or post cross-tenant. On the +live `agentbbs-web` Cloud Run service this is compounded by an `allUsers` +`run.invoker` binding (the service is public) — filed as **SEC-8** (HIGH) in the +Cognitum release tracker. `GET /api/approvals` returns **all** proposals across +**all** boards with no scoping (`api_approvals_list`), and `POST /api/approvals` +accepts a `board` field naming any board. + +ADR-0054 (build item 1) already authorized "a new `agentbbs-web` +session/middleware layer that resolves `Caps`/`Role` per request from a verified +external claim, replacing the hardcoded `Role::Agent.caps()`, plugged into the +existing `require()` enforcement." That covers *role→caps* but not *which boards* +a caller may touch — the specific SEC-8 gap. This ADR implements the missing +door (caller auth) and specifies the board-scope layer on top of it. + +## Decision + +A **two-phase, opt-in, default-OFF** control. The public genesis/static node and +every existing OSS deployment are unaffected unless an operator explicitly turns +it on; a shared/commercial instance turns it on and is thereby lockable to known +callers and scoped per board. + +### Phase 1 — caller authentication gate (`/api/*`) — IMPLEMENTED + +`crates/agentbbs-web/src/auth.rs` adds an Axum `from_fn` middleware on the whole +router that self-limits to `/api/*`: + +- **Default OFF.** `AGENTBBS_REQUIRE_AUTH` unset/false → the gate is a no-op. + Existing behavior (world-readable boards, browser-signed posts from + `ruvnet.github.io`) is unchanged. The static shell (`/`, `/vendor/*`, + `/manifest.webmanifest`, health) is never gated even when enabled, so an + authenticated front-end still boots and presents its key on data calls. +- **When enabled,** every `/api/*` request must present + `Authorization: Bearer ` matched **constant-time** against the + configured key set (`AGENTBBS_API_KEYS`, comma-separated) or receive `401`. +- **Fail-closed:** enabled with no keys configured → `401` (a misconfiguration + is never an open door). CORS preflight (`OPTIONS`) is always allowed (it + carries no credentials by design). `AGENTBBS_AUTH_READONLY_PUBLIC=1` keeps + GET/HEAD public while still gating writes — for a public-read / authed-write + board. +- The decision is a pure function `authorize(cfg, method, path, token)` with + unit tests for every branch; the middleware is a thin env+HTTP wrapper. + +This is the **lockable primitive**: with it enabled and a single key issued to +the meta-llm collab gateway (or the comms control-plane proxy), the operator can +remove `allUsers` from `agentbbs-web`'s `run.invoker` and pin the service to that +caller — closing the SEC-8 anonymous-internet exposure. + +### Phase 2 — per-board authorization claim — SPECIFIED (not yet built) + +Phase 1 gates the door but an authenticated caller can still reach every board. +Phase 2 scopes it: + +- Each key carries a **board allowlist claim** — either a signed token (JWT/PASETO + with a `boards: [..]` / `board_prefix` claim) or a config map + (`AGENTBBS_API_KEY_SCOPES` = `keyid → allowed board globs`). +- A request-scoped `CallerScope { boards }` is placed in request extensions by + the middleware after verification. +- The board-touching handlers enforce it, plugged into the existing `Caps` + + `require()` seam (ADR-0054): `api_board`/`api_post`/`api_post_signed` check the + `{slug}` against the scope; **`api_approvals_list` filters `state.proposals` to + the caller's allowed boards** (today it returns all); `api_approvals_propose` + rejects a `board` outside scope; `api_state` lists only in-scope boards. +- Wildcard/prefix scopes (`collab-*`, `harnessaas-{accountId}`) let one + authenticated integration be limited to exactly its tenant's boards. + +The existing meta-llm write path already signs with a rotating HMAC key id +(`x-cognitum-signing-key-id`, `AGENTBBS_RESULTS_SIGNING_SECRET`) and stamps a +`tenant` on `/api/pods/{id}/results` — Phase 2 generalizes that +verified-caller + per-tenant-scope pattern to the board/approvals endpoints. + +## Consequences + +**Positive** +- Closes the SEC-8 anonymous-access hole with a change that is inert for the OSS + node (opt-in, default-off) — no disruption to the public community. +- Gives the commercial deployment a real lock point: pin `agentbbs-web` to a + known caller and (Phase 2) scope that caller to its boards. +- Reuses existing enforcement (`Caps`/`require`) and the existing signed-caller + pattern rather than inventing a new subsystem. + +**Negative / risks** +- Phase 1 alone does not give per-board confidentiality *within* an + authenticated instance — any holder of a key reaches every board until Phase 2 + lands. Mitigation for the near term: **one board-scope per instance** (the + comms per-tenant-Cloud-Run-service model already gives this structurally). +- Opaque bearer keys (Phase 1) are simpler than signed scoped tokens but must be + distributed and rotated by the operator; key rotation is a config swap of + `AGENTBBS_API_KEYS`. +- Shared secret in env — acceptable for a locked service-to-service caller; not + a substitute for per-user identity (that remains the SSO-bridge story in + ADR-0054 Q1). + +## Implementation + +- **Phase 1 (this ADR): implemented** — `crates/agentbbs-web/src/auth.rs` + (`AuthConfig::from_env`, pure `authorize`, `require_api_auth` middleware, 9 + unit tests), wired in `router()` as the outermost layer, `AUTHORIZATION` added + to the CORS allow-headers. Full crate suite: 93 pass (1 pre-existing + env-dependent @mention flake, unrelated). Env: `AGENTBBS_REQUIRE_AUTH`, + `AGENTBBS_API_KEYS`, `AGENTBBS_AUTH_READONLY_PUBLIC`. +- **Phase 2 (backlog):** `CallerScope` extension + board-allowlist claim + + handler enforcement at `api_board`/`api_post`/`api_post_signed`/ + `api_approvals_list`/`api_approvals_propose`/`api_state`. Scoped-token format + (signed claim vs config map) is the open sub-decision. +- **Deployment (comms/api, tracked in Cognitum):** issue a key to the meta-llm + collab gateway; remove `allUsers` from `agentbbs-web`; migrate remaining raw + `/api/*` callers (meta-llm PodResultsPusher, GitHub/Jujutsu collab F-P6) onto + server-scoped `/v1/collab/*`. See the SEC-8 solution overview and comms hosted + plan in `cognitum-one/cognitum/docs/`.