ADR-0042: Per-Repo Read Authorization, Derived from GitHub Access
Proposed Town-crier Cross-Harness ContractDate: Proposed 2026-07-13. Awaiting socialization with the co-owner (Jasper) — this rewrites the bus read-authz contract. Reasoning: campaigns/town-crier/2026-07-13-per-repo-read-authorization-github-derived.md. Relates to: ADR-0036 (the GitHub App that already holds installation credentials), the enact-verb write-side authz deferral (deferred.md), ADR-0006.
Status note. A cross-harness contract: it changes what a bus member may see. It must mean the same for every member and every linked repo, so it is settled on the contract-decision log with the co-owner before it ships. This ADR crystallizes the proposed end-state; it is not yet ratified.
Context
Town-crier's bus is invite-only: GithubCallbackAction admits a member only if their OAuth-verified email matches an invited users row. But past that gate the model is flat — D1: "any authenticated member may…" — and applied to reads it means any member reads every repo's cries in full:
GET /api/review-requests(index) browses the whole ledger.GET /api/review-requests/{id}(show) returns any request in full — repofull_name, PR URL, the review thread, all findings.GET /api/review-requests/batch— the same, multi-id.
Post-TC-0062 each cry also carries a snapshotted kendo issue title, so the leak now includes the issue's human-readable subject for repos the member has no GitHub access to.
/subscribed is scoped to subscribedRepositoryIds(), but subscription is self-service (a member edits their own harness's watch-list), so it is a filter, not an access boundary. There is no per-repo authorization anywhere on the read surface — yet TC-0034 already treats repo full_names as private (it slims them from the anonymous /github/health report). The ledger is the exception.
The obvious fix — check the member's real GitHub access — is blocked by the fact that town-crier stores no GitHub identity or token per member. Invite provisioning discards the OAuth login.
Decision
Read access to a cry is scoped to the repos the member can access on GitHub, resolved from the member's GitHub identity by the App's own installation credentials, cached off the read path, and failing closed.
1. Capture GitHub identity
Add github_login + github_id to users, populated in GithubCallbackAction from the Socialite user. No user-held token is stored — the App's installation credentials do the checking.
2. Resolve access with App credentials, off the hot path
GithubAppClient gains getRepositoryPermission($repoFullName, $login, $token): ?string → GET /repos/{full_name}/collaborators/{login}/permission (admin|write|read|none; 404 ⇒ no access), minting the token for the repo's own installation_id. Access = permission ∈ {admin, write, read}. A RefreshUserRepositoryAccessJob writes a TTL'd user_repository_access(user_id, repository_id, permission, checked_at) cache; a User::accessibleRepositoryIds() accessor (mirroring the proven subscribedRepositoryIds() spine) reads that cache. No live GitHub call on the dashboard read.
3. Fail closed
Unknown / unresolved / TTL-expired / errored resolution ⇒ the repo is excluded from scope. The resolver never widens scope on error — a fail-open check would leak the very repos this ADR hides.
4. Scope the open reads
- index → scope to
accessibleRepositoryIds()(reusingListAllRequestsData.repositoryIds). - show → 404 (not 403) when the cry's repo is out of scope — no existence leak.
- batch → inaccessible ids drop into the existing
missingarray. - subscribed →
accessible ∩ subscribed— subscription stays a filter atop the access boundary.
5. Write-side rides the same boundary
Turn-taking + finding-carrying writes (claim / submit / enact) gate on the same accessible set: a member cannot review or enact a repo they cannot see. This folds the open enact-verb authz question into one coherent access model — enact becomes reachable only by members who already hold GitHub access to (and could already merge) the PR.
Options considered
- A — GitHub-derived (chosen). Access mirrors GitHub; onboarding is invite-only with no per-repo grant to maintain; the grant list can never drift from GitHub's truth. Cost: OAuth login capture, an App permission check + cache, read-scoping. Risk (mitigated above): hot-path coupling → cache off path; fail-open leak → fail closed.
- B — Invite/admin-scoped ACL. Make
harness_repositoryan admin-granted access list; gate reads on it. Local failure modes, no GitHub dependency — but a hand-maintained ACL that drifts from real access and makes onboarding two-step. Rejected: the boundary is only as honest as its upkeep. - C — Scope-to-subscription. Apply the existing subscription scope to the three reads. Rejected: self-service subscription is not a boundary — a member opts themselves in.
Consequences
Positive. True least-privilege reads; onboarding stays single-step and self-maintaining (the Commander's decisive rationale); read-and-write authz unify; the TC-0034 private-repo posture finally extends to the ledger.
Negative / risks. A member with no resolved access sees an empty ledger until their first post-change OAuth login populates github_login (fail-closed backfill — acceptable, revisit if it bites). The /collaborators/{login}/permission endpoint's treatment of org-visibility-only members (vs explicit collaborators) must be confirmed before it is trusted as the sole signal. A TTL window means an access revocation on GitHub takes up to one TTL to propagate to the bus (a read the member briefly retains) — proposed 30 min; tune against the revocation-latency tolerance.
Enforcement
- Level 1 (arch/feature test): feature tests proving index/show/batch deny (404 / filtered) a repo outside the caller's resolved-accessible set, and the resolver fails closed on an errored permission check.
- Level 4 (doctrine): town-crier
CLAUDE.mdrecords the read-authz model (D1-for-reads retired for a per-repo GitHub-derived scope).
Open questions (resolve with Jasper before build)
- Cache TTL + refresh trigger (login + schedule vs lazy first-miss — lazy reintroduces a hot-path call).
/collaborators/{login}/permissionbehavior for org members vs outside collaborators.- Non-human bus identities (github-action / producer) have no
github_login— confirm they never hit the scoped reads rather than fail-closed into breakage. - One-time backfill for existing members vs let it populate on next login.