ADR-0042: Per-Repo Read Authorization, Derived from GitHub Access
Accepted Town-crier Cross-Harness ContractDate: Proposed 2026-07-13. Accepted 2026-10-02 (Commander ruling at /muster). The co-owner socialization the proposal waited on happened in review: Jasper approved the implementing PR, town-crier #214, before it merged on 2026-07-17. 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. This is a cross-harness contract: it changes what a bus member may see, so it has to mean the same for every member and every linked repo. It was built and reviewed with the co-owner (WR-0404, town-crier #214), and it has been live since 2026-07-17. The ratification on 2026-10-02 makes the record match the code. It does not change the code.
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 — resolved in the build
These were settled during WR-0404 and its follow-ups, not before. The answers are read at town-crier origin/development ee9a6b6.
- Cache TTL and refresh trigger. The TTL is 30 minutes (
town-crier.repository_access_ttl, envTOWN_CRIER_REPOSITORY_ACCESS_TTL).- At login:
RefreshUserRepositoryAccessJobrefreshes access, fanned out in batches (WR-0887). - On a schedule:
RefreshRepositoryAccessCommandrefreshes well inside the TTL. - Nothing is resolved lazily on the read path, so no GitHub call happens on a dashboard read.
- At login:
- Org members vs outside collaborators. The scheduled sweep reads
GET /repos/{repo}/collaborators?affiliation=all, inverted to one call per repo (WR-0493). The login path keeps the per-member/collaborators/{login}/permissionendpoint. - Non-human identities. A token-owning member with no
github_loginresolves to an empty scope: the bus stays dark for that member until they log in.SweepRepositoryAccessActionlogs a warning naming the case. Producer traffic arrives over the GitHub App webhook path (ADR-0036), which is outside the member read scope. The member read scope fails closed and does not break. - Backfill. There was no one-time backfill. Rows populate on each member's next OAuth login, which is the fail-closed default this ADR accepted.
Implementation
| Territory | State | Evidence |
|---|---|---|
| town-crier | Complete (2026-07-17), extended since | #214 (WR-0404, Jasper APPROVED) brought: github_login / github_id on users, user_repository_access with TTL, GithubAppClient::getRepositoryPermission, and fail-closed index/show/batch/subscribed reads with gated claim/submit/enact. #216 made the access-granting permission triple a single constant. Later extensions: the ledger broadcast is now scoped per member (closing the socket read-leak); the repository registry sits inside the boundary (TC-0098); the sweep is inverted to O(repos) (WR-0493); the login-path fan-out is chunked (WR-0887); the config-write gate is allowsConfiguring (WR-1834). App\Auth\RepositoryScope is the one resolver, used by every REST, MCP and broadcast surface. |
Enforcement as built.
- Level 1:
RepositoryAccessBoundaryTest,RefreshUserRepositoryAccessJobTest,RefreshRepositoryCollaboratorAccessJobTest,SweepRepositoryAccessActionTest, andQueuePriorityOrderTest. The last one holds because a starved access refresh revokes access: stale rows fail closed. - Level 4 — still open. At
ee9a6b6, town-crier'sCLAUDE.mdmentions the boundary only in its queue-priority section. It carries no projection of this ADR and does not state that D1-for-reads is retired. That projection is the remaining leg (WR-1935).