Repository navigation
Bind Workspaces to immutable GitHub Teams with live grant verification (DEV-6605) - #13
Conversation
…ication
Policy schema v2 requires one immutable `github_team_id` per Workspace
(unique across Workspaces) and `members: read` in the installation
permissions; an optional `github_team_slug` is a readback assertion that must
match live. Schema v1 is rejected with a migration message.
Every `POST /v1/token` now verifies, after the credential and allowlist
checks and before the mint, that the Workspace's Team still exists in the
installation's Organization and holds a push/maintain/admin grant on the
exact repository. The readback uses a revoked `members: read` probe token and
id-addressed routes (`/organizations/{org_id}/team/{team_id}` and
`.../repos/{owner}/{repo}` with the repository-permissions media type).
Divergence refuses with `403 team_grant_missing`; outages stay
`502 token_unavailable`; nothing is cached between requests.
`node src/broker.mjs policy check [--live]` is the operator migration and
readback gate: it prints the Workspace/Team binding, and with `--live` reads
back every allowlisted grant with one probe per Workspace as a secret-free
table and exits non-zero on any refused row.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
README documents schema v2, the per-mint error codes, the two GitHub REST calls and the App permission each needs, and the `policy check --live` migration gate. ARCHITECTURE adds the Team binding section and states that the Dashboard is a read-only lens whose derived mapping never grants access. AGENTS adds the Team-binding invariant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The offline `policy check` summary pads its columns like the live table; the test now pins that exact output instead of a stale spacing pattern. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
agentrozjedemeai
left a comment
There was a problem hiding this comment.
CHANGES_REQUESTED — exact-head source review of 377924feb112539aecb4d5e569a0eb72795f4518.
P2 — the operator contract undercounts the new GitHub request cost and therefore obscures the rate-limit exposure of the no-cache design.
The Team gate is not "exactly two GitHub REST calls": withProbeToken() first creates the members: read probe and then revokes it, in addition to the two documented GETs. One accepted Node request is therefore four Team-gate requests plus the final scoped-token mint (5 total). The Worker additionally runs the existing live-installation gate per authorized request, making its accepted path 9 requests total. policy check --live is likewise 4 + 3 * workspace_count + grant_count requests.
Please change the text to call the two routes the two read requests, document the real per-adapter/request counts, and make explicit that GitHub quota/rate-limit responses are fail-closed as 502 token_unavailable with no readback cache. This is operationally material for the stated 139-grant migration/readback and per-mint Worker path; it is not merely explanatory wording.
Verified positive boundary evidence:
- The parser requires a unique immutable numeric Team id per Workspace and
members: read; v1 is refused with the migration message. - The token handler performs credential and repository checks, then
verifyTeamGrant, thenmintToken; no policy field bypasses that ordering. - The members-only probe carries only
{ members: "read" }; Team/grant divergence maps to403 team_grant_missing, provider failure to502 token_unavailable, and the existing client remains deny-first for non-2xx responses. - Focused negative probes proved that a Team-read outage and a probe-revocation failure both suppress the final scoped mint.
Validation on the exact checkout with Node v24.19.0: npm ci, npm test (55 pass, 0 fail), npm run check, git diff --check, current-main merge-tree. GitHub test, Reproducible OCI image, and Macroscope - Correctness Check are green. No deploy, release, or merge was performed.
The operator contract called the Team gate "exactly two GitHub REST calls", but the gate also mints and revokes its `members: read` probe token. README now names the two routes as the two read requests and documents the real counts: 5 requests per accepted Node token, 9 per accepted Worker token (installation gate + Team gate + scoped mint) and 4 + 3 x Workspaces + grants for `policy check --live`. Both documents state that GitHub rate-limit or quota responses fail closed as `502 token_unavailable` with no readback cache to fall back on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
agentrozjedemeai
left a comment
There was a problem hiding this comment.
CHANGES_REQUESTED — exact-head re-review of 02147536fdda102c9ef14679ea267ea597d7b7c4.
The prior request-budget P2 is fixed and its thread is resolved. The successor is docs-only relative to 377924fe; the accepted paths now correctly document Node=5, Worker=9, and a fully resolved policy check --live as 4 + 3 × Workspaces + allowlisted grants (8 Workspaces and 139 grants = 167). The source path, Node 24 test suite and dry-run remain green.
Three operator-contract corrections still block approval:
- A Team-gate refusal creates a short-lived
members: readprobe token before the Team or grant read. It must say that no final scoped repository token is minted, rather than that nothing is minted. - GitHub provides the sole live grant authority, but the reviewed policy
workspace.repository_idsallowlist is an additional deny-only admission gate: a live Team grant alone cannot mint an unlisted repository token. Preserve the distinction instead of calling the allowlist absent from authorization. policy check --live = 4 + 3W + Ris the maximum/fully-resolved case, not an unconditional count. The exact missing-Team fixture performs 4 installation-gate calls plus 3 Workspace probe calls, returns refused rows and deliberately skips every per-repository grant GET. Document the formula as the successful/all-Teams-resolve budget and explain that early Team refusal consumes fewer requests.
Evidence: Node 24.19.0 npm ci, npm test (55 pass, 0 fail), npm run check including Wrangler dry-run, git diff --check, and a clean exact checkout all passed. No deploy, release, merge, policy apply or external GitHub Team mutation was performed.
… budget is a maximum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
agentrozjedemeai
left a comment
There was a problem hiding this comment.
CHANGES_REQUESTED — exact-head docs re-review of 3ff468d2be4aa078695a6b9cacb23491104bb910 against main@448844431555f01d547aa6c05bdb0852f08441b7.
The probe-token and policy check --live maximum P2s are fixed and their threads are resolved. README now correctly distinguishes zero final scoped repository token from the short-lived, revoked members: read probe, and it labels 4 + 3 × Workspaces + allowlisted grants as the fully successful maximum. The exact missing-Team fixture confirms W=1/R=1 uses 7 requests total, returns one refused row and issues zero per-repository grant GETs.
Two documentation P2s still block approval:
- The existing unresolved
ARCHITECTURE.md:51thread is valid. GitHub Team/repository grants are the sole live granting authority, butworkspace.repository_idsis a separate deny-only admission gate. The document still calls the live Team grant the sole “access authority”, then says the request has already passed the allowlist. Match the precise README distinction: policy cannot grant or replace GitHub ACL, but it restricts which live grants the broker may exercise. README.md:170and the parallelARCHITECTURE.md:67call the Team gate four requests without limiting that statement to the successful Team+grant path. A missing Team performs only probe mint + Team GET + probe revoke = 3; it never issues the repository-grant GET. The table'spolicy check --livemissing-Team row is correct, but the introductory prose contradicts it. Say “a successful Team gate costs four” and name the 3-request early-refusal path; retain 4 for accepted Node/Worker calculation.
Validation on the detached exact checkout: Node 24.19.0 npm ci; npm test (55 pass, 0 fail); npm run check including Wrangler dry-run; git diff --check; clean status; current-main merge tree. Provider test, OCI and Macroscope checks are green. No deployment, release, merge, policy apply or Team mutation was performed.
…y-refusal Team gate cost Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
agentrozjedemeai
left a comment
There was a problem hiding this comment.
CHANGES_REQUESTED — exact-head docs re-review of c9544cd955cf6773168ee796c2d401cd0a81e123 against main@448844431555f01d547aa6c05bdb0852f08441b7.
The two source-documentation P2s are fixed. README.md:43–48 and ARCHITECTURE.md:51–57 now make GitHub the only grant authority while preserving workspace.repository_ids as a deny-only admission gate before the Team grant read. README.md:170–173 and ARCHITECTURE.md:72–79 now correctly distinguish the successful four-request Team gate from the three-request missing-Team early refusal. I resolved both corresponding reviewer-owned threads after exact-head verification.
P2 — the PR description itself is now stale decision material. Its opening Status statement still says the live Team → repository grant is the "sole access authority" and its Risks section still says "the Team gate is 4 requests" without the successful-path qualification. Both contradict the exact HEAD’s corrected README/ARCHITECTURE contract and the source path: an unallowlisted repository is denied before verifyTeamGrant (src/core.mjs:546–550), and a missing Team performs only probe mint → Team GET → probe revocation (src/core.mjs:236–252, 308–315) with no repository-grant GET.
Please update the author-owned PR description outside the Macroscope-managed markers to preserve the same two distinctions: GitHub grants are the only granting authority while policy is deny-only admission; four requests apply only to a successful Team gate and missing Team refuses after three. This is a docs-only PR, so its review handoff must match the exact reviewed contract before approval.
Validation on the detached exact checkout: Node v24.19.0 npm ci; npm test (55 pass, 0 fail); focused Team-gate tests (3 pass); npm run check including Wrangler --dry-run; git diff --check; clean status; conflict-free current-main merge tree. GitHub test, Reproducible OCI image and Macroscope - Correctness Check are green. No deploy, release, merge, policy apply or Team mutation was performed.
agentrozjedemeai
left a comment
There was a problem hiding this comment.
APPROVED — exact-head re-review of the author-managed PR description on c9544cd955cf6773168ee796c2d401cd0a81e123 against main@448844431555f01d547aa6c05bdb0852f08441b7.
The description now matches the reviewed source contract:
- GitHub Team/repository grants are the only live granting authority;
workspace.repository_idsis a separate deny-only admission gate that can refuse an unallowlisted repository before the Team-grant read. - Four requests applies to a successful Team gate; a missing Team refuses after probe mint, Team read and probe revocation (three requests) without a repository-grant read. The
policy check --liveformula is explicitly the fully successful maximum and names cheaper early refusal.
I also checked the owner/repo grant-read wording in context. The body names the actual {owner}/{repo} route and requires the returned immutable repository id to equal repository_id; the implementation uses the asserted full_name only for that route and refuses an ID mismatch. It does not claim that the repository URL segment itself is an immutable id.
Verification: exact detached checkout clean; Node v24.19.0 npm ci, npm test (55 pass, 0 fail), npm run check including Wrangler --dry-run, and git diff --check passed. Provider test, Reproducible OCI image, and Macroscope - Correctness Check are SUCCESS; all prior review threads are resolved.
No deploy, release, merge, policy apply, or GitHub Team mutation was performed.
Status: ready for review
Implements DEV-6605 task
task-2026-09-18-002(HumanAndMachine-ai/mission-control-data, planDEV-6605-team-workspace-github-identity) and Lazurio decisions 0147–0149: a Team Workspace's GitHub identity is the App via this broker, one broker Workspace per immutable GitHub Team, and the live GitHub Team → repository grant is the only grant authority — the policy never grants anything GitHub has not granted and never replaces GitHub's ACL — verified on every token issuance and failing closed on divergence. The policy'sworkspace.repository_idsallowlist remains a separate, deny-only admission gate: a repository outside the allowlist is refused before the live Team grant is read, even if the Team holds a write grant on it.Motivation
The deployed broker (0.8.0) authorizes only by Workspace id + credential +
repository_ids; thegithub_team_idalready present in the Spectoda policy is inert metadata. Rolling Team Workspaces out on that contract would make the policy a second ACL. This PR makes the Team binding real and versioned.What changes
Policy schema
lazurio.github_app_broker.policy.v2(src/core.mjs)workspaces[].github_team_idrequired, positive integer, unique across Workspaces (exactly one Workspace per Team).workspaces[].github_team_slugoptional readback assertion; when present the live slug must match (same rule as repositoryfull_name: names are asserted, never select a different object).installation_permissions.membersmust beread(the permission the Team readback needs).id,credential_file,repository_idsunchanged.Per-mint live Team gate (
createGithubClient().verifyTeamGrant, wired increateBrokerHandler; both adapters)POST /v1/token: credential → allowlist → (Worker: live installation verification) → live Team grant → one-repository mint. Nothing is cached between requests.members: readprobe installation token, revoked right after (existing probe pattern), and two id-addressed read requests:GET /organizations/{org_id}/team/{team_id}— Team exists in the installation's Organization, id/org/slug match policy. Permission: Organizationmembers: read.GET /organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}withAccept: application/vnd.github.v3.repository+json— returns the repository withpermissions; requires returnedid === repository_idandpush || maintain || admin.404= no grant. Permission: Organizationmembers: read(GitHub also lists it under repositoryadministration: read, which is not required).403 team_grant_missing, nothing minted. GitHub outage →502 token_unavailable(unchanged). Probe revocation failure never masks a refusal.ghsession (id route, media-type response shape,204/404semantics).Migration/readback gate (
src/broker.mjs)node src/broker.mjs policy check— offline parse + Workspace/Team binding summary (no key, no GitHub traffic).node src/broker.mjs policy check --live— installation verification, then everyrepository_idsentry of every Workspace with one probe per Workspace; prints a secret-free table (WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL) and exits non-zero on any refused row. Reads policy/key from any operator custody path; the serving commands keep the/run/secrets+/run/configmount requirement.parseCommandnow returns{ mode }(serve|verify|policy-check).Version: package 0.9.0,
User-Agentbumped, README compatibility matrix updated. Example policy migrated to v2.Docs: README (schema v2, error-code table, exact GitHub calls + required App permissions,
policy checkgate), ARCHITECTURE (Team binding section; Dashboard is a read-only lens — policy inputs are reviewed in the owner Deployment Repo, the broker's live check is the authority), AGENTS invariant 9.What deliberately does not change
actions: write,checks: read,contents: write,pull_requests: write(+ automaticmetadata: read); the probe token is separate,members: readonly, and revoked.--verify-only, adapter (brokered-gh), Worker bindings and health semantics.Verification
npm test: 55/55 pass (Node 26.5.0 locally; CI pins 24.19.0). New tests: parser (missing/duplicate/stringgithub_team_id, bad slug, missingmembers: read, v1 retired),verifyTeamGrantpositive path with exact call sequence/permissions/media type, 8 refusal cases incl. probe revocation, maintain/admin roles, outage vs refusal distinction, handler mint-then-revoked (second mint refused,mintTokencalled once), outage → 502 without cross-Workspace checks,checkPolicyLive+ exact table output,readWorkspaceTeamGrantsone-probe-per-Workspace, Workerteam_grant_missingand Worker rejecting a client without Team verification.npm run check: pass (license check, syntax, Wrangler 4.127.1 dry-run).verifyTeamGrant.policy checkon the example policy, on a v2 copy of the Spectoda policy (8 Workspaces parsed) and on a v2 copy of the Iotor policy (correctly refused:workspaces[0].github_team_id must be a positive integer);--livewithout a key fails with a clear message.ghsession over the same id-based endpoints (approximation ofpolicy check --live; the real gate needs the App key): Spectoda 139/139 grants ok, Iotor 25/25 ok (Team ids resolved by slug, see below).Migration notes (owner Deployment Repos; not edited by this PR)
infra/lazurio/organization-host/github-broker/policy.json: already carriesgithub_team_id+github_team_slugfor all 8 Workspaces andmembers: read. Migration = setschema_versiontolazurio.github_app_broker.policy.v2, runpolicy check --livewith the operator-custodied key, bumpdeployment.jsonto the 0.9.0 release/commit, redeploy.deploy.shalready performs an equivalent human-token Team readback (assert_live_team_repository_grants); it can stay as a second opinion. Live readback today: all 139 policy grants are write-capable.infra/lazurio/organization-host/github-broker/policy.json: has nogithub_team_id. Live IotorLazurio Teams (org id 315543488):energo18934201,management18934202,operations18934200,technical18934199 (a fifth Teamsteward19379814 has no Workspace). Add these ids (and optionally slugs) toiotor-energo/iotor-management/iotor-operations/iotor-technical, set schema v2, runpolicy check --live. Live readback today: all 25 policy grants are write-capable.policy check --livegreen → deploy 0.9.0. A 0.9.0 broker refuses v1 policies at startup//health(Node: no listener; Worker:503 configuration_unavailable), so switching the deployment before the policy is migrated would stop issuance — do it in that order.Risks / open points
/installation/repositoriespage); a missing Team refuses early after 3 requests (probe mint, Team read, probe revoke) with no repository-grant read.policy check --livecosts at most 4 + 3 × Workspaces + allowlisted grants on the fully successful path — for Spectoda 4 + 24 + 139 = 167 — and less when a Workspace's Team is missing (its grant reads are skipped). Rate limits are per installation; a rate-limit/quota response fails closed as502 token_unavailable(no readback cache), so quota exhaustion degrades to refusals, never to stale allows. Worth watching on the Spectoda Worker path.policy check --liverun by the operator is the proof.🤖 Generated with Claude Code
Note
Add live GitHub Team grant verification and v2 policy schema to token issuance
core.parsePolicyrejects v1 with a migration message and requiresmembers readinstallation permissioncore.createBrokerHandlerandworker.createWorkerEntrypointcallverifyTeamGrantto perform a fresh Team existence, identity, and write-capable exact-repository grant check using a short-lived installation probe token that is revoked after the checkpolicy check(offline) andpolicy check --liveCLI modes tobroker.runPolicyCheckfor operators to verify all Workspace grantsteam_grant_missing; GitHub outages remain 502token_unavailablecreateBrokerHandlerandworker.createWorkerEntrypointnow requireverifyTeamGranton the GitHub client and perform an extra live GitHub API call plus probe-token lifecycle per token request; existing v1 policies are no longer accepted bycore.parsePolicyChanges since #13 opened
502 token_unavailableerror classification to include rate-limit and quota responses alongside outage, unreachable, shape errors, and scope issues [0214753]repository_idspolicy gate behavior relative to GitHub Team grants [3ff468d]workspace.repository_idsacts as a deny-only allowlist gate that can refuse repositories before reading Team grants [c9544cd]📊 Macroscope summarized 377924f. 7 files reviewed, 4 issues evaluated, 4 issues filtered, 0 comments posted
🗂️ Filtered Issues
AGENTS.md — 0 comments posted, 1 evaluated, 1 filtered
createBrokerHandlerstill refuses a request atsrc/core.mjs:546unless the requested repository id is inworkspace.repository_ids, before the live Team-grant check. Thus a Team with a live write-capable repository grant is denied whenever that repository is absent from policy, so the policy remains a second access-control list and this instruction misstates the broker’s behavior. [ Out of scope (post-validation triage) ]ARCHITECTURE.md — 0 comments posted, 1 evaluated, 1 filtered
verifyTeamGrantfirst callswithProbeToken, which POSTs/app/installations/{id}/access_tokensbefore either Team read. Thus a missing Team/grant refusal has already minted (and then attempts to revoke) amembers: readprobe token. Document that no final one-repository token is minted, rather than claiming no token is minted. [ Out of scope (post-validation triage) ]README.md — 0 comments posted, 2 evaluated, 2 filtered
workspace.repository_idscheck: a repository on which the Team has a live write grant is still refused unless it is also in that Workspace’s policy allowlist. This misstates the access model operators must configure and audit. [ Out of scope (post-validation triage) ]{owner}/{repo}, which is derived from the mutablerepository.full_name(as the following paragraph itself states). This misdocuments the authorization/readback interface as fully ID-addressed even though a repository rename changes the requested path. [ Out of scope (post-validation triage) ]