Skip to content

Bind Workspaces to immutable GitHub Teams with live grant verification (DEV-6605) - #13

Merged
immakermatty merged 6 commits into
mainfrom
agent/DEV-6605-team-bound-workspace-policy
Sep 18, 2026
Merged

immakermatty merged 6 commits into
mainfrom
agent/DEV-6605-team-bound-workspace-policy

Conversation

@immakermatty

@immakermatty immakermatty commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Status: ready for review

Implements DEV-6605 task task-2026-09-18-002 (HumanAndMachine-ai/mission-control-data, plan DEV-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's workspace.repository_ids allowlist 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; the github_team_id already 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_id required, positive integer, unique across Workspaces (exactly one Workspace per Team).
  • workspaces[].github_team_slug optional readback assertion; when present the live slug must match (same rule as repository full_name: names are asserted, never select a different object).
  • installation_permissions.members must be read (the permission the Team readback needs).
  • v1 is rejected with an explicit migration message. id, credential_file, repository_ids unchanged.

Per-mint live Team gate (createGithubClient().verifyTeamGrant, wired in createBrokerHandler; both adapters)

  • Order on POST /v1/token: credential → allowlist → (Worker: live installation verification) → live Team grant → one-repository mint. Nothing is cached between requests.
  • Uses a members: read probe installation token, revoked right after (existing probe pattern), and two id-addressed read requests:
    1. GET /organizations/{org_id}/team/{team_id} — Team exists in the installation's Organization, id/org/slug match policy. Permission: Organization members: read.
    2. GET /organizations/{org_id}/team/{team_id}/repos/{owner}/{repo} with Accept: application/vnd.github.v3.repository+json — returns the repository with permissions; requires returned id === repository_id and push || maintain || admin. 404 = no grant. Permission: Organization members: read (GitHub also lists it under repository administration: read, which is not required).
  • Team missing / different identity / no grant / pull-or-triage-only / repo id mismatch / 204-without-permissions → 403 team_grant_missing, nothing minted. GitHub outage → 502 token_unavailable (unchanged). Probe revocation failure never masks a refusal.
  • Both calls were confirmed live against Spectoda with an operator gh session (id route, media-type response shape, 204/404 semantics).

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 every repository_ids entry 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/config mount requirement.
  • parseCommand now returns { mode } (serve | verify | policy-check).

Version: package 0.9.0, User-Agent bumped, README compatibility matrix updated. Example policy migrated to v2.

Docs: README (schema v2, error-code table, exact GitHub calls + required App permissions, policy check gate), 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

  • Token scope stays exactly actions: write, checks: read, contents: write, pull_requests: write (+ automatic metadata: read); the probe token is separate, members: read only, and revoked.
  • One token per operation; no caching; Node startup gate remains installation-level (a Team temporarily lacking a grant is refused per request, it does not stop the service for other Workspaces).
  • --verify-only, adapter (brokered-gh), Worker bindings and health semantics.
  • No Organization repos are edited here.

Verification

  • npm test: 55/55 pass (Node 26.5.0 locally; CI pins 24.19.0). New tests: parser (missing/duplicate/string github_team_id, bad slug, missing members: read, v1 retired), verifyTeamGrant positive 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, mintToken called once), outage → 502 without cross-Workspace checks, checkPolicyLive + exact table output, readWorkspaceTeamGrants one-probe-per-Workspace, Worker team_grant_missing and Worker rejecting a client without Team verification.
  • npm run check: pass (license check, syntax, Wrangler 4.127.1 dry-run).
  • Review round 1 (Pablo, P2 docs-only): request counts corrected in README/ARCHITECTURE; no code change, no count asserted anywhere beyond the existing exact 4-call sequence test for verifyTeamGrant.
  • CLI smoke: offline policy check on 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); --live without a key fails with a clear message.
  • Read-only readback with an operator gh session over the same id-based endpoints (approximation of policy 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)

  • Spectoda infra/lazurio/organization-host/github-broker/policy.json: already carries github_team_id + github_team_slug for all 8 Workspaces and members: read. Migration = set schema_version to lazurio.github_app_broker.policy.v2, run policy check --live with the operator-custodied key, bump deployment.json to the 0.9.0 release/commit, redeploy. deploy.sh already 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.
  • Iotor infra/lazurio/organization-host/github-broker/policy.json: has no github_team_id. Live IotorLazurio Teams (org id 315543488): energo 18934201, management 18934202, operations 18934200, technical 18934199 (a fifth Team steward 19379814 has no Workspace). Add these ids (and optionally slugs) to iotor-energo/iotor-management/iotor-operations/iotor-technical, set schema v2, run policy check --live. Live readback today: all 25 policy grants are write-capable.
  • Rollout order per plan risk note: migrate policy → policy check --live green → 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

  • GitHub request budget (documented in README "GitHub request budget"): a successful Team gate is 4 requests (probe mint, 2 reads, probe revoke), so an accepted Node token costs 5 GitHub requests and an accepted Worker token 9 (installation gate 4 + Team gate 4 + scoped mint 1; +1 per additional /installation/repositories page); a missing Team refuses early after 3 requests (probe mint, Team read, probe revoke) with no repository-grant read. policy check --live costs 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 as 502 token_unavailable (no readback cache), so quota exhaustion degrades to refusals, never to stale allows. Worth watching on the Spectoda Worker path.
  • Not verified with an App installation token end-to-end (no App key in this session); the endpoint/permission mapping is from GitHub's permissions reference and the live human-token readback. The first policy check --live run by the operator is the proof.

