Skip to content

feat(api): tenant-scoped task API auth with fail-closed token mode (#3) - #51

Merged
BunsDev merged 1 commit into
mainfrom
feat/issue-3-task-api-auth
Jul 7, 2026
Merged

feat(api): tenant-scoped task API auth with fail-closed token mode (#3)#51
BunsDev merged 1 commit into
mainfrom
feat/issue-3-task-api-auth

Conversation

@BunsDev

@BunsDev BunsDev commented Jul 7, 2026

Copy link
Copy Markdown
Member

Closes #3 — the hosted tenancy gate (highest fan-out Wave 1 issue; unblocks #6, #7, #15, #18).

What lands

  • [api] config: mode = "open" (unauthenticated local dev; doctor warns, docs say never expose) or mode = "token" (bearer required, fail closed — the hosted posture)
  • Two credential shapes: service_token (operator-wide visibility, self-hosted Cave polling) and [[api.tenants]] tokens scoped to one installation id, optionally narrowed to a repo list
  • Server-side enforcement: the installation filter lives in the store query (cave_list takes an ApiScope), not in the client
  • Constant-time token compare (fixed-length digest equality)
  • Uniform 401 that reveals nothing about existing data
  • Audit trail: new api_audit table (schema v3) records caller / scope / action / result for every read, allowed or denied
  • Doctor: error on token mode without tokens, error on duplicate tokens, warnings for open mode and short tokens

Acceptance criteria → proof

Criterion Test
No unauthenticated listing in hosted mode token_mode_fails_closed_and_reveals_nothing
Callers limited to their installation/repo scope tenant_token_sees_only_its_own_installation, tenant_repo_scope_narrows_within_the_installation
A cannot fetch B (two installations) same two-installation tests
Errors don't reveal existence uniform 401 body asserted
Local vs hosted mode documented docs/security.md + config/example.toml
Audit: caller, scope, action, result audit assertions in both tests

Verification

156 tests green · clippy -D warnings clean · python gates green · demo 21/21 (demo config uses default open mode — unchanged local ergonomics)

Copilot AI review requested due to automatic review settings July 7, 2026 05:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements tenant-scoped authentication/authorization for the /api/github/tasks polling endpoint so hosted deployments can fail-closed, limit visibility by installation (optionally repo), and record an audit trail of every read.

Changes:

  • Added [api] configuration (open vs token modes) with service and tenant token support plus doctor validations.
  • Enforced tenant scoping server-side by threading an ApiScope into the store query and adding route-level authorization + auditing.
  • Added an api_audit SQLite table (schema v3) and tests asserting fail-closed behavior, tenant isolation, and audit recording.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
README.md Updates status to reflect task API auth/auditing now implemented.
docs/security.md Documents open vs token modes, tenant scoping, uniform 401s, and auditing.
crates/worker/src/lib.rs Updates store API usage to pass the new optional scope parameter.
crates/webhook/src/routes.rs Adds task API auth extraction, tenant scoping, uniform 401 response, and audit writes.
crates/store/src/lib.rs Adds schema v3 api_audit, ApiScope, scoped cave_list, and audit helpers.
crates/config/src/lib.rs Introduces ApiConfig/ApiMode/TenantToken plus doctor checks for misconfigurations.
config/example.toml Adds commented examples for configuring task API token mode.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +134 to +142
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;
};
Comment on lines +156 to +160
/// 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())
}
Comment on lines +83 to +86
let scope_label = scope
.as_ref()
.map(|s| format!("installation:{}", s.installation_id))
.unwrap_or_else(|| "all".to_string());
Comment thread crates/config/src/lib.rs
Comment on lines +370 to +377
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.",
));
}
}
Comment thread crates/config/src/lib.rs
Comment on lines +379 to +399
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.",
));
}
}
…en mode (#3)

Closes #3.

Gate GET /api/github/tasks behind an [api] config section: 'open' keeps the
unauthenticated local-development path (doctor warns), 'token' requires
bearer tokens and fails closed. A service_token grants operator-wide
visibility; each [[api.tenants]] token is scoped server-side to one
installation id, optionally narrowed to specific repositories — the scope is
enforced in the store query, not the client.

Token comparison is constant-time (fixed-length digest equality), the 401
body is uniform and reveals nothing about existing data, and every read —
allowed or denied — lands in a new api_audit table (schema v3) with caller,
scope, action, and result.

Two-installation route tests prove tenant A cannot list tenant B's tasks,
missing/invalid tokens fail closed, repo narrowing holds inside an
installation, and the audit trail records each read. docs/security.md and
config/example.toml document the boundary; doctor validates token-mode
misconfigurations and duplicate tokens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Val Alexander <bunsthedev@gmail.com>
@BunsDev
BunsDev force-pushed the feat/issue-3-task-api-auth branch from d9e49d3 to 3dc9047 Compare July 7, 2026 05:16
@BunsDev
BunsDev merged commit bc80181 into main Jul 7, 2026
1 check passed
@BunsDev
BunsDev deleted the feat/issue-3-task-api-auth branch July 7, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add tenant-scoped task API authentication for CovenCave and hosted clients

2 participants