diff --git a/README.md b/README.md index da3278f..7fcba83 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ duplicate comments. | `coven-code --headless` execution | Partial | Worker spawns headless sessions with a tokenless session brief and enforces task timeouts; result quality depends on the runtime. | | Worker container isolation | Implemented | `worker.backend = "container"` runs each attempt in a fresh hardened container (read-only rootfs, cap-drop ALL, cpu/memory/pids/tmpfs/network limits, env-only token injection, kill-by-name on timeout); hosted posture refuses host execution without explicit opt-in — see [docs/container-isolation.md](docs/container-isolation.md). | | Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. | -| CovenCave task polling | Implemented | Task API served from the durable store, survives restarts, and is gated by the tenant boundary — `token` mode fails closed, tenant tokens are installation-scoped, and every read is audited (see [docs/security.md](docs/security.md)). | +| CovenCave task polling | Implemented | Task API served from the durable store, survives restarts, and is gated by the tenant boundary — `token` mode fails closed, tenant tokens are installation-scoped, and authenticated reads are audited (see [docs/security.md](docs/security.md)). | | Cave oversight dashboard | Implemented | Tenant-scoped data behind the four Cave views: task history (`/api/github/tasks`), usage (`/api/github/usage`), familiar routing (`/api/github/routing`), and a task-lifecycle audit stream (`/api/github/audit`) — acceptance, execution/retries/timeout, and PR creation. | | Durable queue / task store | Implemented | Deliveries deduplicated by `X-GitHub-Delivery` before GitHub hears success; the SQLite `tasks` table is the queue (atomic claims, no drop path) and interrupted work is requeued at startup ([design](docs/durable-task-store.md)). | | Hosted tier | Planned | See [Hosted vs self-hosted](docs/hosted-vs-self-hosted.md). | diff --git a/crates/webhook/src/routes.rs b/crates/webhook/src/routes.rs index 85af861..c8c89c6 100644 --- a/crates/webhook/src/routes.rs +++ b/crates/webhook/src/routes.rs @@ -64,18 +64,14 @@ pub async fn healthz(State(state): State) -> impl IntoResponse { /// tenant boundary (issue #3). `token` mode fails closed; a tenant token sees /// only its own installation (optionally narrowed to repositories); the /// service token — and `open` mode, for local development — see everything. -/// Every read lands in the audit trail. +/// Authenticated reads land in the durable audit trail. Rejected requests are +/// logged without touching the shared store so unauthenticated traffic cannot +/// exhaust or contend on it. pub async fn list_tasks(State(state): State, headers: HeaderMap) -> impl IntoResponse { let action = "list_tasks"; let (caller, scope) = match authorize_api(&state.config.api, &headers) { ApiCaller::Denied => { - if let Err(e) = state - .store - .record_api_read("anonymous", "none", action, "denied") - .await - { - error!("api audit write failed: {e:#}"); - } + warn!(action, "unauthorized API request denied"); // Fail closed with a body that reveals nothing about what exists. return ( StatusCode::UNAUTHORIZED, @@ -135,18 +131,13 @@ pub async fn list_tasks(State(state): State, headers: HeaderMap) -> im /// GET /api/github/memory — memory activity for the tenant boundary (issue #6), /// so a customer can inspect what memory a familiar read from and attempted to /// write to their repositories. Same auth as `list_tasks`: `token` mode fails -/// closed, a tenant sees only its own installation, and every read is audited. +/// closed, a tenant sees only its own installation, and every authenticated +/// read is audited. pub async fn list_memory(State(state): State, headers: HeaderMap) -> impl IntoResponse { let action = "list_memory"; let (caller, scope) = match authorize_api(&state.config.api, &headers) { ApiCaller::Denied => { - if let Err(e) = state - .store - .record_api_read("anonymous", "none", action, "denied") - .await - { - error!("api audit write failed: {e:#}"); - } + warn!(action, "unauthorized API request denied"); return ( StatusCode::UNAUTHORIZED, Json(json!({"ok": false, "error": "unauthorized"})), @@ -222,13 +213,7 @@ pub async fn revoke_memory( let action = "revoke_memory"; let (caller, installation_id) = match authorize_api(&state.config.api, &headers) { ApiCaller::Denied => { - if let Err(e) = state - .store - .record_api_read("anonymous", "none", action, "denied") - .await - { - error!("api audit write failed: {e:#}"); - } + warn!(action, "unauthorized API request denied"); return ( StatusCode::UNAUTHORIZED, Json(json!({"ok": false, "error": "unauthorized"})), @@ -308,13 +293,7 @@ pub async fn usage(State(state): State, headers: HeaderMap) -> impl In let action = "usage"; let (caller, scope) = match authorize_api(&state.config.api, &headers) { ApiCaller::Denied => { - if let Err(e) = state - .store - .record_api_read("anonymous", "none", action, "denied") - .await - { - error!("api audit write failed: {e:#}"); - } + warn!(action, "unauthorized API request denied"); return ( StatusCode::UNAUTHORIZED, Json(json!({"ok": false, "error": "unauthorized"})), @@ -375,13 +354,7 @@ pub async fn audit(State(state): State, headers: HeaderMap) -> impl In let action = "audit"; let (caller, scope) = match authorize_api(&state.config.api, &headers) { ApiCaller::Denied => { - if let Err(e) = state - .store - .record_api_read("anonymous", "none", action, "denied") - .await - { - error!("api audit write failed: {e:#}"); - } + warn!(action, "unauthorized API request denied"); return ( StatusCode::UNAUTHORIZED, Json(json!({"ok": false, "error": "unauthorized"})), @@ -444,10 +417,7 @@ pub async fn routing( let action = "routing"; let (caller, installation_id) = match authorize_api(&state.config.api, &headers) { ApiCaller::Denied => { - let _ = state - .store - .record_api_read("anonymous", "none", action, "denied") - .await; + warn!(action, "unauthorized API request denied"); return ( StatusCode::UNAUTHORIZED, Json(json!({"ok": false, "error": "unauthorized"})), @@ -2376,13 +2346,10 @@ mod tenancy_tests { } let audit = state.store.api_audit_entries().await.expect("audit"); - assert_eq!(audit.len(), 2); - for (caller, scope, action, result) in audit { - assert_eq!(caller, "anonymous"); - assert_eq!(scope, "none"); - assert_eq!(action, "list_tasks"); - assert_eq!(result, "denied"); - } + assert!( + audit.is_empty(), + "unauthenticated requests must not write to the durable store: {audit:?}" + ); } #[tokio::test] diff --git a/docs/security.md b/docs/security.md index 75a2403..4a1161d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -86,7 +86,7 @@ Tenant-scoped data: - Task history, status, branch, PR, and Check Run links. - Optional familiar memory, if enabled by the customer. -The public task API must not return cross-installation data. `/api/github/tasks` is gated by the `[api]` config section (issue #3): `mode = "open"` keeps it unauthenticated for local development and Cave polling — never expose that publicly — while `mode = "token"` fails closed and requires bearer tokens. A `service_token` grants the operator full visibility; each `[[api.tenants]]` token is scoped server-side to one installation id (optionally narrowed to specific repositories). Unauthorized calls receive a uniform `401 unauthorized` that reveals nothing about existing data, and every read — allowed or denied — is recorded in the store's `api_audit` table with caller, scope, action, and result. +The public task API must not return cross-installation data. `/api/github/tasks` is gated by the `[api]` config section (issue #3): `mode = "open"` keeps it unauthenticated for local development and Cave polling — never expose that publicly — while `mode = "token"` fails closed and requires bearer tokens. A `service_token` grants the operator full visibility; each `[[api.tenants]]` token is scoped server-side to one installation id (optionally narrowed to specific repositories). Unauthorized calls receive a uniform `401 unauthorized` that reveals nothing about existing data and are emitted to the application log without writing to the shared durable store. Authenticated reads are recorded in the store's `api_audit` table with caller, scope, action, and result. ## Model and Memory Boundaries