diff --git a/README.md b/README.md index 8f7cbd8..735f195 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ duplicate comments. | Headless execution contract | Locked (v1) | Brief, result envelope, exit codes, and git-auth channel are pinned in [`docs/headless-contract.md`](docs/headless-contract.md) with JSON Schemas, golden fixtures, and a conformance test. | | `coven-code --headless` execution | Partial | Worker spawns headless sessions with a tokenless session brief and enforces task timeouts; result quality depends on the runtime. | | Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. | -| CovenCave task polling | Partial | Task API is served from the durable store and survives restarts; hosted control-plane auth is planned (#3). | +| 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)). | | 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). | | Familiar trust contract | Planned | See [Familiar Contract](FAMILIAR-CONTRACT.md). | diff --git a/config/example.toml b/config/example.toml index 7661c51..eaf4b90 100644 --- a/config/example.toml +++ b/config/example.toml @@ -24,6 +24,18 @@ max_retries = 2 # Retries for infra errors (exit code 2) # SQLite file — keep it on a persistent volume; parents are created at start. path = "data/coven-github.db" +# ── Task API auth (issue #3) ──────────────────────────────────────────────── +# Gate GET /api/github/tasks. "open" = unauthenticated (local development +# ONLY — never expose publicly). "token" = bearer tokens required, fail +# closed; the hosted posture. +# [api] +# mode = "token" +# service_token = "GENERATE_LONG_RANDOM" # operator-wide visibility +# [[api.tenants]] +# token = "GENERATE_LONG_RANDOM" # one installation's visibility +# installation_id = 123456 +# repos = ["your-org/your-repo"] # optional narrower scope + # ── Familiar configuration ────────────────────────────────────────────────── # Add one [[familiars]] block per familiar you want to expose as a GitHub bot. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 3825254..0ac81d4 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -19,6 +19,10 @@ pub struct Config { /// Hosted memory governance policy (issue #6). Absent section = memory off. #[serde(default)] pub memory: MemoryConfig, + /// Task API authentication (issue #3). Absent section = open mode, which + /// is only safe for local development. + #[serde(default)] + pub api: ApiConfig, } /// Hosted memory governance policy (issue #6). Off by default; opting in is a @@ -71,6 +75,40 @@ fn default_true() -> bool { true } +/// Task API access control. See `docs/security.md`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ApiConfig { + /// `open` (unauthenticated; local development only) or `token` + /// (bearer tokens required; fail closed — the hosted posture). + #[serde(default)] + pub mode: ApiMode, + /// Operator-wide token with visibility across every installation + /// (self-hosted Cave polling). + pub service_token: Option, + /// Tenant tokens scoped to a single installation (and optionally to a + /// subset of its repositories). + #[serde(default)] + pub tenants: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiMode { + #[default] + Open, + Token, +} + +/// A bearer token granting read access to one installation's tasks. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TenantToken { + pub token: String, + pub installation_id: u64, + /// Optional narrower scope: only these `owner/name` repositories. + #[serde(default)] + pub repos: Vec, +} + /// Durable store location. See `docs/durable-task-store.md`. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StorageConfig { @@ -367,6 +405,52 @@ impl Config { )); } + // ── Task API auth (issue #3) ──────────────────────────────────── + match self.api.mode { + ApiMode::Open => { + if self.api.service_token.is_some() || !self.api.tenants.is_empty() { + out.push(Diagnostic::warning( + "api.mode", + "tokens are configured but api.mode is 'open' — they are ignored; set api.mode = \"token\" to enforce them.", + )); + } else { + out.push(Diagnostic::warning( + "api.mode", + "the task API is unauthenticated (api.mode = \"open\") — fine for local development, never expose it publicly; hosted deployments must use \"token\".", + )); + } + } + ApiMode::Token => { + if self.api.service_token.is_none() && self.api.tenants.is_empty() { + out.push(Diagnostic::error( + "api.mode", + "api.mode is 'token' but no api.service_token or [[api.tenants]] tokens are configured — every task API call would fail.", + )); + } + } + } + let mut seen_api_tokens = std::collections::HashSet::new(); + for candidate in self + .api + .service_token + .iter() + .chain(self.api.tenants.iter().map(|t| &t.token)) + { + let trimmed = candidate.trim(); + if trimmed.len() < 16 { + out.push(Diagnostic::warning( + "api.tenants[].token", + "an API token is shorter than 16 characters — use a long random string.", + )); + } + if !trimmed.is_empty() && !seen_api_tokens.insert(trimmed) { + out.push(Diagnostic::error( + "api.tenants[].token", + "duplicate API token — two scopes would be indistinguishable at the boundary.", + )); + } + } + // ── Review policy ─────────────────────────────────────────────── let known_ids: std::collections::HashSet<&str> = self.familiars.iter().map(|f| f.id.as_str()).collect(); @@ -495,6 +579,12 @@ fn next_step_for(field: &str, _message: &str) -> &'static str { "memory.approval_required" => { "Keep memory.approval_required = true so learned facts need maintainer review, or accept the risk deliberately." } + "api.mode" => { + "Set api.mode = \"token\" and configure api.service_token and/or [[api.tenants]] tokens for hosted use." + } + "api.tenants[].token" => { + "Generate long random tokens (e.g. openssl rand -hex 32) and keep each scope's token unique." + } _ => "Update this config field, then rerun coven-github doctor.", } } @@ -595,6 +685,7 @@ mod tests { review: ReviewConfig::default(), storage: StorageConfig::default(), memory: MemoryConfig::default(), + api: ApiConfig::default(), } } @@ -869,6 +960,55 @@ mod tests { assert!(errs.contains(&"familiars")); } + #[test] + fn token_mode_without_tokens_is_an_error_and_open_mode_warns() { + let dir = tmpdir(); + let pem = write_pem(&dir); + let bin = write_bin(&dir); + let mut cfg = config_with( + GitHubAppConfig { + app_id: 123, + private_key_path: pem, + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: bin, + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + }, + vec![good_familiar()], + ); + + // Default open mode: a warning, not an error. + let diags = cfg.check(); + assert!(errors(&diags).is_empty(), "diags: {diags:?}"); + assert!( + diags + .iter() + .any(|d| d.field == "api.mode" && d.message.contains("unauthenticated")), + "open mode must warn: {diags:?}" + ); + + // Token mode with nothing to match against would deny every call. + cfg.api.mode = ApiMode::Token; + let diags = cfg.check(); + assert!( + errors(&diags).contains(&"api.mode"), + "token mode without tokens must error: {diags:?}" + ); + + // A configured tenant token clears the error. + cfg.api.tenants = vec![TenantToken { + token: "a-long-random-api-token".into(), + installation_id: 1, + repos: vec![], + }]; + assert!(errors(&cfg.check()).is_empty()); + } + #[test] fn first_run_errors_include_operator_next_steps() { let dir = tmpdir(); diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 0dd3e15..85eb8f4 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -15,7 +15,7 @@ use std::path::Path; use std::sync::{Arc, Mutex}; /// Current schema version, stored in `PRAGMA user_version`. -const SCHEMA_VERSION: i32 = 2; +const SCHEMA_VERSION: i32 = 3; /// Handle to the durable store. Cheap to clone; all clones share one writer /// connection. @@ -341,21 +341,27 @@ impl Store { /// The Cave oversight projection: every non-reply task, newest first. /// `familiar_names` maps familiar ids to display names (config-owned). + /// `scope` limits visibility to one tenant (issue #3); `None` is the + /// adapter's own unrestricted view. pub async fn cave_list( &self, familiar_names: HashMap, + scope: Option, ) -> Result> { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { let conn = conn.lock().expect("store mutex poisoned"); let mut stmt = conn.prepare( "SELECT id, repo, familiar_id, kind, state, result_status, - branch, pr_number, check_run_url, updated_at + branch, pr_number, check_run_url, updated_at, + installation_id FROM tasks WHERE json_extract(kind, '$.kind') <> 'command_reply' + AND (?1 IS NULL OR installation_id = ?1) ORDER BY updated_at DESC, id", )?; - let rows = stmt.query_map([], |row| { + let installation_filter = scope.as_ref().map(|s| s.installation_id); + let rows = stmt.query_map(params![installation_filter], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -367,6 +373,7 @@ impl Store { row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, row.get::<_, String>(9)?, + row.get::<_, u64>(10)?, )) })?; let mut items = Vec::new(); @@ -382,7 +389,16 @@ impl Store { pr_number, check_run_url, updated_at, + _installation_id, ) = row?; + // Repository narrowing within the installation, when scoped. + if let Some(scope) = &scope { + if let Some(repos) = &scope.repos { + if !repos.contains(&repo) { + continue; + } + } + } let kind: TaskKind = serde_json::from_str(&kind_json) .context("stored task kind is unreadable")?; let (issue_number, issue_title) = surface_of(&kind); @@ -413,6 +429,62 @@ impl Store { } } +/// Tenant visibility limits for task API reads (issue #3). +#[derive(Debug, Clone)] +pub struct ApiScope { + pub installation_id: u64, + /// `None` = every repository in the installation; `Some` narrows further. + pub repos: Option>, +} + +impl Store { + /// Appends a task-API read to the audit trail (issue #3). + pub async fn record_api_read( + &self, + caller: &str, + scope: &str, + action: &str, + result: &str, + ) -> Result<()> { + let conn = self.conn.clone(); + let (caller, scope, action, result) = ( + caller.to_string(), + scope.to_string(), + action.to_string(), + result.to_string(), + ); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().expect("store mutex poisoned"); + conn.execute( + "INSERT INTO api_audit (at, caller, scope, action, result) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![now_rfc3339(), caller, scope, action, result], + )?; + Ok(()) + }) + .await + .expect("store task panicked") + } + + /// `(caller, scope, action, result)` audit rows, oldest first. + pub async fn api_audit_entries(&self) -> Result> { + let conn = self.conn.clone(); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().expect("store mutex poisoned"); + let mut stmt = + conn.prepare("SELECT caller, scope, action, result FROM api_audit ORDER BY id")?; + let rows = stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("store task panicked") + } +} + /// Terminal transition applied by [`Store::finish`]. #[derive(Debug, Clone, Default)] pub struct Terminal { @@ -525,6 +597,22 @@ fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch("ALTER TABLE tasks ADD COLUMN result_status TEXT;") .context("failed to apply schema v2")?; } + if version < 3 { + // v3: task API read audit (issue #3). + conn.execute_batch( + r#" + CREATE TABLE api_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + caller TEXT NOT NULL, + scope TEXT NOT NULL, + action TEXT NOT NULL, + result TEXT NOT NULL + ); + "#, + ) + .context("failed to apply schema v3")?; + } conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; Ok(()) } @@ -870,7 +958,7 @@ mod queue_tests { .expect("finish"); let items = store - .cave_list(HashMap::from([("cody".to_string(), "Cody".to_string())])) + .cave_list(HashMap::from([("cody".to_string(), "Cody".to_string())]), None) .await .expect("list"); assert_eq!(items.len(), 1); @@ -950,7 +1038,7 @@ mod queue_tests { enqueue(&store, "d1", &fix_task("work")).await; enqueue(&store, "d2", &reply).await; - let items = store.cave_list(HashMap::new()).await.expect("list"); + let items = store.cave_list(HashMap::new(), None).await.expect("list"); assert_eq!(items.len(), 1, "adapter replies are not Cave tasks"); assert_eq!(items[0].id, "work"); assert_eq!(items[0].status, TaskListStatus::Queued); diff --git a/crates/webhook/src/routes.rs b/crates/webhook/src/routes.rs index 1aeacab..7653716 100644 --- a/crates/webhook/src/routes.rs +++ b/crates/webhook/src/routes.rs @@ -13,8 +13,8 @@ use tracing::{error, info, warn}; use coven_github_api::tasks::TaskListStatus; use coven_github_api::{GitHubEvent, Task, TaskKind}; -use coven_github_config::Config; -use coven_github_store::{Delivery, Recorded, Routing, Store}; +use coven_github_config::{ApiConfig, ApiMode, Config}; +use coven_github_store::{ApiScope, Delivery, Recorded, Routing, Store}; use crate::{ commands::{parse_mention, Command, MentionKind, COMMAND_LIST}, @@ -43,12 +43,69 @@ fn familiar_names(config: &Config) -> std::collections::HashMap .collect() } -/// GET /api/github/tasks — current task state for CovenCave polling. -pub async fn list_tasks(State(state): State) -> impl IntoResponse { - match state.store.cave_list(familiar_names(&state.config)).await { - Ok(tasks) => Json(json!({ "ok": true, "tasks": tasks })).into_response(), +/// GET /api/github/tasks — task state for CovenCave polling, behind the +/// 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. +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:#}"); + } + // Fail closed with a body that reveals nothing about what exists. + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"ok": false, "error": "unauthorized"})), + ) + .into_response(); + } + ApiCaller::Open => ("open".to_string(), None), + ApiCaller::Service => ("service".to_string(), None), + ApiCaller::Tenant(tenant) => ( + format!("tenant:{}", tenant.installation_id), + Some(ApiScope { + installation_id: tenant.installation_id, + repos: if tenant.repos.is_empty() { + None + } else { + Some(tenant.repos.clone()) + }, + }), + ), + }; + let scope_label = scope + .as_ref() + .map(|s| format!("installation:{}", s.installation_id)) + .unwrap_or_else(|| "all".to_string()); + + match state + .store + .cave_list(familiar_names(&state.config), scope) + .await + { + Ok(tasks) => { + if let Err(e) = state + .store + .record_api_read(&caller, &scope_label, action, &format!("ok:{}", tasks.len())) + .await + { + error!("api audit write failed: {e:#}"); + } + Json(json!({ "ok": true, "tasks": tasks })).into_response() + } Err(e) => { error!("task list unavailable: {e:#}"); + let _ = state + .store + .record_api_read(&caller, &scope_label, action, "error") + .await; ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"ok": false, "error": "task list unavailable"})), @@ -58,6 +115,50 @@ pub async fn list_tasks(State(state): State) -> impl IntoResponse { } } +/// Resolved identity of a task-API caller. +enum ApiCaller<'a> { + /// `open` mode: unauthenticated local development. + Open, + /// Operator-wide service token: unrestricted visibility. + Service, + /// Tenant token: one installation's scope. + Tenant(&'a coven_github_config::TenantToken), + /// No valid credential in `token` mode. Fail closed. + Denied, +} + +fn authorize_api<'a>(api: &'a ApiConfig, headers: &HeaderMap) -> ApiCaller<'a> { + if api.mode == ApiMode::Open { + return ApiCaller::Open; + } + let candidate = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|v| !v.is_empty()); + let Some(candidate) = candidate else { + return ApiCaller::Denied; + }; + if let Some(service) = &api.service_token { + if token_matches(candidate, service) { + return ApiCaller::Service; + } + } + for tenant in &api.tenants { + if token_matches(candidate, &tenant.token) { + return ApiCaller::Tenant(tenant); + } + } + ApiCaller::Denied +} + +/// Constant-time token comparison: comparing fixed-length digests leaks +/// nothing about how many characters of the token matched. +fn token_matches(candidate: &str, expected: &str) -> bool { + Sha256::digest(candidate.as_bytes()) == Sha256::digest(expected.as_bytes()) +} + /// POST /webhook — GitHub webhook receiver. pub async fn handle_webhook( State(state): State, @@ -498,7 +599,7 @@ async fn command_task( Command::Status => { let items = state .store - .cave_list(familiar_names(&state.config)) + .cave_list(familiar_names(&state.config), None) .await .unwrap_or_else(|e| { warn!("status could not reach the store: {e:#}"); @@ -590,6 +691,7 @@ mod tests { review, storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + api: coven_github_config::ApiConfig::default(), }), store: Store::open_in_memory().expect("in-memory store"), notify: std::sync::Arc::new(tokio::sync::Notify::new()), @@ -1337,3 +1439,162 @@ mod delivery_idempotency_tests { assert!(state.store.task_states().await.unwrap().is_empty()); } } + +#[cfg(test)] +mod tenancy_tests { + //! The tenant boundary on the task API (issue #3): token mode fails + //! closed, a tenant sees only its own installation, and every read is + //! audited. + use super::tests::{app_state, seed_task}; + use super::*; + use axum::extract::State; + use coven_github_config::TenantToken; + use std::sync::Arc; + + fn token_state(api: ApiConfig) -> AppState { + let base = app_state(); + let mut config = (*base.config).clone(); + config.api = api; + AppState { + config: Arc::new(config), + ..base + } + } + + fn two_tenant_api() -> ApiConfig { + ApiConfig { + mode: ApiMode::Token, + service_token: Some("service-token-0123456789abcdef".to_string()), + tenants: vec![ + TenantToken { + token: "tenant-one-0123456789abcdef".to_string(), + installation_id: 1, + repos: vec![], + }, + TenantToken { + token: "tenant-two-0123456789abcdef".to_string(), + installation_id: 2, + repos: vec![], + }, + ], + } + } + + fn task_for(id: &str, installation_id: u64, repo_name: &str) -> Task { + Task { + id: id.to_string(), + installation_id, + repo_owner: "OpenCoven".to_string(), + repo_name: repo_name.to_string(), + familiar_id: "cody".to_string(), + commander: None, + kind: TaskKind::FixIssue { + issue_number: 42, + issue_title: format!("task {id}"), + issue_body: "b".to_string(), + }, + } + } + + async fn list(state: &AppState, bearer: Option<&str>) -> (StatusCode, serde_json::Value) { + let mut headers = HeaderMap::new(); + if let Some(token) = bearer { + headers.insert( + "authorization", + format!("Bearer {token}").parse().expect("header"), + ); + } + let response = list_tasks(State(state.clone()), headers) + .await + .into_response(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + (status, serde_json::from_slice(&bytes).expect("json")) + } + + fn repos_of(json: &serde_json::Value) -> Vec { + json["tasks"] + .as_array() + .expect("tasks array") + .iter() + .map(|t| t["repo"].as_str().expect("repo").to_string()) + .collect() + } + + #[tokio::test] + async fn open_mode_lists_without_auth_for_local_development() { + let state = app_state(); + seed_task(&state, "d1", &task_for("t1", 1, "alpha")).await; + let (status, json) = list(&state, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["tasks"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn token_mode_fails_closed_and_reveals_nothing() { + let state = token_state(two_tenant_api()); + seed_task(&state, "d1", &task_for("t1", 1, "alpha")).await; + + for bearer in [None, Some("wrong-token-0123456789abcdef")] { + let (status, json) = list(&state, bearer).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "bearer: {bearer:?}"); + assert_eq!(json["error"], "unauthorized"); + assert!(json.get("tasks").is_none(), "no data may leak: {json}"); + } + + 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"); + } + } + + #[tokio::test] + async fn tenant_token_sees_only_its_own_installation() { + let state = token_state(two_tenant_api()); + seed_task(&state, "d1", &task_for("t1", 1, "alpha")).await; + seed_task(&state, "d2", &task_for("t2", 2, "beta")).await; + + // Installation 1's token must never see installation 2's task. + let (status, json) = list(&state, Some("tenant-one-0123456789abcdef")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(repos_of(&json), vec!["OpenCoven/alpha".to_string()]); + + let (_, json) = list(&state, Some("tenant-two-0123456789abcdef")).await; + assert_eq!(repos_of(&json), vec!["OpenCoven/beta".to_string()]); + + // The operator-wide service token sees both. + let (_, json) = list(&state, Some("service-token-0123456789abcdef")).await; + assert_eq!(json["tasks"].as_array().unwrap().len(), 2); + + let audit = state.store.api_audit_entries().await.expect("audit"); + assert_eq!( + audit + .iter() + .map(|(caller, scope, _, result)| (caller.as_str(), scope.as_str(), result.as_str())) + .collect::>(), + vec![ + ("tenant:1", "installation:1", "ok:1"), + ("tenant:2", "installation:2", "ok:1"), + ("service", "all", "ok:2"), + ] + ); + } + + #[tokio::test] + async fn tenant_repo_scope_narrows_within_the_installation() { + let mut api = two_tenant_api(); + api.tenants[0].repos = vec!["OpenCoven/alpha".to_string()]; + let state = token_state(api); + seed_task(&state, "d1", &task_for("t1", 1, "alpha")).await; + seed_task(&state, "d2", &task_for("t2", 1, "gamma")).await; + + let (_, json) = list(&state, Some("tenant-one-0123456789abcdef")).await; + assert_eq!(repos_of(&json), vec!["OpenCoven/alpha".to_string()]); + } +} diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 68fef0b..1bb89e8 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -1723,6 +1723,7 @@ mod disposition_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + api: coven_github_config::ApiConfig::default(), } } @@ -1836,6 +1837,7 @@ mod process_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + api: coven_github_config::ApiConfig::default(), } } @@ -2110,6 +2112,7 @@ exit 0 review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + api: coven_github_config::ApiConfig::default(), }; let task = Task { id: "task-pub".to_string(), @@ -2336,6 +2339,7 @@ exit 0 review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + api: coven_github_config::ApiConfig::default(), }; let task = Task { id: "task-stale".to_string(), @@ -2372,7 +2376,7 @@ exit 0 .expect("stale review must complete cleanly"); // Cave sees the honest terminal state. - let items = store.cave_list(HashMap::new()).await.expect("list"); + let items = store.cave_list(HashMap::new(), None).await.expect("list"); assert_eq!(items.len(), 1); assert_eq!(items[0].status, TaskListStatus::Superseded); @@ -2470,6 +2474,7 @@ mod command_and_marker_tests { review: coven_github_config::ReviewConfig::default(), storage: coven_github_config::StorageConfig::default(), memory: coven_github_config::MemoryConfig::default(), + api: coven_github_config::ApiConfig::default(), } } diff --git a/docs/security.md b/docs/security.md index af4b0ad..799a4fd 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. The current in-memory `/api/github/tasks` path is suitable for local development and Cave polling, but hosted usage needs tenant-scoped authentication before launch. +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. ## Model and Memory Boundaries