feat(api): tenant-scoped task API auth with fail-closed token mode (#3) - #51
Merged
Conversation
Contributor
There was a problem hiding this comment.
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 plusdoctorvalidations. - Enforced tenant scoping server-side by threading an
ApiScopeinto the store query and adding route-level authorization + auditing. - Added an
api_auditSQLite 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 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 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
force-pushed
the
feat/issue-3-task-api-auth
branch
from
July 7, 2026 05:16
d9e49d3 to
3dc9047
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) ormode = "token"(bearer required, fail closed — the hosted posture)service_token(operator-wide visibility, self-hosted Cave polling) and[[api.tenants]]tokens scoped to one installation id, optionally narrowed to a repo listcave_listtakes anApiScope), not in the clientapi_audittable (schema v3) records caller / scope / action / result for every read, allowed or deniedAcceptance criteria → proof
token_mode_fails_closed_and_reveals_nothingtenant_token_sees_only_its_own_installation,tenant_repo_scope_narrows_within_the_installationVerification
156 tests green · clippy
-D warningsclean · python gates green · demo 21/21 (demo config uses default open mode — unchanged local ergonomics)