🤖 Generated with Claude Code

Note

Add live GitHub Team grant verification and v2 policy schema to token issuance

  • Binds each Workspace to one immutable GitHub Team id in policy v2; core.parsePolicy rejects v1 with a migration message and requires members read installation permission
  • Before every token mint, core.createBrokerHandler and worker.createWorkerEntrypoint call verifyTeamGrant to 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 check
  • Adds policy check (offline) and policy check --live CLI modes to broker.runPolicyCheck for operators to verify all Workspace grants
  • Maps deleted Teams, identity mismatches, missing grants, and non-write grants to HTTP 403 team_grant_missing; GitHub outages remain 502 token_unavailable
  • Behavioral Change: createBrokerHandler and worker.createWorkerEntrypoint now require verifyTeamGrant on 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 by core.parsePolicy

Changes since #13 opened

  • Expanded 502 token_unavailable error classification to include rate-limit and quota responses alongside outage, unreachable, shape errors, and scope issues [0214753]
  • Added GitHub request budget documentation specifying exact request counts for Team gate, Node adapter, Worker adapter, and policy check operations [0214753]
  • Clarified that refusals mint nothing and no readback is cached, with the only active window being the lifetime of already-issued tokens [0214753]
  • Clarified token lifecycle during Team verification refusals [3ff468d]
  • Clarified repository_ids policy gate behavior relative to GitHub Team grants [3ff468d]
  • Updated request cost documentation with new failure scenario rows [3ff468d]
  • Clarified authorization model to establish live GitHub Team membership and Team-to-repository grants as the sole grant authority, with the policy never superseding GitHub ACLs, and documented that workspace.repository_ids acts as a deny-only allowlist gate that can refuse repositories before reading Team grants [c9544cd]
  • Documented GitHub request flow and counts for various authorization scenarios, specifying that a successful Team gate requires four requests (probe mint, two reads, probe revocation), accepted Node requests cost five requests, accepted Worker requests cost nine requests due to repeating the installation gate, and missing Team scenarios now refuse early after three requests without reading repository 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
  • line 25: The new invariant says the policy is “never a second ACL,” but createBrokerHandler still refuses a request at src/core.mjs:546 unless the requested repository id is in workspace.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
  • line 60: The statement that a Team-grant refusal mints nothing is false: verifyTeamGrant first calls withProbeToken, which POSTs /app/installations/{id}/access_tokens before either Team read. Thus a missing Team/grant refusal has already minted (and then attempts to revoke) a members: read probe 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
  • line 44: The statement that the Team grant is the “only access authority” and that the policy is “never a second ACL” contradicts the broker’s enforced workspace.repository_ids check: 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) ]
  • line 141: The claim that both Team-gate REST calls are “addressed by immutable ids” is false for the repository-grant call: its route includes {owner}/{repo}, which is derived from the mutable repository.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) ]

immakermatty and others added 3 commits September 18, 2026 17:33
…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 agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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, then mintToken; no policy field bypasses that ordering.
  • The members-only probe carries only { members: "read" }; Team/grant divergence maps to 403 team_grant_missing, provider failure to 502 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.

Comment thread README.md Outdated
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>
Comment thread README.md Outdated
Comment thread ARCHITECTURE.md Outdated

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. A Team-gate refusal creates a short-lived members: read probe token before the Team or grant read. It must say that no final scoped repository token is minted, rather than that nothing is minted.
  2. GitHub provides the sole live grant authority, but the reviewed policy workspace.repository_ids allowlist 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.
  3. policy check --live = 4 + 3W + R is 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.

Comment thread README.md Outdated
Comment thread README.md
Comment thread README.md Outdated
… budget is a maximum

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. The existing unresolved ARCHITECTURE.md:51 thread is valid. GitHub Team/repository grants are the sole live granting authority, but workspace.repository_ids is 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.
  2. README.md:170 and the parallel ARCHITECTURE.md:67 call 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's policy check --live missing-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.

Comment thread README.md Outdated
…y-refusal Team gate cost

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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_ids is 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 --live formula 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.

@immakermatty
immakermatty deleted the agent/DEV-6605-team-bound-workspace-policy branch September 18, 2026 20:05
@immakermatty
immakermatty restored the agent/DEV-6605-team-bound-workspace-policy branch September 18, 2026 20:06
@immakermatty immakermatty reopened this Sep 18, 2026
@immakermatty
immakermatty merged commit 3e3568b into main Sep 18, 2026
5 checks passed
@immakermatty
immakermatty deleted the agent/DEV-6605-team-bound-workspace-policy branch September 18, 2026 20:08
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.

2 participants