From 2de53baecf293f0f0a985161a0393ed43d005f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 17:54:49 +0200 Subject: [PATCH 1/6] Mint read-only tokens for pull and triage Team grants A Team that holds only a `pull` or `triage` grant on a repository was refused with `team_grant_missing`, so an Organization that lowered a Team to read cut its Team Environment off the repository completely: no clone, no fetch, no issues. The live Team grant now selects one of two access tiers at mint time: - push/maintain/admin -> write tier, unchanged: actions: write, checks: read, contents: write, pull_requests: write (+ workflows: write when the policy declares it); - pull/triage -> read tier: contents: read (+ GitHub's automatic metadata: read), plus issues: write only when the policy declares that the installation accepted it. No contents, pull request, Actions, Checks or workflow write. No grant, a missing or drifted Team, or a foreign owner still refuse exactly as before. `mintToken` requires an explicit tier, so a lost tier fails before GitHub instead of becoming the write set, and the handler checks the minted tier matches the verified one. The token response adds `access` ("read" | "write"); `policy check` prints the permission set each tier asks for and lists read grants with an ACCESS column. The adapter returns the tier (a missing value from an older broker means write), still refuses no command up front, and after GitHub refuses a read-tier gh command adds one stderr line naming the read access instead of leaving it to look like an outage. Version 0.11.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- adapter/broker-client.mjs | 4 + adapter/brokered-gh.mjs | 15 +- package-lock.json | 4 +- package.json | 2 +- src/broker.mjs | 17 +- src/core.mjs | 98 +++++++--- src/worker.mjs | 4 +- test/broker.test.mjs | 377 +++++++++++++++++++++++++++++++------- test/brokered-gh.test.mjs | 118 ++++++++++++ test/worker.test.mjs | 31 +++- 10 files changed, 564 insertions(+), 106 deletions(-) diff --git a/adapter/broker-client.mjs b/adapter/broker-client.mjs index 9d8627e..d558fd6 100644 --- a/adapter/broker-client.mjs +++ b/adapter/broker-client.mjs @@ -158,7 +158,10 @@ export async function requestGitHubAppToken({ } catch { returnedRepository = undefined; } + // Brokers before 0.11.0 send no `access`; they mint only for push/maintain/admin grants. + const access = result?.access === undefined ? "write" : result.access; if ( + (access !== "read" && access !== "write") || typeof result?.token !== "string" || result.token.length < 20 || returnedRepository === undefined || @@ -177,6 +180,7 @@ export async function requestGitHubAppToken({ repository: returnedRepository, repositoryId: result.repository_id, expiresAt: result.expires_at, + access, }); } diff --git a/adapter/brokered-gh.mjs b/adapter/brokered-gh.mjs index 1e6b6f8..3343551 100644 --- a/adapter/brokered-gh.mjs +++ b/adapter/brokered-gh.mjs @@ -255,7 +255,20 @@ export async function runBrokeredGh({ assertConfigDirectory(); const repository = resolveGhRepository({ args, environment, readOrigin }); const credential = await requestToken({ repository, environment }); - return runRealGh(args, brokeredGhEnvironment(environment, credential.token, repository)); + // The adapter refuses nothing up front by access tier: official gh runs and GitHub decides. + // After GitHub refuses a read-tier command, say why, so it does not read like an outage. + const exitCode = runRealGh(args, brokeredGhEnvironment(environment, credential.token, repository)); + if (exitCode !== 0 && credential.access === "read") writeStderr(readAccessNotice(repository)); + return exitCode; +} + +/** Explains a failed command under a read-tier token; contains no token or credential. */ +export function readAccessNotice(repository) { + return ( + `Lazurio for GitHub: this Team Environment has read access to ${repository}. ` + + "GitHub refuses pushes, pull requests and other changes to it from here. " + + "Propose the change in an issue (gh issue create) or ask someone with write access.\n" + ); } async function main() { diff --git a/package-lock.json b/package-lock.json index b8087e0..67eca8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lazurio/github-app-broker", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lazurio/github-app-broker", - "version": "0.10.0", + "version": "0.11.0", "license": "Apache-2.0", "devDependencies": { "wrangler": "4.127.1" diff --git a/package.json b/package.json index 2abcea3..d5f1f6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lazurio/github-app-broker", - "version": "0.10.0", + "version": "0.11.0", "private": true, "license": "Apache-2.0", "repository": { diff --git a/src/broker.mjs b/src/broker.mjs index d713d85..6b95211 100644 --- a/src/broker.mjs +++ b/src/broker.mjs @@ -6,11 +6,14 @@ import http from "node:http"; import { MAX_BODY_BYTES, + READ_ACCESS, + WRITE_ACCESS, checkPolicyLive, createBrokerHandler, createGithubClient as createSharedGithubClient, formatPolicyCheckTable, parsePolicy, + tokenPermissions, } from "./core.mjs"; export { checkPolicyLive, formatPolicyCheckTable, parsePolicy } from "./core.mjs"; @@ -149,7 +152,17 @@ export function parseCommand(arguments_) { fail(USAGE); } -/** Offline summary of the parsed Team binding; no GitHub traffic and no secret. */ +function formatPermissions(permissions) { + return Object.entries(permissions) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([permission, level]) => `${permission}=${level}`) + .join(" "); +} + +/** + * Offline summary of the parsed Team binding and of the exact permission set each access tier + * mints under this policy; no GitHub traffic and no secret. + */ export function formatPolicySummary(policy) { const header = ["WORKSPACE", "TEAM_ID", "TEAM_SLUG"]; const rows = policy.workspaces.map((workspace) => [ @@ -161,6 +174,8 @@ export function formatPolicySummary(policy) { const line = (values) => values.map((value, column) => value.padEnd(widths[column])).join(" ").trimEnd(); return [ `${policy.schema_version} owner=${policy.github_owner.login} installation=${policy.installation_id}`, + `write tier (push/maintain/admin grant) asks for: ${formatPermissions(tokenPermissions(policy, WRITE_ACCESS))}`, + `read tier (pull/triage grant) asks for: ${formatPermissions(tokenPermissions(policy, READ_ACCESS))}`, line(header), ...rows.map(line), ].join("\n") + "\n"; diff --git a/src/core.mjs b/src/core.mjs index 93548a1..4bf7097 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -8,11 +8,19 @@ const RETIRED_POLICY_SCHEMA_V2 = "lazurio.github_app_broker.policy.v2"; const V3_MIGRATION = "migrate to lazurio.github_app_broker.policy.v3 by deleting `repositories` and every `workspaces[].repository_ids`; the live Team grant is the scope"; const GITHUB_API_VERSION = "2026-03-10"; -const USER_AGENT = "lazurio-github-app-broker/0.10.0"; +const USER_AGENT = "lazurio-github-app-broker/0.11.0"; /** Upper bound for `policy check --live` Team repository pages (100 repositories per page). */ const MAX_TEAM_REPOSITORY_PAGES = 100; const DUMMY_WORKSPACE_CREDENTIAL = "0".repeat(64); -const TOKEN_PERMISSIONS = Object.freeze({ + +/** + * Access tiers. The live Team grant selects the tier at mint time: a `push`, `maintain` or `admin` + * grant mints the write tier, a `pull` or `triage` grant the read tier. The policy holds no tier. + */ +export const WRITE_ACCESS = "write"; +export const READ_ACCESS = "read"; + +const WRITE_TOKEN_PERMISSIONS = Object.freeze({ actions: "write", checks: "read", contents: "write", @@ -23,10 +31,35 @@ const TOKEN_PERMISSIONS = Object.freeze({ * only when the Organization accepted it and the reviewed policy declares it, so a deployment whose * installation has not accepted the permission keeps minting the base set. */ -const WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...TOKEN_PERMISSIONS, workflows: "write" }); +const WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...WRITE_TOKEN_PERMISSIONS, workflows: "write" }); +/** + * A read-tier token can clone, fetch and read the repository (plus GitHub's automatic + * `metadata: read`). It never asks for pull request, Actions, Checks or workflow access. + */ +const READ_TOKEN_PERMISSIONS = Object.freeze({ contents: "read" }); +/** + * Opening an issue needs `issues: write`, GitHub's smallest installation permission that can create + * one. The read tier asks for it only when the Organization accepted it and the reviewed policy + * declares it, exactly like `workflows: write` for the write tier. + */ +const ISSUE_READ_TOKEN_PERMISSIONS = Object.freeze({ ...READ_TOKEN_PERMISSIONS, issues: "write" }); -function tokenPermissions(policy) { - return policy.installation_permissions.workflows === "write" ? WORKFLOW_TOKEN_PERMISSIONS : TOKEN_PERMISSIONS; +/** The exact permission set a token of `access` asks GitHub for under `policy`. */ +export function tokenPermissions(policy, access) { + if (access === WRITE_ACCESS) { + return policy.installation_permissions.workflows === "write" ? WORKFLOW_TOKEN_PERMISSIONS : WRITE_TOKEN_PERMISSIONS; + } + if (access === READ_ACCESS) { + return policy.installation_permissions.issues === "write" ? ISSUE_READ_TOKEN_PERMISSIONS : READ_TOKEN_PERMISSIONS; + } + fail("token access tier must be read or write"); +} + +/** Maps a live Team grant role to its access tier; undefined means no usable grant. */ +function grantAccess(role) { + if (role === "admin" || role === "maintain" || role === "write") return WRITE_ACCESS; + if (role === "triage" || role === "read") return READ_ACCESS; + return undefined; } /** * Organization `members: read` reads Teams and Team repository grants. GitHub answers the Team @@ -337,10 +370,6 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () return "none"; } - function isWriteCapable(role) { - return role === "admin" || role === "maintain" || role === "write"; - } - async function readTeam(policy, workspace, authorization) { const team = await request(`/organizations/${policy.github_owner.id}/team/${workspace.github_team_id}`, { authorization, @@ -399,6 +428,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () deny(`repository ${coordinate.full_name} belongs to another owner than the policy's Organization`); } const role = grantRole(grant?.permissions); + const access = grantAccess(role); const returned = parseRepositoryCoordinate(grant?.full_name); if ( grant?.owner?.id !== policy.github_owner.id || @@ -406,9 +436,9 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () grant.id <= 0 || !returned || (target?.repository_id !== undefined && grant.id !== target.repository_id) || - !isWriteCapable(role) + access === undefined ) { - refuse(`Workspace ${workspace.id} GitHub Team grant on repository ${coordinate.full_name} is not write-capable`); + refuse(`Workspace ${workspace.id} GitHub Team holds no verifiable grant on repository ${coordinate.full_name}`); } return Object.freeze({ workspace_id: workspace.id, @@ -416,6 +446,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () repository_id: grant.id, full_name: returned.full_name, role, + access, }); } @@ -423,7 +454,8 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () * Live per-mint Team gate. `target` is `{ repository: "Owner/name" }` or `{ repository_id }`. * Throws RepositoryDeniedError when the repository belongs to another owner, and TeamGrantError * when the Workspace's immutable GitHub Team is gone, has a different identity than the policy - * asserts, or lacks a write-capable grant on the repository. Nothing is cached across calls. + * asserts, or holds no grant on the repository. A `pull`/`triage` grant resolves to the read + * tier, a `push`/`maintain`/`admin` grant to the write tier. Nothing is cached across calls. */ async function verifyTeamGrant(policy, workspace, target) { return withProbeToken(policy, TEAM_PROBE_PERMISSIONS, async (authorization) => { @@ -442,9 +474,9 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () } /** - * Readback of one Workspace: its Team identity and the Team's live write-capable repositories, - * with a single probe token. A missing or drifted Team becomes one refused row; it never throws - * for that case. + * Readback of one Workspace: its Team identity and the Team's live repository grants with the + * access tier each one mints, with a single probe token. A missing or drifted Team becomes one + * refused row; it never throws for that case. */ async function readWorkspaceTeamGrants(policy, workspace) { return withProbeToken(policy, TEAM_PROBE_PERMISSIONS, async (authorization) => { @@ -461,6 +493,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () repository_id: "-", full_name: "-", role: "-", + access: "-", status: "refused", detail: error.message, }), @@ -476,12 +509,13 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () if (!Array.isArray(repositories)) fail("GitHub returned an invalid Team repository list"); for (const repository of repositories) { const role = grantRole(repository?.permissions); + const access = grantAccess(role); const coordinate = parseRepositoryCoordinate(repository?.full_name); if ( repository?.owner?.id !== policy.github_owner.id || !Number.isSafeInteger(repository?.id) || !coordinate || - !isWriteCapable(role) + access === undefined ) { continue; } @@ -493,6 +527,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () repository_id: repository.id, full_name: coordinate.full_name, role, + access, status: "ok", detail: "", }), @@ -509,8 +544,9 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () repository_id: "-", full_name: "-", role: "-", + access: "-", status: "ok", - detail: "Team holds no write-capable repository grant", + detail: "Team holds no repository grant", }), ); } @@ -518,8 +554,12 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () }); } - async function mintToken(policy, repositoryId) { - const requested = tokenPermissions(policy); + /** + * Mints the one-repository token of the `access` tier the live Team gate resolved. The tier is + * required: there is no default, so a caller that lost the tier can never mint the write set. + */ + async function mintToken(policy, repositoryId, access) { + const requested = tokenPermissions(policy, access); const result = await request(`/app/installations/${policy.installation_id}/access_tokens`, { method: "POST", body: { @@ -546,7 +586,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () ) { fail("GitHub returned a token outside the requested repository or permission scope"); } - return { token: result.token, expires_at: result.expires_at, repository_id: repositoryId }; + return { token: result.token, expires_at: result.expires_at, repository_id: repositoryId, access }; } return Object.freeze({ verifyPolicy, verifyTeamGrant, verifyWorkspaceTeam, readWorkspaceTeamGrants, mintToken }); @@ -554,8 +594,9 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () /** * Migration and readback gate: verifies the live installation, then every Workspace's Team - * binding, and lists each Team's live write-capable repositories as information. Only a missing - * or drifted Team (or installation drift, which throws) makes the result not ok. Emits no secret. + * binding, and lists each Team's live repository grants with the access tier each one mints as + * information. Only a missing or drifted Team (or installation drift, which throws) makes the + * result not ok. Emits no secret. */ export async function checkPolicyLive({ policy, github }) { if (typeof github?.verifyPolicy !== "function" || typeof github?.readWorkspaceTeamGrants !== "function") { @@ -576,6 +617,7 @@ const POLICY_CHECK_COLUMNS = Object.freeze([ ["repository_id", "REPOSITORY_ID"], ["full_name", "REPOSITORY"], ["role", "ROLE"], + ["access", "ACCESS"], ["status", "STATUS"], ["detail", "DETAIL"], ]); @@ -592,11 +634,13 @@ export function formatPolicyCheckTable(rows) { workspaces.set(row.workspace_id, refused ? "refused" : "ok"); } const refusedTeams = [...workspaces.values()].filter((status) => status === "refused").length; - const repositories = rows.filter(({ status, repository_id }) => status === "ok" && repository_id !== "-").length; + const grants = rows.filter(({ status, repository_id }) => status === "ok" && repository_id !== "-"); + const writeGrants = grants.filter(({ access }) => access === WRITE_ACCESS).length; + const readGrants = grants.filter(({ access }) => access === READ_ACCESS).length; return [ line(POLICY_CHECK_COLUMNS.map(([, header]) => header)), ...cells.map(line), - `${workspaces.size} Workspaces checked, ${workspaces.size - refusedTeams} Teams ok, ${refusedTeams} refused; ${repositories} write-capable repository grants`, + `${workspaces.size} Workspaces checked, ${workspaces.size - refusedTeams} Teams ok, ${refusedTeams} refused; ${grants.length} repository grants (${writeGrants} write, ${readGrants} read)`, ].join("\n") + "\n"; } @@ -658,16 +702,20 @@ export function createBrokerHandler({ policy, github, credentials, secretMatches } const repositoryId = grant?.repository_id; const repository = parseRepositoryCoordinate(grant?.full_name); + const access = grant?.access; if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0 || !repository) { fail("Team gate returned an invalid repository"); } - const minted = await github.mintToken(policy, repositoryId); + if (access !== WRITE_ACCESS && access !== READ_ACCESS) fail("Team gate returned an invalid access tier"); + const minted = await github.mintToken(policy, repositoryId, access); if (minted?.repository_id !== repositoryId) fail("minted token is outside the verified repository"); + if (minted?.access !== access) fail("minted token is outside the verified access tier"); return result(200, { token: minted.token, expires_at: minted.expires_at, repository_id: repositoryId, repository: repository.full_name, + access, }); } diff --git a/src/worker.mjs b/src/worker.mjs index c43bf98..7e91959 100644 --- a/src/worker.mjs +++ b/src/worker.mjs @@ -180,8 +180,8 @@ export function createWorkerEntrypoint({ createGithub = defaultGithub, fetchImpl await github.verifyPolicy(activePolicy); return github.verifyWorkspaceTeam(activePolicy, workspace); }, - mintToken(activePolicy, repositoryId) { - return github.mintToken(activePolicy, repositoryId); + mintToken(activePolicy, repositoryId, access) { + return github.mintToken(activePolicy, repositoryId, access); }, }); const handle = createBrokerHandler({ diff --git a/test/broker.test.mjs b/test/broker.test.mjs index 3f03e04..d510802 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -52,9 +52,11 @@ function policyFixture() { }; } +/** Synthetic live grants of alpha's Team: name -> [repository id, access tier the grant mints]. */ const SYNTHETIC_REPOSITORIES = new Map([ - ["example-org/alpha", 3001], - ["example-org/beta", 3002], + ["example-org/alpha", [3001, "write"]], + ["example-org/beta", [3002, "write"]], + ["example-org/handbook", [3004, "read"]], ]); /** Synthetic Workspace Team proof: the bound Team exists with its asserted identity. */ @@ -63,27 +65,33 @@ const provenTeam = async (_policy, workspace) => ({ slug: workspace.github_team_slug ?? workspace.id, }); -/** Synthetic Team gate: resolves both body forms to the canonical repository id and name. */ +/** Synthetic Team gate: resolves both body forms to the canonical repository id, name and tier. */ const grantedTeam = async (_policy, workspace, target) => { const entry = target.repository_id === undefined ? [...SYNTHETIC_REPOSITORIES].find(([name]) => name === target.repository.toLowerCase()) - : [...SYNTHETIC_REPOSITORIES].find(([, id]) => id === target.repository_id); + : [...SYNTHETIC_REPOSITORIES].find(([, [id]]) => id === target.repository_id); if (!entry) throw Object.assign(new Error("no grant"), { code: "team_grant_missing" }); + const [fullName, [repositoryId, access]] = entry; return { workspace_id: workspace.id, github_team_id: workspace.github_team_id, github_team_slug: workspace.github_team_slug ?? workspace.id, - repository_id: entry[1], - full_name: entry[0], - role: "write", + repository_id: repositoryId, + full_name: fullName, + role: access === "write" ? "write" : "read", + access, }; }; -async function withServer(run, mintToken = async (_policy, repositoryId) => ({ +/** Synthetic mint that honours the requested repository and access tier. */ +const syntheticMint = async (_policy, repositoryId, access) => ({ token: `ghs_synthetic_${repositoryId}`, expires_at: "2030-01-01T00:00:00Z", repository_id: repositoryId, -}), verifyTeamGrant = grantedTeam, verifyWorkspaceTeam = provenTeam) { + access, +}); + +async function withServer(run, mintToken = syntheticMint, verifyTeamGrant = grantedTeam, verifyWorkspaceTeam = provenTeam) { const server = createBrokerServer({ policy: parsePolicy(policyFixture()), github: { verifyTeamGrant, verifyWorkspaceTeam, mintToken }, @@ -155,11 +163,71 @@ test("mints a non-cacheable token for the Team-granted repository in both body f expires_at: "2030-01-01T00:00:00Z", repository_id: 3001, repository: "example-org/alpha", + access: "write", }); } }); }); +test("mints the read tier for a read-granted repository and says so in the response", async () => { + const mints = []; + await withServer( + async (origin) => { + for (const body of [{ repository: "example-org/handbook" }, { repository_id: 3004 }]) { + const response = await request(origin, { body }); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { + token: "ghs_synthetic_3004", + expires_at: "2030-01-01T00:00:00Z", + repository_id: 3004, + repository: "example-org/handbook", + access: "read", + }); + } + assert.deepEqual(mints, [[3004, "read"], [3004, "read"]]); + }, + async (policy, repositoryId, access) => { + mints.push([repositoryId, access]); + return syntheticMint(policy, repositoryId, access); + }, + ); +}); + +test("fails closed when the Team gate or the mint loses the access tier", async () => { + for (const [name, mintToken, verifyTeamGrant] of [ + [ + "gate without a tier", + async () => { + throw new Error("must not mint without a verified tier"); + }, + async (...args) => ({ ...(await grantedTeam(...args)), access: undefined }), + ], + [ + "gate with an unknown tier", + async () => { + throw new Error("must not mint without a verified tier"); + }, + async (...args) => ({ ...(await grantedTeam(...args)), access: "admin" }), + ], + [ + "mint of another tier", + async (policy, repositoryId) => syntheticMint(policy, repositoryId, "write"), + async (...args) => ({ ...(await grantedTeam(...args)), access: "read" }), + ], + ]) { + await withServer( + async (origin) => { + const response = await request(origin); + assert.equal(response.status, 502, name); + assert.deepEqual(await response.json(), { error: "token_unavailable" }, name); + }, + mintToken, + verifyTeamGrant, + ); + } +}); + test("rejects an invalid Workspace credential without calling GitHub", async () => { let calls = 0; await withServer( @@ -262,12 +330,13 @@ test("does not cache installation tokens between requests", async () => { assert.equal((await request(origin)).status, 200); assert.equal(calls, 2); }, - async (_policy, repositoryId) => { + async (_policy, repositoryId, access) => { calls += 1; return { token: `ghs_synthetic_${repositoryId}_${calls}`, expires_at: "2030-01-01T00:00:00Z", repository_id: repositoryId, + access, }; }, ); @@ -482,10 +551,11 @@ test("mints through repository_ids and rejects under- or over-scoped responses", fetchImpl, now: () => now, }); - assert.deepEqual(await github.mintToken(policy, 3001), { + assert.deepEqual(await github.mintToken(policy, 3001, "write"), { token: "ghs_scoped_synthetic_token", expires_at: new Date(now + 60 * 60 * 1000).toISOString(), repository_id: 3001, + access: "write", }); responsePermissions = { @@ -495,7 +565,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", pull_requests: "write", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); @@ -506,7 +576,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", pull_requests: "write", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); @@ -517,7 +587,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", metadata: "read", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); @@ -530,7 +600,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", pull_requests: "write", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); }); @@ -558,7 +628,7 @@ test("asks for workflows: write only when the policy declares the accepted permi const withoutWorkflows = parsePolicy(policyFixture()); responsePermissions = { ...base, metadata: "read", workflows: "write" }; await assert.rejects( - () => github.mintToken(withoutWorkflows, 3001), + () => github.mintToken(withoutWorkflows, 3001, "write"), /outside the requested repository or permission scope/, ); assert.deepEqual(requested, base); @@ -566,20 +636,104 @@ test("asks for workflows: write only when the policy declares the accepted permi const fixture = policyFixture(); fixture.installation_permissions = { ...fixture.installation_permissions, emails: "read", workflows: "write" }; const withWorkflows = parsePolicy(fixture); - assert.equal((await github.mintToken(withWorkflows, 3001)).repository_id, 3001); + assert.equal((await github.mintToken(withWorkflows, 3001, "write")).repository_id, 3001); assert.deepEqual(requested, { ...base, workflows: "write" }); responsePermissions = { ...base, metadata: "read" }; await assert.rejects( - () => github.mintToken(withWorkflows, 3001), + () => github.mintToken(withWorkflows, 3001, "write"), /outside the requested repository or permission scope/, ); responsePermissions = { ...base, metadata: "read", workflows: "write", emails: "read" }; await assert.rejects( - () => github.mintToken(withWorkflows, 3001), + () => github.mintToken(withWorkflows, 3001, "write"), /outside the requested repository or permission scope/, ); + + // The write tier never asks for issues, even when the installation accepted them. + fixture.installation_permissions.issues = "write"; + responsePermissions = { ...base, metadata: "read", workflows: "write" }; + assert.equal((await github.mintToken(parsePolicy(fixture), 3001, "write")).access, "write"); + assert.deepEqual(requested, { ...base, workflows: "write" }); +}); + +test("the read tier asks for contents: read, adds issues: write only when declared, and nothing else", async () => { + const now = 1_700_000_000_000; + let requests = 0; + let requested; + let responsePermissions; + const github = createGithubClient({ + appId: "42", + privateKey: testPrivateKey(), + fetchImpl: async (_url, init) => { + requests += 1; + const body = JSON.parse(init.body); + assert.deepEqual(body.repository_ids, [3004]); + requested = body.permissions; + return jsonResponse({ + token: "ghs_scoped_synthetic_token", + expires_at: new Date(now + 60 * 60 * 1000).toISOString(), + repositories: [{ id: 3004 }], + permissions: responsePermissions, + }); + }, + now: () => now, + }); + + // Base policy: no issues accepted. A workflows acceptance never leaks into the read tier. + const fixture = policyFixture(); + fixture.installation_permissions.workflows = "write"; + const withoutIssues = parsePolicy(fixture); + responsePermissions = { contents: "read", metadata: "read" }; + assert.deepEqual(await github.mintToken(withoutIssues, 3004, "read"), { + token: "ghs_scoped_synthetic_token", + expires_at: new Date(now + 60 * 60 * 1000).toISOString(), + repository_id: 3004, + access: "read", + }); + assert.deepEqual(requested, { contents: "read" }); + + fixture.installation_permissions.issues = "write"; + const withIssues = parsePolicy(fixture); + responsePermissions = { contents: "read", issues: "write", metadata: "read" }; + assert.equal((await github.mintToken(withIssues, 3004, "read")).access, "read"); + assert.deepEqual(requested, { contents: "read", issues: "write" }); + + // An issues permission declared only as read is not the accepted write permission. + fixture.installation_permissions.issues = "read"; + responsePermissions = { contents: "read", metadata: "read" }; + await github.mintToken(parsePolicy(fixture), 3004, "read"); + assert.deepEqual(requested, { contents: "read" }); + + // Any write beyond the requested read set, or a missing issues grant, is refused. + for (const permissions of [ + { contents: "write", metadata: "read" }, + { contents: "read", metadata: "read", pull_requests: "write" }, + { contents: "read", metadata: "read", actions: "write" }, + { contents: "read", metadata: "read", checks: "read" }, + { contents: "read", metadata: "read", workflows: "write" }, + { contents: "read", issues: "write", metadata: "read" }, + ]) { + responsePermissions = permissions; + await assert.rejects( + () => github.mintToken(withoutIssues, 3004, "read"), + /outside the requested repository or permission scope/, + JSON.stringify(permissions), + ); + } + responsePermissions = { contents: "read", metadata: "read" }; + await assert.rejects( + () => github.mintToken(withIssues, 3004, "read"), + /outside the requested repository or permission scope/, + ); + + // There is no default tier: a missing or unknown tier never reaches GitHub. + const before = requests; + for (const access of [undefined, "", "admin", "WRITE"]) { + await assert.rejects(() => github.mintToken(withIssues, 3004, access), /access tier must be read or write/); + } + assert.equal(requests, before); }); test("rejects duplicated Workspace credential values and aliased paths before listening", async () => { @@ -635,11 +789,7 @@ test("keeps one verified credential snapshot for the complete server lifetime", github: { verifyTeamGrant: grantedTeam, verifyWorkspaceTeam: provenTeam, - mintToken: async (_policy, repositoryId) => ({ - token: `ghs_synthetic_${repositoryId}`, - expires_at: "2030-01-01T00:00:00Z", - repository_id: repositoryId, - }), + mintToken: syntheticMint, }, readFile: (file) => { const count = (reads.get(file) ?? 0) + 1; @@ -673,11 +823,7 @@ test("verifies live policy exactly once before serving runtime requests", async }, verifyTeamGrant: grantedTeam, verifyWorkspaceTeam: provenTeam, - mintToken: async (_policy, repositoryId) => ({ - token: `ghs_synthetic_${repositoryId}`, - expires_at: "2030-01-01T00:00:00Z", - repository_id: repositoryId, - }), + mintToken: syntheticMint, }, readFile: (file) => secretByPath.get(file) ?? "", realpath: (file) => file, @@ -832,7 +978,7 @@ function teamGrantFetch({ }; } -test("verifies the live Team binding and write-capable grant by name with a revoked probe", async () => { +test("verifies the live Team binding and a write grant by name with a revoked probe", async () => { const policy = parsePolicy(policyFixture()); const calls = []; const github = createGithubClient({ @@ -848,6 +994,7 @@ test("verifies the live Team binding and write-capable grant by name with a revo repository_id: 3001, full_name: "example-org/alpha", role: "write", + access: "write", }); assert.deepEqual(calls.map(({ method, path }) => `${method} ${path}`), [ "POST /app/installations/2001/access_tokens", @@ -905,7 +1052,7 @@ test("returns the canonical repository name and id GitHub reports for a differen assert.ok(calls.includes("/organizations/1001/team/4001/repos/Example-Org/ALPHA")); }); -test("refuses with team_grant_missing when the Team is absent, drifted, or lacks a write grant", async () => { +test("refuses with team_grant_missing when the Team is absent, drifted, or holds no grant", async () => { const policy = parsePolicy(policyFixture()); const workspace = policy.workspaces[0]; const privateKey = testPrivateKey(); @@ -948,34 +1095,40 @@ test("refuses with team_grant_missing when the Team is absent, drifted, or lacks }, { name: "id not visible", fetch: teamGrantFetch(), target: { repository_id: 3999 }, message: /cannot see repository 3999/ }, { - name: "pull-only grant", - fetch: teamGrantFetch({ grant: writeGrant({ permissions: { admin: false, maintain: false, pull: true, push: false, triage: false } }) }), + name: "grant payload without any permission", + fetch: teamGrantFetch({ grant: writeGrant({ permissions: { admin: false, maintain: false, pull: false, push: false, triage: false } }) }), target: byName, - message: /not write-capable/, + message: /holds no verifiable grant on repository example-org\/alpha/, }, { - name: "triage-only grant", - fetch: teamGrantFetch({ grant: writeGrant({ permissions: { admin: false, maintain: false, pull: true, push: false, triage: true } }) }), + name: "grant payload without a permissions object", + fetch: teamGrantFetch({ grant: writeGrant({ permissions: undefined }) }), target: byId, - message: /not write-capable/, + message: /holds no verifiable grant/, }, { name: "id form grant returns a different repository id", fetch: teamGrantFetch({ grant: writeGrant({ id: 3999 }) }), target: byId, - message: /not write-capable/, + message: /holds no verifiable grant/, + }, + { + name: "read grant returns a different repository id", + fetch: teamGrantFetch({ grant: writeGrant({ id: 3999, permissions: { admin: false, maintain: false, pull: true, push: false, triage: false } }) }), + target: byId, + message: /holds no verifiable grant/, }, { name: "grant without a canonical full name", fetch: teamGrantFetch({ grant: writeGrant({ full_name: undefined }) }), target: byName, - message: /not write-capable/, + message: /holds no verifiable grant/, }, { name: "204 without permissions payload", fetch: teamGrantFetch(), target: byName, - message: /not write-capable/, + message: /holds no verifiable grant/, }, ]; for (const { name, fetch: fetchImpl, target, message } of cases) { @@ -1056,22 +1209,39 @@ test("refuses a repository of another owner with repository_denied in both forms } }); -test("accepts maintain and admin grants and distinguishes outages from refusals", async () => { +test("maps every live grant role to its access tier in both body forms", async () => { const policy = parsePolicy(policyFixture()); const privateKey = testPrivateKey(); - for (const [permissions, role] of [ - [{ admin: false, maintain: true, pull: true, push: true, triage: true }, "maintain"], - [{ admin: true, maintain: true, pull: true, push: true, triage: true }, "admin"], + for (const [permissions, role, access] of [ + [{ admin: false, maintain: false, pull: true, push: false, triage: false }, "read", "read"], + [{ admin: false, maintain: false, pull: true, push: false, triage: true }, "triage", "read"], + [{ admin: false, maintain: false, pull: true, push: true, triage: true }, "write", "write"], + [{ admin: false, maintain: true, pull: true, push: true, triage: true }, "maintain", "write"], + [{ admin: true, maintain: true, pull: true, push: true, triage: true }, "admin", "write"], ]) { - const github = createGithubClient({ - appId: "42", - privateKey, - fetchImpl: teamGrantFetch({ - grant: { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" }, permissions }, - }), - }); - assert.equal((await github.verifyTeamGrant(policy, policy.workspaces[0], { repository_id: 3001 })).role, role); + for (const target of [{ repository: "example-org/alpha" }, { repository_id: 3001 }]) { + const calls = []; + const github = createGithubClient({ + appId: "42", + privateKey, + fetchImpl: teamGrantFetch({ + calls, + grant: { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" }, permissions }, + }), + }); + const grant = await github.verifyTeamGrant(policy, policy.workspaces[0], target); + assert.deepEqual([grant.role, grant.access, grant.repository_id, grant.full_name], [role, access, 3001, "example-org/alpha"], role); + // The tier comes from the same single grant read through the revoked probe; nothing else is read. + assert.deepEqual(calls[0].body, { permissions: PROBE_PERMISSIONS }); + assert.equal(calls.at(-1).method, "DELETE"); + assert.equal(calls.filter(({ path }) => path.includes("/team/4001/repos/")).length, 1); + } } +}); + +test("distinguishes Team grant outages from refusals", async () => { + const policy = parsePolicy(policyFixture()); + const privateKey = testPrivateKey(); const outage = createGithubClient({ appId: "42", @@ -1105,13 +1275,14 @@ test("mints only after a live Team grant and refuses with team_grant_missing oth } return grantedTeam(policy, workspace, target); }, - mintToken: async (_policy, repositoryId) => { + mintToken: async (_policy, repositoryId, access) => { mints += 1; assert.equal(checks, mints, "the Team grant must be verified before every mint"); return { token: `ghs_synthetic_${repositoryId}_${mints}`, expires_at: "2030-01-01T00:00:00Z", repository_id: repositoryId, + access, }; }, }, @@ -1157,17 +1328,19 @@ async function withLiveCoreServer(fetchImpl, run) { } } -function mintingFetch(calls, teamFetch = teamGrantFetch()) { +/** Synthetic GitHub that grants exactly the requested scoped permissions plus metadata: read. */ +function mintingFetch(calls, teamFetch = teamGrantFetch(), scopedMints = []) { return async (url, init) => { const path = new URL(url).pathname; calls.push(`${init.method} ${path}`); if (path === "/app/installations/2001/access_tokens" && JSON.parse(init.body).repository_ids) { - const [repositoryId] = JSON.parse(init.body).repository_ids; + const { repository_ids: [repositoryId], permissions } = JSON.parse(init.body); + scopedMints.push(permissions); return jsonResponse({ token: `ghs_scoped_synthetic_${repositoryId}`, expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), repositories: [{ id: repositoryId }], - permissions: { actions: "write", checks: "read", contents: "write", metadata: "read", pull_requests: "write" }, + permissions: { ...permissions, metadata: "read" }, }); } return teamFetch(url, init); @@ -1217,6 +1390,57 @@ test("end to end: a Team-granted repository mints for its id; other Teams, repos }); }); +test("end to end: a pull or triage grant mints a read-tier token; push mints write; no grant mints nothing", async () => { + const readGrant = (triage) => teamGrantFetch({ + grant: { + id: 3001, + full_name: "example-org/alpha", + owner: { id: 1001, login: "example-org" }, + permissions: { admin: false, maintain: false, pull: true, push: false, triage }, + }, + }); + for (const triage of [false, true]) { + const calls = []; + const scopedMints = []; + await withLiveCoreServer(mintingFetch(calls, readGrant(triage), scopedMints), async (origin) => { + for (const body of [{ repository: "example-org/alpha" }, { repository_id: 3001 }]) { + const response = await request(origin, { body }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.access, "read"); + assert.equal(json.repository_id, 3001); + assert.equal(json.repository, "example-org/alpha"); + } + // The installation has not accepted issues: write, so the read tier asks for contents only. + assert.deepEqual(scopedMints, [{ contents: "read" }, { contents: "read" }]); + + // Cross-Workspace: beta's Team holds nothing on alpha, whatever alpha's Team holds. + const crossWorkspace = await request(origin, { + workspace: "beta-team", + secret: secretByPath.get("/run/secrets/workspace-beta"), + }); + assert.equal(crossWorkspace.status, 403); + assert.deepEqual(await crossWorkspace.json(), { error: "team_grant_missing" }); + assert.equal(scopedMints.length, 2); + }); + } + + const writeMints = []; + await withLiveCoreServer(mintingFetch([], teamGrantFetch(), writeMints), async (origin) => { + const response = await request(origin); + assert.equal((await response.json()).access, "write"); + assert.deepEqual(writeMints, [{ actions: "write", checks: "read", contents: "write", pull_requests: "write" }]); + }); + + const noneMints = []; + await withLiveCoreServer(mintingFetch([], teamGrantFetch({ grant: null }), noneMints), async (origin) => { + const response = await request(origin); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "team_grant_missing" }); + assert.deepEqual(noneMints, []); + }); +}); + test("end to end: an id of another owner is refused with repository_denied and mints nothing", async () => { const calls = []; const foreign = teamGrantFetch({ @@ -1273,6 +1497,8 @@ test("policy check --live lists live Team grants as information and fails only o formatPolicySummary(policy), [ "lazurio.github_app_broker.policy.v3 owner=example-org installation=2001", + "write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write pull_requests=write", + "read tier (pull/triage grant) asks for: contents=read", "WORKSPACE TEAM_ID TEAM_SLUG", "alpha-team 4001 alpha-team", "beta-team 4002 -", @@ -1283,11 +1509,12 @@ test("policy check --live lists live Team grants as information and fails only o const probes = []; const rowsFor = { "alpha-team": [ - { workspace_id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", repository_id: 3001, full_name: "example-org/alpha", role: "write", status: "ok", detail: "" }, - { workspace_id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", repository_id: 3003, full_name: "example-org/gamma", role: "admin", status: "ok", detail: "" }, + { workspace_id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", repository_id: 3001, full_name: "example-org/alpha", role: "write", access: "write", status: "ok", detail: "" }, + { workspace_id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", repository_id: 3003, full_name: "example-org/gamma", role: "admin", access: "write", status: "ok", detail: "" }, + { workspace_id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", repository_id: 3004, full_name: "example-org/handbook", role: "read", access: "read", status: "ok", detail: "" }, ], "beta-team": [ - { workspace_id: "beta-team", github_team_id: 4002, github_team_slug: "?", repository_id: "-", full_name: "-", role: "-", status: "refused", detail: "Workspace beta-team GitHub Team 4002 no longer exists in the Organization" }, + { workspace_id: "beta-team", github_team_id: 4002, github_team_slug: "?", repository_id: "-", full_name: "-", role: "-", access: "-", status: "refused", detail: "Workspace beta-team GitHub Team 4002 no longer exists in the Organization" }, ], }; const github = { @@ -1304,28 +1531,29 @@ test("policy check --live lists live Team grants as information and fails only o assert.equal( table, [ - "WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL", - "alpha-team 4001 alpha-team 3001 example-org/alpha write ok", - "alpha-team 4001 alpha-team 3003 example-org/gamma admin ok", - "beta-team 4002 ? - - - refused Workspace beta-team GitHub Team 4002 no longer exists in the Organization", - "2 Workspaces checked, 1 Teams ok, 1 refused; 2 write-capable repository grants", + "WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE ACCESS STATUS DETAIL", + "alpha-team 4001 alpha-team 3001 example-org/alpha write write ok", + "alpha-team 4001 alpha-team 3003 example-org/gamma admin write ok", + "alpha-team 4001 alpha-team 3004 example-org/handbook read read ok", + "beta-team 4002 ? - - - - refused Workspace beta-team GitHub Team 4002 no longer exists in the Organization", + "2 Workspaces checked, 1 Teams ok, 1 refused; 3 repository grants (2 write, 1 read)", "", ].join("\n"), ); assert.doesNotMatch(table, /ghs_|secret/i); rowsFor["beta-team"] = [ - { workspace_id: "beta-team", github_team_id: 4002, github_team_slug: "beta", repository_id: "-", full_name: "-", role: "-", status: "ok", detail: "Team holds no write-capable repository grant" }, + { workspace_id: "beta-team", github_team_id: 4002, github_team_slug: "beta", repository_id: "-", full_name: "-", role: "-", access: "-", status: "ok", detail: "Team holds no repository grant" }, ]; const healthy = await checkPolicyLive({ policy, github }); assert.equal(healthy.ok, true, "a Team without grants is information, not drift"); - assert.match(formatPolicyCheckTable(healthy.rows), /2 Workspaces checked, 2 Teams ok, 0 refused; 2 write-capable repository grants/); + assert.match(formatPolicyCheckTable(healthy.rows), /2 Workspaces checked, 2 Teams ok, 0 refused; 3 repository grants \(2 write, 1 read\)/); const failedInstallation = { ...github, verifyPolicy: async () => { throw new Error("live GitHub installation identity, selection or permissions differ from policy"); } }; await assert.rejects(() => checkPolicyLive({ policy, github: failedInstallation }), /differ from policy/); }); -test("readWorkspaceTeamGrants paginates the Team's write-capable repositories with one probe", async () => { +test("readWorkspaceTeamGrants paginates the Team's repository grants with their tier and one probe", async () => { const policy = parsePolicy(policyFixture()); const calls = []; const writable = { admin: false, maintain: false, pull: true, push: true, triage: true }; @@ -1339,6 +1567,7 @@ test("readWorkspaceTeamGrants paginates the Team's write-capable repositories wi { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" }, permissions: { ...writable, maintain: true } }, { id: 9001, full_name: "other-org/foreign", owner: { id: 1002, login: "other-org" }, permissions: writable }, { id: 3002, full_name: "example-org/beta", owner: { id: 1001, login: "example-org" }, permissions: { pull: true } }, + { id: 3005, full_name: "example-org/none", owner: { id: 1001, login: "example-org" }, permissions: { pull: false } }, ]; const fetchImpl = async (url, init) => { const path = new URL(url).pathname + new URL(url).search; @@ -1360,24 +1589,30 @@ test("readWorkspaceTeamGrants paginates the Team's write-capable repositories wi const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); const alpha = await github.readWorkspaceTeamGrants(policy, policy.workspaces[0]); - assert.equal(alpha.length, 100, "99 write grants from page one plus alpha; triage, read-only and foreign rows are skipped"); - assert.deepEqual(alpha.at(-1), { + assert.equal(alpha.length, 102, "100 grants from page one plus alpha and beta; foreign and grant-less rows are skipped"); + assert.deepEqual(alpha.at(-2), { workspace_id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", repository_id: 3001, full_name: "example-org/alpha", role: "maintain", + access: "write", status: "ok", detail: "", }); - assert.equal(alpha.some(({ repository_id }) => repository_id === 9001 || repository_id === 3002 || repository_id === 5000), false); + assert.deepEqual( + alpha.filter(({ access }) => access === "read").map(({ repository_id, role }) => [repository_id, role]), + [[5000, "triage"], [3002, "read"]], + ); + assert.equal(alpha.filter(({ access }) => access === "write").length, 100); + assert.equal(alpha.some(({ repository_id }) => repository_id === 9001 || repository_id === 3005), false); assert.equal(calls.filter((call) => call === "POST /app/installations/2001/access_tokens").length, 1); assert.equal(calls.at(-1), "DELETE /installation/token"); const beta = await github.readWorkspaceTeamGrants(policy, policy.workspaces[1]); assert.deepEqual(beta.map(({ repository_id, status, detail }) => [repository_id, status, detail]), [ - ["-", "ok", "Team holds no write-capable repository grant"], + ["-", "ok", "Team holds no repository grant"], ]); }); diff --git a/test/brokered-gh.test.mjs b/test/brokered-gh.test.mjs index 585ba69..a799982 100644 --- a/test/brokered-gh.test.mjs +++ b/test/brokered-gh.test.mjs @@ -17,6 +17,7 @@ import { classifyGhCommand, HOSTED_GITHUB_ACTOR, READ_ONLY_GH_CONFIG_DIR, + readAccessNotice, resolveGhRepository, runBrokeredGh, uncredentialedGhEnvironment, @@ -117,6 +118,8 @@ test("requests one fresh repo-scoped token by name without exposing the Workspac repository: "Example/Alpha", repositoryId: 101, expiresAt: "2026-08-18T13:00:00Z", + // A broker before 0.11.0 sends no tier; it minted only for write-capable grants. + access: "write", }); assert.equal(captured.url, "http://github-token-broker:8787/v1/token"); assert.deepEqual(JSON.parse(captured.options.body), { repository: "example/alpha" }); @@ -253,6 +256,30 @@ test("rejects a broker response for another repository or without a canonical id } }); +test("reports the access tier the broker minted and rejects an unknown tier", async () => { + const shared = { + repository: "Example/Alpha", + environment, + readFile: () => "workspace-credential-with-at-least-32-bytes", + now: () => NOW, + }; + for (const access of ["read", "write"]) { + const result = await requestGitHubAppToken({ + ...shared, + fetchImpl: async () => response({ ...validToken(), access }), + }); + assert.equal(result.access, access); + assert.equal(result.token, TOKEN); + } + for (const access of [null, "", "admin", "READ", 1]) { + await assert.rejects( + () => requestGitHubAppToken({ ...shared, fetchImpl: async () => response({ ...validToken(), access }) }), + /invalid scoped response/, + JSON.stringify(access), + ); + } +}); + test("broker refusal, timeout and malformed response fail closed", async () => { const shared = { repository: "Example/Alpha", @@ -559,6 +586,97 @@ test("repository operations accept T3 lowercase identity and pass only an env-lo assert.equal(child.environment.GH_CONFIG_DIR, READ_ONLY_GH_CONFIG_DIR); }); +test("the gh allow-list refuses no repository command by access tier", () => { + for (const args of [ + ["pr", "create", "--title", "t", "--body", "b"], + ["pr", "merge", "1"], + ["pr", "comment", "1", "--body", "b"], + ["issue", "create", "--title", "t", "--body", "b"], + ["issue", "list"], + ["run", "rerun", "1"], + ["repo", "view"], + ["api", "repos/Example/Alpha/contents/README.md"], + ["api", "-X", "POST", "repos/Example/Alpha/issues", "-f", "title=t"], + ]) { + assert.equal(classifyGhCommand(args, environment), "repository", args.join(" ")); + } +}); + +/** A requestToken that runs the real broker client against a stub broker answering `body`. */ +function brokerClientAnswering(body, requests = []) { + return (options) => requestGitHubAppToken({ + ...options, + readFile: () => "workspace-credential-with-at-least-32-bytes", + exists: () => false, + fetchImpl: async (url, init) => { + requests.push({ url, body: JSON.parse(init.body) }); + return response(body); + }, + now: () => NOW, + }); +} + +test("a read-tier token runs official gh; reads succeed silently and a refused write is explained", async () => { + const readToken = { ...validToken(101, "Example/Alpha"), access: "read" }; + const children = []; + const stderr = []; + const run = (args, exitCode, body = readToken) => runBrokeredGh({ + args, + environment, + readOrigin: () => "https://github.com/Example/Alpha.git", + requestToken: brokerClientAnswering(body), + assertConfigDirectory: () => {}, + runRealGh: (childArgs, childEnvironment) => { + children.push({ args: childArgs, token: childEnvironment.GH_TOKEN }); + return exitCode; + }, + writeStderr: (value) => stderr.push(value), + }); + + // Reads and issue creation go to official gh with the read-tier token and add no output. + assert.equal(await run(["issue", "create", "--title", "t", "--body", "b"], 0), 0); + assert.equal(await run(["api", "repos/Example/Alpha", "--jq", ".full_name"], 0), 0); + assert.deepEqual(stderr, []); + + // A pull request is not refused up front: official gh runs, GitHub refuses, the exit code + // passes through and the adapter says why instead of looking like a broker outage. + assert.equal(await run(["pr", "create", "--title", "t", "--body", "b"], 1), 1); + assert.deepEqual(children.map(({ args }) => args[0]), ["issue", "api", "pr"]); + assert.ok(children.every(({ token }) => token === TOKEN)); + assert.deepEqual(stderr, [readAccessNotice("Example/Alpha")]); + assert.match(stderr[0], /read access to Example\/Alpha/); + assert.match(stderr[0], /gh issue create/); + assert.equal(stderr.join("").includes(TOKEN), false); + + // A write-tier failure, or one from a broker that reports no tier, stays official gh's own output. + assert.equal(await run(["pr", "create"], 1, { ...validToken(), access: "write" }), 1); + assert.equal(await run(["pr", "create"], 1, validToken()), 1); + assert.equal(stderr.length, 1); +}); + +test("discovery inside a checkout succeeds with a read-tier repository proof", async () => { + const stdout = []; + const requests = []; + assert.equal( + await runBrokeredGh({ + args: ["auth", "status", "--json", "hosts"], + environment, + readOrigin: () => "https://github.com/Example/Alpha.git", + requestToken: brokerClientAnswering({ ...validToken(), access: "read" }, requests), + requestWorkspace: async () => { + throw new Error("a checkout proof never falls back to the Workspace proof"); + }, + writeStdout: (value) => stdout.push(value), + writeStderr: () => {}, + }), + 0, + ); + assert.equal(JSON.parse(stdout.at(-1)).hosts["github.com"][0].state, "success"); + assert.deepEqual(requests.map(({ url, body }) => [url, body]), [ + ["http://github-token-broker:8787/v1/token", { repository: "Example/Alpha" }], + ]); +}); + test("denied commands cannot reach the broker or official gh", async () => { let brokerCalls = 0; let childCalls = 0; diff --git a/test/worker.test.mjs b/test/worker.test.mjs index f706955..e10d576 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -61,13 +61,13 @@ const grantedAlpha = async (_policy, _workspace, target) => { if (target.repository !== undefined && target.repository.toLowerCase() !== "example-org/alpha") { throw new TeamGrantError("no grant"); } - return { repository_id: 3001, full_name: "example-org/alpha", role: "write" }; + return { repository_id: 3001, full_name: "example-org/alpha", role: "write", access: "write" }; }; const provenTeam = async (_policy, workspace) => ({ id: workspace.github_team_id, slug: workspace.id }); function workerFixture({ teamGrant = grantedAlpha, workspaceTeam = provenTeam } = {}) { - const calls = { verify: 0, teamGrant: 0, workspaceTeam: 0, mint: 0, order: [] }; + const calls = { verify: 0, teamGrant: 0, workspaceTeam: 0, mint: 0, order: [], tiers: [] }; const worker = createWorkerEntrypoint({ createGithub: () => ({ async verifyPolicy() { @@ -84,13 +84,15 @@ function workerFixture({ teamGrant = grantedAlpha, workspaceTeam = provenTeam } calls.order.push("workspace-team"); return workspaceTeam(policy, workspace); }, - async mintToken(_policy, repositoryId) { + async mintToken(_policy, repositoryId, access) { calls.mint += 1; calls.order.push("mint"); + calls.tiers.push(access); return { token: `ghs_synthetic_${repositoryId}`, expires_at: "2030-01-01T00:00:00Z", repository_id: repositoryId, + access, }; }, }), @@ -263,10 +265,33 @@ test("Worker verifies the live installation, then the Team grant, before every f expires_at: "2030-01-01T00:00:00Z", repository_id: 3001, repository: "example-org/alpha", + access: "write", }); } assert.deepEqual(countsOf(calls), { verify: 2, teamGrant: 2, mint: 2 }); assert.deepEqual(calls.order, ["installation", "team", "mint", "installation", "team", "mint"]); + assert.deepEqual(calls.tiers, ["write", "write"]); +}); + +test("Worker passes the read tier of a pull or triage grant to the mint and reports it", async () => { + const { worker, calls } = workerFixture({ + teamGrant: async (policy, workspace, target) => ({ + ...(await grantedAlpha(policy, workspace, target)), + role: "triage", + access: "read", + }), + }); + const response = await worker.fetch(tokenRequest(), environment()); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + token: "ghs_synthetic_3001", + expires_at: "2030-01-01T00:00:00Z", + repository_id: 3001, + repository: "example-org/alpha", + access: "read", + }); + assert.deepEqual(calls.order, ["installation", "team", "mint"]); + assert.deepEqual(calls.tiers, ["read"]); }); test("Worker refuses cross-repository and foreign-owner ids from the Team gate and mints nothing", async () => { From 2f25f6060e36db9ff09011b277943cd1c3254ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 17:54:49 +0200 Subject: [PATCH 2/6] Document read-only Team grants, the issues permission and the rollout order README, ARCHITECTURE and the AGENTS invariants describe the two access tiers, why triage maps to the read tier, the honest scope of `issues: write` (GitHub has no create-only issue permission), the App permission an Organization must accept before read-only Environments can open issues, and the rollout order: release, deploy the broker per Organization, optionally pin the adapter, accept issues where wanted, and only then lower Team grants. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 32 ++++--- ARCHITECTURE.md | 107 +++++++++++++-------- README.md | 240 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 276 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index faea018..218ee05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,11 +13,14 @@ dependency-light and customer-neutral. request and never cached; a name never selects a different object than the id GitHub returns for it. 3. The App private key never leaves the broker workload. -4. A token is limited to one repository and exactly `actions: write`, - `checks: read`, `contents: write` and `pull_requests: write` (plus GitHub's - automatic `metadata: read`), plus `workflows: write` only when the reviewed - policy declares that the installation accepted it, and expires on GitHub's - installation-token schedule. +4. A token is limited to one repository and to exactly one of two permission + sets (plus GitHub's automatic `metadata: read`), and expires on GitHub's + installation-token schedule. The write tier is `actions: write`, + `checks: read`, `contents: write` and `pull_requests: write`, plus + `workflows: write` only when the reviewed policy declares that the + installation accepted it. The read tier is `contents: read`, plus + `issues: write` only when the reviewed policy declares that the + installation accepted it. A mint without an explicit tier fails. 5. Workspace credentials and installation tokens are never logged, committed, stored in Git configuration or persisted by this service. 6. The broker authorizes a Workspace and repository, never a browser user. @@ -25,12 +28,13 @@ dependency-light and customer-neutral. 8. Every behavior change requires focused tests for the positive path and cross-Workspace, cross-repository and foreign-owner denial. 9. Every Workspace is bound to exactly one immutable GitHub Team id, and every - token issuance verifies live that the Team exists and holds a write-capable - grant on the exact repository, failing closed with `team_grant_missing`. - The live Team grant is the scope: the policy holds no repository list and - never narrows or widens it. Nothing from that readback is cached; the - policy, the Dashboard and any derived mapping are reviewed inputs, never a - second ACL. + token issuance verifies live that the Team exists and holds a grant on the + exact repository, failing closed with `team_grant_missing`. The grant's + role selects the tier: `push`, `maintain` or `admin` mints the write tier, + `pull` or `triage` the read tier. The live Team grant is the scope: the + policy holds no repository list or tier and never narrows or widens it. + Nothing from that readback is cached; the policy, the Dashboard and any + derived mapping are reviewed inputs, never a second ACL. `actions: write` is GitHub's smallest installation-token permission that can rerun a workflow. GitHub does not provide a rerun-only token capability, so @@ -38,6 +42,12 @@ this permission also authorizes other Actions mutations inside the same exact repository. Keep that provider limitation explicit in architecture and operator documentation; never describe the token as rerun-only. +`issues: write` is likewise GitHub's smallest installation-token permission +that can open an issue. It also authorizes editing, labelling, closing, +reopening and commenting on existing issues of the same repository. Never +describe a read-tier token with `issues: write` as read-only or as able only +to create issues. + Use an isolated worktree and pull request for every change. Code, comments, commits and pull-request descriptions are English. Run `npm test` and `npm run check` before handoff. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 77e344d..07d387b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,15 +11,18 @@ predeclared Team Workspaces. Its deployment-owned policy binds: - each immutable Workspace id to exactly one immutable GitHub Team id and a separate deployment credential reference. -The policy names no repositories. The scope of a Workspace is the live GitHub -Team grant: the repositories of the policy's Organization on which the -Workspace's Team holds a `push`, `maintain` or `admin` grant at the moment of -a request. The policy no longer narrows below GitHub. An Organization that -wants a narrower scope for a Team Environment does it with GitHub Team grants: -it grants that Team only the repositories the Environment should reach, or -binds the Environment to a dedicated, narrower Team. GitHub stays the one -place where access is granted, read and revoked, and an Organization Admin can -make a new repository available to a Team Environment herself, without a +The policy names no repositories and no access tiers. The scope of a +Workspace is the live GitHub Team grant: the repositories of the policy's +Organization on which the Workspace's Team holds any repository grant at the +moment of a request, and the grant's role selects the tier. A `push`, +`maintain` or `admin` grant mints the write tier; a `pull` or `triage` grant +mints the read tier. The policy no longer narrows below GitHub. An +Organization that wants a narrower scope for a Team Environment does it with +GitHub Team grants: it grants that Team only the repositories the Environment +should reach, at the role it should have, or binds the Environment to a +dedicated, narrower Team. GitHub stays the one place where access is granted, +read and revoked, and an Organization Admin can make a new repository +available to a Team Environment, or lower it to read, herself, without a broker policy change or redeploy. Credential references and credential values must be unique across all @@ -34,13 +37,20 @@ owner, target type, repository selection mode and exact permissions. It does not compare a repository set; there is none in the policy. The Node adapter performs this gate before listening; the Worker performs it after local authorization on every valid token request. Every token request then asks -GitHub to mint a new one-repository token with exactly `actions: write`, -`checks: read`, `contents: write` and `pull_requests: write`, plus -`workflows: write` when the policy declares that the installation accepted it, -and refuses a response with any other scope or any other repository. -Between the installation gate and the mint, the runtime performs the Team gate -described below; it is the step that turns a live GitHub Team grant into the -authority for the token. +GitHub to mint a new one-repository token with exactly the permission set of +its tier, and refuses a response with any other scope or any other repository: + +- write tier: `actions: write`, `checks: read`, `contents: write` and + `pull_requests: write`, plus `workflows: write` when the policy declares + that the installation accepted it; +- read tier: `contents: read`, plus `issues: write` when the policy declares + that the installation accepted it. + +GitHub adds `metadata: read` to both. There is no default tier: a mint without +an explicit tier fails before reaching GitHub, so a lost tier can never become +the write set. Between the installation gate and the mint, the runtime +performs the Team gate described below; it is the step that turns a live +GitHub Team grant into the authority and the tier for the token. Read-only Checks access and Actions write access let the standard GitHub CLI render check runs and their workflow-run context and explicitly rerun a failed workflow without a synthetic commit or human credential. GitHub exposes no @@ -49,6 +59,21 @@ Actions mutations in the same repository. The remaining boundaries therefore stay material: one immutable repository per short-lived token, a live Team grant on exactly that repository and no Checks write access. +The read tier exists so an Organization can keep some repositories read-only +for a Team without cutting that Team's Environment off them. A read-tier token +clones, fetches and reads one repository and receives no contents, pull +request, Actions, Checks or workflow write and no Checks or Actions read. +`issues: write` is the only write it can carry, so that an Agent without write +access can propose a change as an issue the way a person with read access +does. GitHub has no create-only issue permission: the same permission lets the +token edit, label, close, reopen and comment on existing issues of that +repository, which is closer to the `triage` role for issues than to read +access. An Organization opts into that by accepting the App permission and +declaring it in its reviewed policy; without it the read tier only reads. +`triage` maps to the same read tier as `pull`, because the permission that +would add pull request triage, `pull_requests: write`, also opens and edits +pull requests. + `workflows: write` lets a Workspace push commits that change `.github/workflows/`. Editing a workflow does not by itself run it or hand it secrets, but a workflow it writes can, in an eligible run, reference and @@ -59,7 +84,7 @@ approval). That is the same capability a Team member with a write grant already has from GitHub, and an Organization opts into it by accepting the App permission and declaring it in its reviewed policy. A policy without it mints the base set, so the release can roll out before any Organization -accepts. +accepts. The same holds for `issues: write` in the read tier. ## Team binding and live grant verification @@ -83,17 +108,20 @@ the id form resolves the repository's current name and owner by `/repositories/{id}` (another owner is `repository_denied`), checks the Team's grant with `/organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}` and the repository-permissions media type, revokes the probe, and only then mints -the one-repository token for the repository id GitHub returned. The returned -`owner.id` must equal the policy's Organization id. A missing Team, an -identity that differs from the policy assertions, a missing grant, a -`pull`/`triage`-only grant, a repository the installation cannot see or, for -the id form, a returned id that differs from the requested id is refused with -`403 team_grant_missing`; a GitHub outage, rate-limit or quota response is -`502 token_unavailable`. No refusal issues a repository token — the only -token minted before the decision is the short-lived probe, already revoked — -and nothing from the readback is retained between requests, so a revoked -grant can still act only through a token already issued, and a new grant -works on the next request. +the one-repository token of the grant's tier for the repository id GitHub +returned. The returned `owner.id` must equal the policy's Organization id. +`admin`, `maintain` or `push` selects the write tier, `triage` or `pull` the +read tier. A missing Team, an identity that differs from the policy +assertions, a missing grant or one without any of these roles, a repository +the installation cannot see or, for the id form, a returned id that differs +from the requested id is refused with `403 team_grant_missing`; a GitHub +outage, rate-limit or quota response is `502 token_unavailable`. No refusal +issues a repository token — the only token minted before the decision is the +short-lived probe, already revoked — and nothing from the readback is +retained between requests, so a revoked or +lowered grant can still act only through a token already issued, and a new or +raised grant works on the next request. The token response names the minted +tier as `access`, so the adapter and operators see which one was issued. Names are inputs, never keys. The authorization keys are the immutable Workspace id, Team id, Organization id and the repository id GitHub returns @@ -115,11 +143,13 @@ path. A Workspace whose Team temporarily lacks a grant does not stop the service for other Workspaces; it is refused per request. `policy check --live` runs the installation gate and then, per Workspace, -verifies the Team identity and lists the Team's live write-capable -repositories. It is the migration and readback gate an operator runs before -deploying a policy. The repository rows are information; the command exits -non-zero only on a missing or drifted Team or installation drift, and its rows -never contain a token or credential. +verifies the Team identity and lists the Team's live repository grants with +the tier each one mints. Without `--live` it prints the exact permission set +each tier asks for under the policy. It is the migration and readback gate an +operator runs before deploying a policy or lowering Team grants. The +repository rows are information; the command exits non-zero only on a missing +or drifted Team or installation drift, and its rows never contain a token or +credential. The Lazurio Dashboard is a read-only lens over this contract. It may show the desired Team-to-repository mapping and derive a reviewed policy input, but @@ -147,8 +177,9 @@ continue after the browser disconnects. Workspace, then replace the client-side copy in its custody boundary. - Remove the GitHub Team's grant on a repository, or delete the Team, to refuse that Workspace's next token for the repository without touching the - policy or restarting the broker. Granting a Team a repository likewise takes - effect on the next request. + policy or restarting the broker. Lower the grant to `pull` or `triage` to + make the next token read-tier. Granting or raising a Team's grant likewise + takes effect on the next request. - Remove a Workspace from the policy, or rotate its credential, to stop all of its issuance. - Rotate the App private key in deployment custody and restart the Node broker @@ -203,7 +234,11 @@ GitHub API proxy. A repository command must resolve one HTTPS GitHub checkout or one matching `--repo` selector, obtain a fresh token through `/v1/token`, and then run the real CLI. The adapter holds no repository list: it sends the repository name and accepts only a response for that same repository; the -broker's live Team gate decides. Installation tokens exist only in the child +broker's live Team gate decides. It also refuses nothing by tier: with a +read-tier token official `gh` runs and GitHub refuses what the token cannot +do, with GitHub's own error and exit code. After such a refusal the adapter +adds one stderr line naming the read access, so a permission refusal is not +mistaken for a broker outage. Installation tokens exist only in the child environment and are never cached, rendered or written to GitHub CLI config. The official CLI remains responsible for pull requests, REST and GraphQL. diff --git a/README.md b/README.md index 95e4548..fdf88df 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,16 @@ Open-source, publicly auditable source for the small credential boundary used by Lazurio Team Workspaces. The broker exchanges an authenticated Workspace request for a short-lived GitHub App installation token restricted to one -immutable repository id and exactly `actions: write`, `checks: read`, -`contents: write` plus `pull_requests: write`, and `workflows: write` where the -Organization's policy declares it. +immutable repository id. The Workspace's live GitHub Team grant on that +repository selects one of two exact permission sets: + +- write tier (`push`, `maintain` or `admin` grant): `actions: write`, + `checks: read`, `contents: write` plus `pull_requests: write`, and + `workflows: write` where the Organization's policy declares it; +- read tier (`pull` or `triage` grant): `contents: read`, and `issues: write` + where the Organization's policy declares it. + +GitHub adds `metadata: read` to every token. Lazurio for GitHub is an independent project. It is not affiliated with, sponsored by, or endorsed by GitHub, Inc. GitHub and GitHub CLI are trademarks @@ -30,8 +37,9 @@ Workspace id + Workspace credential + repository (name or id) -> exact deployment policy (Organization, App, installation, Workspace -> Team) -> repository owner is the policy's Organization -> live GitHub App installation identity and permissions - -> live write-capable grant of the Workspace's immutable GitHub Team on that repository - -> one-repository installation token for the repository id GitHub returned + -> live grant of the Workspace's immutable GitHub Team on that repository + (push/maintain/admin -> write tier, pull/triage -> read tier, none -> refused) + -> one-repository installation token of that tier for the repository id GitHub returned ``` - It never asks which human last used the Workspace. @@ -39,17 +47,28 @@ Workspace id + Workspace credential + repository (name or id) - The GitHub App private key exists only in the broker workload. - Every Workspace is bound to exactly one immutable GitHub Team id. The live GitHub Team grant is the scope: a Workspace can obtain a token for exactly - the repositories of the policy's Organization on which its Team holds a - `push`, `maintain` or `admin` grant at the moment of the request. The policy - holds no repository list, so it never grants anything GitHub has not granted - and never narrows what GitHub has granted. An Organization Admin who grants - a Team write access to a new repository makes it available to that Team's - Environment without a broker policy change or redeploy. -- A minted token can read checks, rerun workflows, change repository contents - and create or update pull requests only for that one repository. GitHub has - no rerun-only installation permission, so `actions: write` also permits - other Actions mutations in that repository; the token still receives no - Checks write, administration, membership or cross-repository authority. + the repositories of the policy's Organization on which its Team holds any + repository grant at the moment of the request, and the grant decides the + tier. A `push`, `maintain` or `admin` grant mints the write tier; a `pull` or + `triage` grant mints the read tier. The policy holds no repository list and + no tier, so it never grants anything GitHub has not granted and never + narrows or widens what GitHub has granted. An Organization Admin who grants + a Team access to a new repository, or raises or lowers a grant, changes what + that Team's Environment can do on the next request, without a broker policy + change or redeploy. +- A write-tier token can read checks, rerun workflows, change repository + contents and create or update pull requests only for that one repository. + GitHub has no rerun-only installation permission, so `actions: write` also + permits other Actions mutations in that repository; the token still receives + no Checks write, administration, membership or cross-repository authority. +- A read-tier token can clone, fetch and read the contents of that one + repository. It receives no contents, pull request, Actions, Checks or + workflow write and no Checks or Actions read. With `issues: write` declared + it can also open issues, which is how an Agent without write access proposes + a change. GitHub has no create-only issue permission: `issues: write` also + lets the token edit, label, close, reopen and comment on existing issues in + that repository, which is closer to the `triage` role for issues than to a + person with read access. - Immutable ids are the authorization keys: the Workspace id, the Team id, the Organization id and the repository id GitHub returns from the live Team grant. Repository names are resolved live on every request and never cached. @@ -107,12 +126,19 @@ Schema v3 binds each Workspace to its GitHub Team and names no repositories: `members: read` and `pull_requests: write`; the App reads Teams and Team repository grants through `members: read`. - `installation_permissions.workflows: write` is optional. When the - Organization accepted it and the policy declares it, every token also asks - for `workflows: write`, so a Workspace can push commits that add or change - `.github/workflows/` files in its repository, exactly like a Team member - with a write grant. Without it the token keeps the base set. Because the - installation gate compares permissions exactly, accept the permission and - deploy the policy that declares it in one short window. + Organization accepted it and the policy declares it, every write-tier token + also asks for `workflows: write`, so a Workspace can push commits that add + or change `.github/workflows/` files in its repository, exactly like a Team + member with a write grant. Without it the token keeps the base set. Because + the installation gate compares permissions exactly, accept the permission + and deploy the policy that declares it in one short window. +- `installation_permissions.issues: write` is optional in the same way. When + the Organization accepted it and the policy declares it, every read-tier + token also asks for `issues: write`, so a Workspace whose Team holds a + `pull` or `triage` grant can open issues. Without it a read-tier token can + clone, fetch and read but not open issues. The write tier never asks for + `issues`. See [Read-only Team grants](#read-only-team-grants) for the App + permission this needs. - `workspaces[]` has `id`, `github_team_id`, optional `github_team_slug` and `credential_file`. Workspace ids, Team ids and credential files must each be unique, so exactly one broker Workspace exists per Team. The optional slug @@ -130,8 +156,8 @@ Schema v2 (and the already retired v1) is rejected with a migration message. Migrate by setting `schema_version` to `lazurio.github_app_broker.policy.v3` and deleting `repositories` and every `workspaces[].repository_ids`; the live Team grant is the scope. Run `policy check --live` against the converted -policy and compare the listed write-capable repositories of each Team with -what that Team's Environment should reach. A Team that holds more write grants +policy and compare the listed repositories of each Team with what that Team's +Environment should reach. A Team that holds more write grants than its v2 allowlist gains those repositories; narrow such a Team on GitHub before switching. Only then deploy broker `0.10.0` with the v3 policy. @@ -140,6 +166,69 @@ the new `{"repository": ...}` body and the `{"repository_id": ...}` body that already deployed `0.9.0` adapters send. The `0.10.0` adapter sends only the name form and therefore needs a `0.10.0` broker. +#### Read-only Team grants + +Since `0.11.0` a Team's `pull` or `triage` grant mints the read tier instead +of being refused as `403 team_grant_missing`. An Organization can therefore +give a Team only read access to some repositories (for example a knowledge +base, planning data or the Organization root): the Team's Environment can +still clone, pull and read them and propose changes as issues, while the +people who hold write access change them from their own Environments under +their own GitHub accounts. Before `0.11.0` lowering such a grant to read cut +the Team's Environment off the repository completely. + +`triage` maps to the same read tier as `pull`. The installation permission +that would add pull request triage is `pull_requests: write`, which also lets a +token open and edit pull requests; an Environment without write access +proposes through issues, so the read tier stays minimal. Issue triage comes +with `issues: write` anyway, because GitHub has no create-only issue +permission. + +The policy stays schema v3 and names no tier: the tier comes from the live +grant on every request. + +**App permission prerequisite for issues.** Opening an issue needs the App to +request the repository permission `Issues: Read and write`. The public Lazurio +for GitHub App requested no `issues` permission when `0.11.0` was prepared, so +issue creation needs, per Organization: + +1. once, an owner of the App adds `Issues: Read and write` in the App's + permission settings. GitHub then asks every installation to accept; an + installation that has not accepted keeps its previous permissions, so + nothing changes for that Organization yet; +2. an Organization owner reviews and accepts the request in the Organization's + GitHub App settings. Accepting also accepts any other permission the App + requests at that moment, so read the whole request; and +3. because the installation gate compares permissions exactly, the policy + then declares the whole newly accepted set, including + `installation_permissions.issues: write`, in the same short window. + Between acceptance and the policy deploy the Worker refuses every token + request of that Organization with `502 token_unavailable`, and the Node + adapter refuses to start and fails `--verify-only`, exactly as with + `workflows: write`. + +Without these steps the read tier still clones, fetches and reads; GitHub only +refuses issue creation. + +**Rollout order.** The order matters, because a grant lowered before step 2 +cuts the Environment off the repository: + +1. Release `0.11.0`. +2. Deploy broker `0.11.0` for each Organization. No policy change is needed + and write grants mint exactly what they minted before. +3. Optionally pin the `0.11.0` adapter in the Workspaces, which explains a + refused write instead of leaving only GitHub's error. The `0.10.0` adapter + already works with read-tier tokens because it ignores `access`. +4. Where the Organization wants issues from read-only Environments, follow the + App permission prerequisite above. +5. Run `policy check --live` to see every Team's current grants and tiers, then + lower the Team grants on GitHub. A lowered repository mints the read tier on + the next request; a write-tier token minted earlier stays valid until it + expires on GitHub's installation-token schedule (at most one hour). + +Rolling the broker back to `0.10.0` refuses read-only Teams again with +`team_grant_missing`; it never widens access. + ### API `POST /v1/token` accepts exactly one of two bodies: @@ -162,13 +251,17 @@ body with both keys, neither, any other key, a non-positive or non-integer id, or a name that is not exactly `Owner/name` is refused with `400`. The response contains the installation token, GitHub's expiry timestamp, the -immutable `repository_id` and the canonical `repository` full name GitHub -returned, with `Cache-Control: no-store`: +immutable `repository_id`, the canonical `repository` full name GitHub +returned and the `access` tier that was minted (`write` or `read`), with +`Cache-Control: no-store`: ```json -{"token": "", "expires_at": "2030-01-01T00:00:00Z", "repository_id": 2345, "repository": "example-organization/example-repository"} +{"token": "", "expires_at": "2030-01-01T00:00:00Z", "repository_id": 2345, "repository": "example-organization/example-repository", "access": "write"} ``` +`access` is additive: clients that ignore it keep working, and brokers before +`0.11.0` never send it because they mint only the write tier. + `POST /v1/workspace` is the repository-independent connection proof the adapter uses for host-level discovery outside a repository checkout. It takes the same headers (its body is not read), runs the same credential gate, then @@ -195,14 +288,15 @@ and revocation; it only guarantees that no final repository token exists: 3. installation gate: exact App identity, Organization target, selection mode and permissions (the Node adapter at startup, the Worker on every request); 4. Team gate: the Workspace's Team exists with the asserted identity and holds - a write-capable grant on the repository; then the one-repository mint. + a grant on the repository, whose role selects the tier; then the + one-repository mint of that tier. | Status | `error` | Meaning | | --- | --- | --- | | `401` | `workspace_unauthorized` | Unknown Workspace id or wrong credential. No GitHub traffic. | | `400` | `invalid_request` | Body is not exactly one of the two forms. No GitHub traffic. | | `403` | `repository_denied` | The repository belongs to another owner than the policy's Organization. The name form is refused without GitHub traffic; the id form after the live readback. | -| `403` | `team_grant_missing` | Live GitHub readback shows the Workspace's Team is gone, has a different identity than the policy asserts, or lacks a push/maintain/admin grant on the repository; or the repository id is not visible to the installation. | +| `403` | `team_grant_missing` | Live GitHub readback shows the Workspace's Team is gone, has a different identity than the policy asserts, or holds no grant (none of pull, triage, push, maintain or admin) on the repository; or the repository id is not visible to the installation. | | `502` | `token_unavailable` | GitHub was unreachable, rate-limited or over quota, returned an unexpected shape, the mint left the requested scope, or the body exceeded the size limit. | | `415` | `unsupported_media_type` | Body is not JSON. | @@ -226,17 +320,22 @@ requests are: 3. `GET /organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}` with `Accept: application/vnd.github.v3.repository+json` returns the repository with the Team's `permissions`. The broker requires the returned - `owner.id` to equal `github_owner.id`, `push`, `maintain` or `admin` to be - `true` and, for the id form, the returned id to equal the requested id. A - `404` or a `pull`/`triage`-only grant is `team_grant_missing` + `owner.id` to equal `github_owner.id`, at least one of `pull`, `triage`, + `push`, `maintain` or `admin` to be `true` and, for the id form, the + returned id to equal the requested id. `admin`, `maintain` or `push` + selects the write tier; otherwise `triage` or `pull` selects the read tier. + A `404` or a payload without any of these is `team_grant_missing` (Organization `members: read`; GitHub also lists this endpoint under - repository `administration: read`, which is not required). The probe also - needs repository `metadata: read`: without it GitHub answers `404` for - every private repository the probe cannot see, which would look like a - missing grant. - -The token is minted for the repository id GitHub returned in step 3. Archived -repositories are not special-cased: GitHub itself refuses writes to them. + repository `administration: read`, which is not required). GitHub answers + `404` here for a Team without a grant even on a public repository, so a + public repository is not readable through the broker unless the Team was + granted it. The probe also needs repository `metadata: read`: without it + GitHub answers `404` for every private repository the probe cannot see, + which would look like a missing grant. + +The token is minted for the repository id GitHub returned in step 3, with the +permission set of the tier that step selected. Archived repositories are not +special-cased: GitHub itself refuses writes to them. Nothing from these readbacks is cached: revoking a Team grant on GitHub refuses the next token without a restart, granting one makes the next request succeed, and a token already issued expires on GitHub's installation-token @@ -322,26 +421,37 @@ BROKER_POLICY_FILE=./policy.json \ node src/broker.mjs policy check --live ``` -Without `--live` the command only parses the policy and prints the Workspace -to Team binding; it needs no key and makes no GitHub call. With `--live` it -first performs the same installation verification as `--verify-only`, then, -with one `members: read` plus `metadata: read` probe token per Workspace, -verifies the Team identity and lists the Team's live write-capable -repositories of the policy's Organization: +Without `--live` the command only parses the policy and prints the exact +permission set each tier asks for under this policy, then the Workspace to +Team binding; it needs no key and makes no GitHub call: ```text -WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL -alpha-team 4001 alpha-team 3001 example-org/alpha write ok -alpha-team 4001 alpha-team 3003 example-org/gamma admin ok -beta-team 4002 ? - - - refused Workspace beta-team GitHub Team 4002 no longer exists in the Organization -2 Workspaces checked, 1 Teams ok, 1 refused; 2 write-capable repository grants +lazurio.github_app_broker.policy.v3 owner=example-org installation=2001 +write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write pull_requests=write +read tier (pull/triage grant) asks for: contents=read issues=write +WORKSPACE TEAM_ID TEAM_SLUG +alpha-team 4001 alpha-team ``` -The repository rows are information: they show the Team's live write grants -as the probe sees them. In `selected` installation mode a listed repository is -mintable only while it is also installed for the App; the check does not -prove that per repository. A Team with no write-capable grant prints one `ok` row -saying so. The output contains no token or credential. The command exits +With `--live` it first performs the same installation verification as +`--verify-only`, then, with one `members: read` plus `metadata: read` probe +token per Workspace, verifies the Team identity and lists the Team's live +repository grants of the policy's Organization with the tier each one mints: + +```text +WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE ACCESS STATUS DETAIL +alpha-team 4001 alpha-team 3001 example-org/alpha write write ok +alpha-team 4001 alpha-team 3003 example-org/gamma admin write ok +alpha-team 4001 alpha-team 3004 example-org/handbook read read ok +beta-team 4002 ? - - - - refused Workspace beta-team GitHub Team 4002 no longer exists in the Organization +2 Workspaces checked, 1 Teams ok, 1 refused; 3 repository grants (2 write, 1 read) +``` + +The repository rows are information: they show the Team's live grants as the +probe sees them and the tier a token for each would have. In `selected` +installation mode a listed repository is mintable only while it is also +installed for the App; the check does not prove that per repository. A Team +with no grant prints one `ok` row saying so. The output contains no token or credential. The command exits non-zero only when a Team is missing or its identity differs from the policy, or when the installation differs from the policy. The check never edits GitHub; a refused row is fixed by restoring the Team on GitHub or reviewing @@ -441,6 +551,24 @@ same one-repository token as other repository commands and does not require a human `gh auth` login or a synthetic source commit. The adapter does not claim to narrow GitHub's `actions: write` permission to that single verb. +The adapter refuses no repository command by access tier. It sends the +repository, receives whichever tier the broker minted and runs official `gh` +with that token; GitHub decides. With a read-tier token, a Git credential +helper built on `requestGitHubAppToken` serves `git clone`, `fetch` and `pull`, +and `gh repo view`, `gh api` reads and, where the policy declares +`issues: write`, `gh issue create` work. A `git push` fails with GitHub's own +`403` permission error. A `gh` command GitHub refuses, such as `gh pr create`, +keeps official `gh`'s error and exit code, and the adapter adds one stderr line +saying that the Environment has read access to that repository and suggesting +an issue, so a refusal does not look like a broker outage. On a private +repository pull request and check reads (`gh pr list`, `gh pr view`, +`gh pr checks`) also fail under the read tier, because it asks for no +`pull_requests` or `checks` access. + +`requestGitHubAppToken` returns the minted `access`. It accepts `read` or +`write`, treats a missing value as `write` (brokers before `0.11.0` mint only +that tier) and refuses any other value as an invalid broker response. + Current T3 PR operations explicitly pass `--hostname github.com` to official `gh`. The adapter accepts only that exact host on repository commands; the discovery/viewer envelopes remain exact, a missing value or any other host is @@ -500,7 +628,7 @@ REST or GraphQL request. | Lazurio T3 Code | `lazurio-pilot-prestable-20260817.1` | | GitHub CLI | `2.97.0` | | Node.js | `24.19.0` | -| Adapter | `0.10.0` | +| Adapter | `0.11.0` | | Cloudflare Wrangler | `4.127.1` | Upstream T3 or `gh` command-envelope drift must pass the exact contract tests From 005cd3bc134fb4753a5af7d1bf6825e4d12b1ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 18:04:03 +0200 Subject: [PATCH 3/6] Read tier sees pull requests and checks; issues: write opt-in covers both tiers Two decisions folded into the read-only grants change: - The read tier now asks for `checks: read`, `contents: read` and `pull_requests: read`, so people in a read-only Environment can list and view pull requests and check runs. It still asks for no write other than the optional `issues: write`, and for no Actions access. - When the policy declares the accepted `issues: write`, the write tier asks for it too, so write-capable Team Environments can file escalation issues. Without the declaration the write tier is exactly its previous set. Tests pin both tier sets with and without each opt-in and refuse any under- or over-scoped mint. README, ARCHITECTURE and the AGENTS invariants describe the new sets. They also note that GitHub CLI check views ask for the workflow run behind an Actions check, which the read tier cannot read. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 13 +++--- ARCHITECTURE.md | 40 +++++++++------- README.md | 96 +++++++++++++++++++++++---------------- src/core.mjs | 39 ++++++++-------- test/broker.test.mjs | 95 ++++++++++++++++++++++++++++++-------- test/brokered-gh.test.mjs | 13 +++++- 6 files changed, 193 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 218ee05..4d950c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,11 @@ dependency-light and customer-neutral. 4. A token is limited to one repository and to exactly one of two permission sets (plus GitHub's automatic `metadata: read`), and expires on GitHub's installation-token schedule. The write tier is `actions: write`, - `checks: read`, `contents: write` and `pull_requests: write`, plus - `workflows: write` only when the reviewed policy declares that the - installation accepted it. The read tier is `contents: read`, plus - `issues: write` only when the reviewed policy declares that the - installation accepted it. A mint without an explicit tier fails. + `checks: read`, `contents: write` and `pull_requests: write`. The read tier + is `checks: read`, `contents: read` and `pull_requests: read`. Each tier + adds `issues: write`, and the write tier `workflows: write`, only when the + reviewed policy declares that the installation accepted that permission. A + mint without an explicit tier fails. 5. Workspace credentials and installation tokens are never logged, committed, stored in Git configuration or persisted by this service. 6. The broker authorizes a Workspace and repository, never a browser user. @@ -46,7 +46,8 @@ operator documentation; never describe the token as rerun-only. that can open an issue. It also authorizes editing, labelling, closing, reopening and commenting on existing issues of the same repository. Never describe a read-tier token with `issues: write` as read-only or as able only -to create issues. +to create issues, and never describe a read-tier token as able to read +GitHub Actions data: it asks for no `actions` access. Use an isolated worktree and pull request for every change. Code, comments, commits and pull-request descriptions are English. Run `npm test` and diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 07d387b..b7c4aef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,10 +41,10 @@ GitHub to mint a new one-repository token with exactly the permission set of its tier, and refuses a response with any other scope or any other repository: - write tier: `actions: write`, `checks: read`, `contents: write` and - `pull_requests: write`, plus `workflows: write` when the policy declares - that the installation accepted it; -- read tier: `contents: read`, plus `issues: write` when the policy declares - that the installation accepted it. + `pull_requests: write`, plus `workflows: write` and `issues: write` each + when the policy declares that the installation accepted it; +- read tier: `checks: read`, `contents: read` and `pull_requests: read`, plus + `issues: write` when the policy declares that the installation accepted it. GitHub adds `metadata: read` to both. There is no default tier: a mint without an explicit tier fails before reaching GitHub, so a lost tier can never become @@ -61,18 +61,24 @@ grant on exactly that repository and no Checks write access. The read tier exists so an Organization can keep some repositories read-only for a Team without cutting that Team's Environment off them. A read-tier token -clones, fetches and reads one repository and receives no contents, pull -request, Actions, Checks or workflow write and no Checks or Actions read. -`issues: write` is the only write it can carry, so that an Agent without write -access can propose a change as an issue the way a person with read access -does. GitHub has no create-only issue permission: the same permission lets the -token edit, label, close, reopen and comment on existing issues of that -repository, which is closer to the `triage` role for issues than to read -access. An Organization opts into that by accepting the App permission and -declaring it in its reviewed policy; without it the read tier only reads. -`triage` maps to the same read tier as `pull`, because the permission that -would add pull request triage, `pull_requests: write`, also opens and edits -pull requests. +clones, fetches and reads one repository and lists and views its pull requests +and check runs, so people in a read-only Environment see pull requests and CI. +It receives no contents, pull request, Checks, Actions or workflow write and +no Actions read; GitHub CLI views that also ask for the workflow run behind an +Actions check therefore can fail under the read tier. `issues: write` is the +only write it can carry, so that an Agent without write access can propose a +change as an issue the way a person with read access does. GitHub has no +create-only issue permission: the same permission lets the token edit, label, +close, reopen and comment on existing issues of that repository, which is +closer to the `triage` role for issues than to read access. `triage` maps to +the same read tier as `pull`, because the permission that would add pull +request triage, `pull_requests: write`, also opens and edits pull requests. + +`issues: write` is one opt-in for both tiers. An Organization opts in by +accepting the App permission and declaring it in its reviewed policy; then a +write-tier token also carries it, so a write-capable Environment can file the +escalation issues its work asks for. Without the declaration neither tier asks +for `issues` and the write tier is exactly its previous set. `workflows: write` lets a Workspace push commits that change `.github/workflows/`. Editing a workflow does not by itself run it or hand it @@ -84,7 +90,7 @@ approval). That is the same capability a Team member with a write grant already has from GitHub, and an Organization opts into it by accepting the App permission and declaring it in its reviewed policy. A policy without it mints the base set, so the release can roll out before any Organization -accepts. The same holds for `issues: write` in the read tier. +accepts. The same holds for `issues: write` in both tiers. ## Team binding and live grant verification diff --git a/README.md b/README.md index fdf88df..062cf61 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ repository selects one of two exact permission sets: - write tier (`push`, `maintain` or `admin` grant): `actions: write`, `checks: read`, `contents: write` plus `pull_requests: write`, and - `workflows: write` where the Organization's policy declares it; -- read tier (`pull` or `triage` grant): `contents: read`, and `issues: write` - where the Organization's policy declares it. + `workflows: write` and `issues: write` where the Organization's policy + declares them; +- read tier (`pull` or `triage` grant): `checks: read`, `contents: read` plus + `pull_requests: read`, and `issues: write` where the Organization's policy + declares it. GitHub adds `metadata: read` to every token. @@ -57,18 +59,19 @@ Workspace id + Workspace credential + repository (name or id) that Team's Environment can do on the next request, without a broker policy change or redeploy. - A write-tier token can read checks, rerun workflows, change repository - contents and create or update pull requests only for that one repository. - GitHub has no rerun-only installation permission, so `actions: write` also - permits other Actions mutations in that repository; the token still receives - no Checks write, administration, membership or cross-repository authority. + contents and create or update pull requests only for that one repository, + and, with `issues: write` declared, open and manage issues there. GitHub has + no rerun-only installation permission, so `actions: write` also permits + other Actions mutations in that repository; the token still receives no + Checks write, administration, membership or cross-repository authority. - A read-tier token can clone, fetch and read the contents of that one - repository. It receives no contents, pull request, Actions, Checks or - workflow write and no Checks or Actions read. With `issues: write` declared - it can also open issues, which is how an Agent without write access proposes - a change. GitHub has no create-only issue permission: `issues: write` also - lets the token edit, label, close, reopen and comment on existing issues in - that repository, which is closer to the `triage` role for issues than to a - person with read access. + repository and list and view its pull requests and check runs. It receives + no contents, pull request, Checks, Actions or workflow write and no Actions + read. With `issues: write` declared it can also open issues, which is how an + Agent without write access proposes a change. GitHub has no create-only + issue permission: `issues: write` also lets the token edit, label, close, + reopen and comment on existing issues in that repository, which is closer to + the `triage` role for issues than to a person with read access. - Immutable ids are the authorization keys: the Workspace id, the Team id, the Organization id and the repository id GitHub returns from the live Team grant. Repository names are resolved live on every request and never cached. @@ -133,12 +136,13 @@ Schema v3 binds each Workspace to its GitHub Team and names no repositories: the installation gate compares permissions exactly, accept the permission and deploy the policy that declares it in one short window. - `installation_permissions.issues: write` is optional in the same way. When - the Organization accepted it and the policy declares it, every read-tier - token also asks for `issues: write`, so a Workspace whose Team holds a - `pull` or `triage` grant can open issues. Without it a read-tier token can - clone, fetch and read but not open issues. The write tier never asks for - `issues`. See [Read-only Team grants](#read-only-team-grants) for the App - permission this needs. + the Organization accepted it and the policy declares it, every token of + both tiers also asks for `issues: write`, so a Team Environment can open + issues whatever its grant: a read-only Environment proposes a change, a + write-capable one files an escalation. Without it neither tier asks for + `issues`, and the write tier keeps exactly its previous set. See + [Read-only Team grants](#read-only-team-grants) for the App permission this + needs. - `workspaces[]` has `id`, `github_team_id`, optional `github_team_slug` and `credential_file`. Workspace ids, Team ids and credential files must each be unique, so exactly one broker Workspace exists per Team. The optional slug @@ -177,6 +181,10 @@ people who hold write access change them from their own Environments under their own GitHub accounts. Before `0.11.0` lowering such a grant to read cut the Team's Environment off the repository completely. +The read tier reads contents, pull requests and check runs, so people in a +read-only Environment see pull requests and CI results, but it writes nothing +except, where declared, issues. + `triage` maps to the same read tier as `pull`. The installation permission that would add pull request triage is `pull_requests: write`, which also lets a token open and edit pull requests; an Environment without write access @@ -190,7 +198,7 @@ grant on every request. **App permission prerequisite for issues.** Opening an issue needs the App to request the repository permission `Issues: Read and write`. The public Lazurio for GitHub App requested no `issues` permission when `0.11.0` was prepared, so -issue creation needs, per Organization: +issue creation, from either tier, needs, per Organization: 1. once, an owner of the App adds `Issues: Read and write` in the App's permission settings. GitHub then asks every installation to accept; an @@ -207,8 +215,10 @@ issue creation needs, per Organization: adapter refuses to start and fails `--verify-only`, exactly as with `workflows: write`. -Without these steps the read tier still clones, fetches and reads; GitHub only -refuses issue creation. +Without these steps the read tier still clones, fetches and reads, the write +tier mints exactly what it minted before, and GitHub only refuses issue +creation. Once the policy declares `issues: write`, write-tier tokens of that +Organization also carry it. **Rollout order.** The order matters, because a grant lowered before step 2 cuts the Environment off the repository: @@ -219,8 +229,9 @@ cuts the Environment off the repository: 3. Optionally pin the `0.11.0` adapter in the Workspaces, which explains a refused write instead of leaving only GitHub's error. The `0.10.0` adapter already works with read-tier tokens because it ignores `access`. -4. Where the Organization wants issues from read-only Environments, follow the - App permission prerequisite above. +4. Where the Organization wants its Environments to open issues (proposals + from read-only ones, escalations from write-capable ones), follow the App + permission prerequisite above. 5. Run `policy check --live` to see every Team's current grants and tiers, then lower the Team grants on GitHub. A lowered repository mints the read tier on the next request; a write-tier token minted earlier stays valid until it @@ -427,8 +438,8 @@ Team binding; it needs no key and makes no GitHub call: ```text lazurio.github_app_broker.policy.v3 owner=example-org installation=2001 -write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write pull_requests=write -read tier (pull/triage grant) asks for: contents=read issues=write +write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write issues=write pull_requests=write +read tier (pull/triage grant) asks for: checks=read contents=read issues=write pull_requests=read WORKSPACE TEAM_ID TEAM_SLUG alpha-team 4001 alpha-team ``` @@ -451,9 +462,10 @@ The repository rows are information: they show the Team's live grants as the probe sees them and the tier a token for each would have. In `selected` installation mode a listed repository is mintable only while it is also installed for the App; the check does not prove that per repository. A Team -with no grant prints one `ok` row saying so. The output contains no token or credential. The command exits -non-zero only when a Team is missing or its identity differs from the policy, -or when the installation differs from the policy. The check never edits +with no grant prints one `ok` row saying so. The output contains no token or +credential. The command exits non-zero only when a Team is missing or its +identity differs from the policy, or when the installation differs from the +policy. The check never edits GitHub; a refused row is fixed by restoring the Team on GitHub or reviewing the policy, never by the broker. @@ -555,15 +567,21 @@ The adapter refuses no repository command by access tier. It sends the repository, receives whichever tier the broker minted and runs official `gh` with that token; GitHub decides. With a read-tier token, a Git credential helper built on `requestGitHubAppToken` serves `git clone`, `fetch` and `pull`, -and `gh repo view`, `gh api` reads and, where the policy declares -`issues: write`, `gh issue create` work. A `git push` fails with GitHub's own -`403` permission error. A `gh` command GitHub refuses, such as `gh pr create`, -keeps official `gh`'s error and exit code, and the adapter adds one stderr line -saying that the Environment has read access to that repository and suggesting -an issue, so a refusal does not look like a broker outage. On a private -repository pull request and check reads (`gh pr list`, `gh pr view`, -`gh pr checks`) also fail under the read tier, because it asks for no -`pull_requests` or `checks` access. +and `gh repo view`, `gh api` reads, `gh pr list` and `gh pr view` and, where +the policy declares `issues: write`, `gh issue create` work. A `git push` fails +with GitHub's own `403` permission error. A `gh` command GitHub refuses, such +as `gh pr create`, keeps official `gh`'s error and exit code, and the adapter +adds one stderr line saying that the Environment has read access to that +repository and suggesting an issue, so a refusal does not look like a broker +outage. + +Check results need one caveat. `gh pr checks` and `--json statusCheckRollup` +(which T3's pull request list uses) also ask GitHub for the workflow run +behind each GitHub Actions check. That field is Actions data, and the read +tier asks for no `actions` access, so on a repository whose checks come from +GitHub Actions those views can fail under the read tier. `gh run view` fails +for the same reason. The write tier is not affected because `actions: write` +includes read. `requestGitHubAppToken` returns the minted `access`. It accepts `read` or `write`, treats a missing value as `write` (brokers before `0.11.0` mint only diff --git a/src/core.mjs b/src/core.mjs index 4bf7097..0a4486f 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -27,31 +27,32 @@ const WRITE_TOKEN_PERMISSIONS = Object.freeze({ pull_requests: "write", }); /** - * Pushing a commit that touches `.github/workflows/` needs `workflows: write`. The token asks for it - * only when the Organization accepted it and the reviewed policy declares it, so a deployment whose - * installation has not accepted the permission keeps minting the base set. + * A read-tier token can clone, fetch and read the repository and see its pull requests and check + * runs (plus GitHub's automatic `metadata: read`). It never asks for any contents, pull request, + * Actions or workflow write, nor for Actions read. */ -const WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...WRITE_TOKEN_PERMISSIONS, workflows: "write" }); -/** - * A read-tier token can clone, fetch and read the repository (plus GitHub's automatic - * `metadata: read`). It never asks for pull request, Actions, Checks or workflow access. - */ -const READ_TOKEN_PERMISSIONS = Object.freeze({ contents: "read" }); +const READ_TOKEN_PERMISSIONS = Object.freeze({ + checks: "read", + contents: "read", + pull_requests: "read", +}); + /** - * Opening an issue needs `issues: write`, GitHub's smallest installation permission that can create - * one. The read tier asks for it only when the Organization accepted it and the reviewed policy - * declares it, exactly like `workflows: write` for the write tier. + * The exact permission set a token of `access` asks GitHub for under `policy`. Two opt-ins follow + * the permissions the Organization accepted and the reviewed policy declares, so a deployment + * whose installation has not accepted them keeps minting the base sets: + * - `workflows: write` (write tier only) lets a push touch `.github/workflows/`; + * - `issues: write` (both tiers) is GitHub's smallest installation permission that can open an + * issue, so an Environment can propose a change or escalate through an issue. */ -const ISSUE_READ_TOKEN_PERMISSIONS = Object.freeze({ ...READ_TOKEN_PERMISSIONS, issues: "write" }); - -/** The exact permission set a token of `access` asks GitHub for under `policy`. */ export function tokenPermissions(policy, access) { + const accepted = policy.installation_permissions; + const issues = accepted.issues === "write" ? { issues: "write" } : {}; if (access === WRITE_ACCESS) { - return policy.installation_permissions.workflows === "write" ? WORKFLOW_TOKEN_PERMISSIONS : WRITE_TOKEN_PERMISSIONS; - } - if (access === READ_ACCESS) { - return policy.installation_permissions.issues === "write" ? ISSUE_READ_TOKEN_PERMISSIONS : READ_TOKEN_PERMISSIONS; + const workflows = accepted.workflows === "write" ? { workflows: "write" } : {}; + return Object.freeze({ ...WRITE_TOKEN_PERMISSIONS, ...workflows, ...issues }); } + if (access === READ_ACCESS) return Object.freeze({ ...READ_TOKEN_PERMISSIONS, ...issues }); fail("token access tier must be read or write"); } diff --git a/test/broker.test.mjs b/test/broker.test.mjs index d510802..a53c07e 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -651,14 +651,63 @@ test("asks for workflows: write only when the policy declares the accepted permi /outside the requested repository or permission scope/, ); - // The write tier never asks for issues, even when the installation accepted them. +}); + +test("the write tier adds issues: write only when the policy declares the accepted permission", async () => { + const now = 1_700_000_000_000; + const base = { actions: "write", checks: "read", contents: "write", pull_requests: "write" }; + let requested; + let responsePermissions; + const github = createGithubClient({ + appId: "42", + privateKey: testPrivateKey(), + fetchImpl: async (_url, init) => { + requested = JSON.parse(init.body).permissions; + return jsonResponse({ + token: "ghs_scoped_synthetic_token", + expires_at: new Date(now + 60 * 60 * 1000).toISOString(), + repositories: [{ id: 3001 }], + permissions: responsePermissions, + }); + }, + now: () => now, + }); + + // Not declared: the write tier stays the base set and refuses an issues grant it did not ask for. + responsePermissions = { ...base, issues: "write", metadata: "read" }; + await assert.rejects( + () => github.mintToken(parsePolicy(policyFixture()), 3001, "write"), + /outside the requested repository or permission scope/, + ); + assert.deepEqual(requested, base); + + // Declared alone, and together with workflows. + const fixture = policyFixture(); fixture.installation_permissions.issues = "write"; - responsePermissions = { ...base, metadata: "read", workflows: "write" }; + responsePermissions = { ...base, issues: "write", metadata: "read" }; + assert.equal((await github.mintToken(parsePolicy(fixture), 3001, "write")).access, "write"); + assert.deepEqual(requested, { ...base, issues: "write" }); + + fixture.installation_permissions.workflows = "write"; + responsePermissions = { ...base, issues: "write", metadata: "read", workflows: "write" }; assert.equal((await github.mintToken(parsePolicy(fixture), 3001, "write")).access, "write"); + assert.deepEqual(requested, { ...base, issues: "write", workflows: "write" }); + + // Declared but missing from GitHub's answer is refused. + responsePermissions = { ...base, metadata: "read", workflows: "write" }; + await assert.rejects( + () => github.mintToken(parsePolicy(fixture), 3001, "write"), + /outside the requested repository or permission scope/, + ); + + // An issues permission declared only as read is not the accepted write permission. + fixture.installation_permissions.issues = "read"; + responsePermissions = { ...base, metadata: "read", workflows: "write" }; + await github.mintToken(parsePolicy(fixture), 3001, "write"); assert.deepEqual(requested, { ...base, workflows: "write" }); }); -test("the read tier asks for contents: read, adds issues: write only when declared, and nothing else", async () => { +test("the read tier asks for contents, pull request and check reads, adds issues: write only when declared, and nothing else", async () => { const now = 1_700_000_000_000; let requests = 0; let requested; @@ -681,39 +730,44 @@ test("the read tier asks for contents: read, adds issues: write only when declar now: () => now, }); + const readBase = { checks: "read", contents: "read", pull_requests: "read" }; + // Base policy: no issues accepted. A workflows acceptance never leaks into the read tier. const fixture = policyFixture(); fixture.installation_permissions.workflows = "write"; const withoutIssues = parsePolicy(fixture); - responsePermissions = { contents: "read", metadata: "read" }; + responsePermissions = { ...readBase, metadata: "read" }; assert.deepEqual(await github.mintToken(withoutIssues, 3004, "read"), { token: "ghs_scoped_synthetic_token", expires_at: new Date(now + 60 * 60 * 1000).toISOString(), repository_id: 3004, access: "read", }); - assert.deepEqual(requested, { contents: "read" }); + assert.deepEqual(requested, readBase); fixture.installation_permissions.issues = "write"; const withIssues = parsePolicy(fixture); - responsePermissions = { contents: "read", issues: "write", metadata: "read" }; + responsePermissions = { ...readBase, issues: "write", metadata: "read" }; assert.equal((await github.mintToken(withIssues, 3004, "read")).access, "read"); - assert.deepEqual(requested, { contents: "read", issues: "write" }); + assert.deepEqual(requested, { ...readBase, issues: "write" }); // An issues permission declared only as read is not the accepted write permission. fixture.installation_permissions.issues = "read"; - responsePermissions = { contents: "read", metadata: "read" }; + responsePermissions = { ...readBase, metadata: "read" }; await github.mintToken(parsePolicy(fixture), 3004, "read"); - assert.deepEqual(requested, { contents: "read" }); + assert.deepEqual(requested, readBase); - // Any write beyond the requested read set, or a missing issues grant, is refused. + // Any write or read beyond the requested set, a missing read, or an undeclared issues grant is refused. for (const permissions of [ - { contents: "write", metadata: "read" }, - { contents: "read", metadata: "read", pull_requests: "write" }, - { contents: "read", metadata: "read", actions: "write" }, - { contents: "read", metadata: "read", checks: "read" }, - { contents: "read", metadata: "read", workflows: "write" }, - { contents: "read", issues: "write", metadata: "read" }, + { ...readBase, contents: "write", metadata: "read" }, + { ...readBase, pull_requests: "write", metadata: "read" }, + { ...readBase, checks: "write", metadata: "read" }, + { ...readBase, actions: "read", metadata: "read" }, + { ...readBase, actions: "write", metadata: "read" }, + { ...readBase, workflows: "write", metadata: "read" }, + { ...readBase, issues: "write", metadata: "read" }, + { contents: "read", pull_requests: "read", metadata: "read" }, + { checks: "read", contents: "read", metadata: "read" }, ]) { responsePermissions = permissions; await assert.rejects( @@ -722,7 +776,7 @@ test("the read tier asks for contents: read, adds issues: write only when declar JSON.stringify(permissions), ); } - responsePermissions = { contents: "read", metadata: "read" }; + responsePermissions = { ...readBase, metadata: "read" }; await assert.rejects( () => github.mintToken(withIssues, 3004, "read"), /outside the requested repository or permission scope/, @@ -1411,8 +1465,9 @@ test("end to end: a pull or triage grant mints a read-tier token; push mints wri assert.equal(json.repository_id, 3001); assert.equal(json.repository, "example-org/alpha"); } - // The installation has not accepted issues: write, so the read tier asks for contents only. - assert.deepEqual(scopedMints, [{ contents: "read" }, { contents: "read" }]); + // The installation has not accepted issues: write, so the read tier asks for reads only. + const readSet = { checks: "read", contents: "read", pull_requests: "read" }; + assert.deepEqual(scopedMints, [readSet, readSet]); // Cross-Workspace: beta's Team holds nothing on alpha, whatever alpha's Team holds. const crossWorkspace = await request(origin, { @@ -1498,7 +1553,7 @@ test("policy check --live lists live Team grants as information and fails only o [ "lazurio.github_app_broker.policy.v3 owner=example-org installation=2001", "write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write pull_requests=write", - "read tier (pull/triage grant) asks for: contents=read", + "read tier (pull/triage grant) asks for: checks=read contents=read pull_requests=read", "WORKSPACE TEAM_ID TEAM_SLUG", "alpha-team 4001 alpha-team", "beta-team 4002 -", diff --git a/test/brokered-gh.test.mjs b/test/brokered-gh.test.mjs index a799982..7768e9f 100644 --- a/test/brokered-gh.test.mjs +++ b/test/brokered-gh.test.mjs @@ -633,15 +633,24 @@ test("a read-tier token runs official gh; reads succeed silently and a refused w writeStderr: (value) => stderr.push(value), }); - // Reads and issue creation go to official gh with the read-tier token and add no output. + // Reads, pull request and check views and issue creation go to official gh with the read-tier + // token and add no output. assert.equal(await run(["issue", "create", "--title", "t", "--body", "b"], 0), 0); assert.equal(await run(["api", "repos/Example/Alpha", "--jq", ".full_name"], 0), 0); + assert.equal(await run(["pr", "view", "1", "--json", "state"], 0), 0); + assert.equal(await run(["pr", "checks", "1"], 0), 0); assert.deepEqual(stderr, []); // A pull request is not refused up front: official gh runs, GitHub refuses, the exit code // passes through and the adapter says why instead of looking like a broker outage. assert.equal(await run(["pr", "create", "--title", "t", "--body", "b"], 1), 1); - assert.deepEqual(children.map(({ args }) => args[0]), ["issue", "api", "pr"]); + assert.deepEqual(children.map(({ args }) => args.slice(0, 2).join(" ")), [ + "issue create", + "api repos/Example/Alpha", + "pr view", + "pr checks", + "pr create", + ]); assert.ok(children.every(({ token }) => token === TOKEN)); assert.deepEqual(stderr, [readAccessNotice("Example/Alpha")]); assert.match(stderr[0], /read access to Example\/Alpha/); From 9d32c6750ce2faf7740d99741fadb6d9d8d93128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 18:06:29 +0200 Subject: [PATCH 4/6] Read tier reads GitHub Actions too, so check views work read-only `gh pr checks` and `--json statusCheckRollup`, which T3's pull request list uses, read the workflow run behind each GitHub Actions check. That is Actions data, so with only `checks: read` and `pull_requests: read` those views could fail in a read-only Environment. The read tier now also asks for `actions: read`, still with no write of any kind except the optional `issues: write`. Tests pin the read set and refuse a mint that leaves out any of its four reads or adds Actions write. The docs and the AGENTS invariant state that `actions: read` also reads the repository's workflow logs and artifacts, as a person with read access can. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 7 ++++--- ARCHITECTURE.md | 18 +++++++++++------- README.md | 40 +++++++++++++++++++-------------------- src/core.mjs | 9 ++++++--- test/broker.test.mjs | 14 +++++++------- test/brokered-gh.test.mjs | 2 ++ 6 files changed, 50 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4d950c6..467e40b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ dependency-light and customer-neutral. sets (plus GitHub's automatic `metadata: read`), and expires on GitHub's installation-token schedule. The write tier is `actions: write`, `checks: read`, `contents: write` and `pull_requests: write`. The read tier - is `checks: read`, `contents: read` and `pull_requests: read`. Each tier + is `actions: read`, `checks: read`, `contents: read` and + `pull_requests: read`. Each tier adds `issues: write`, and the write tier `workflows: write`, only when the reviewed policy declares that the installation accepted that permission. A mint without an explicit tier fails. @@ -46,8 +47,8 @@ operator documentation; never describe the token as rerun-only. that can open an issue. It also authorizes editing, labelling, closing, reopening and commenting on existing issues of the same repository. Never describe a read-tier token with `issues: write` as read-only or as able only -to create issues, and never describe a read-tier token as able to read -GitHub Actions data: it asks for no `actions` access. +to create issues. The read tier's `actions: read` likewise reads more than +check status: it also reads the repository's workflow run logs and artifacts. Use an isolated worktree and pull request for every change. Code, comments, commits and pull-request descriptions are English. Run `npm test` and diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b7c4aef..405d50e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,8 +43,9 @@ its tier, and refuses a response with any other scope or any other repository: - write tier: `actions: write`, `checks: read`, `contents: write` and `pull_requests: write`, plus `workflows: write` and `issues: write` each when the policy declares that the installation accepted it; -- read tier: `checks: read`, `contents: read` and `pull_requests: read`, plus - `issues: write` when the policy declares that the installation accepted it. +- read tier: `actions: read`, `checks: read`, `contents: read` and + `pull_requests: read`, plus `issues: write` when the policy declares that + the installation accepted it. GitHub adds `metadata: read` to both. There is no default tier: a mint without an explicit tier fails before reaching GitHub, so a lost tier can never become @@ -61,11 +62,14 @@ grant on exactly that repository and no Checks write access. The read tier exists so an Organization can keep some repositories read-only for a Team without cutting that Team's Environment off them. A read-tier token -clones, fetches and reads one repository and lists and views its pull requests -and check runs, so people in a read-only Environment see pull requests and CI. -It receives no contents, pull request, Checks, Actions or workflow write and -no Actions read; GitHub CLI views that also ask for the workflow run behind an -Actions check therefore can fail under the read tier. `issues: write` is the +clones, fetches and reads one repository and lists and views its pull requests, +check runs and GitHub Actions workflow runs, so people in a read-only +Environment see pull requests and CI. `actions: read` is there because GitHub +CLI check views and the `statusCheckRollup` behind T3's pull request list read +the workflow run of each Actions check; it also lets the token read that +repository's workflow run logs and artifacts, as a person with read access +can. The token receives no contents, pull request, Checks, Actions or workflow +write. `issues: write` is the only write it can carry, so that an Agent without write access can propose a change as an issue the way a person with read access does. GitHub has no create-only issue permission: the same permission lets the token edit, label, diff --git a/README.md b/README.md index 062cf61..1b8752a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ repository selects one of two exact permission sets: `checks: read`, `contents: write` plus `pull_requests: write`, and `workflows: write` and `issues: write` where the Organization's policy declares them; -- read tier (`pull` or `triage` grant): `checks: read`, `contents: read` plus - `pull_requests: read`, and `issues: write` where the Organization's policy - declares it. +- read tier (`pull` or `triage` grant): `actions: read`, `checks: read`, + `contents: read` plus `pull_requests: read`, and `issues: write` where the + Organization's policy declares it. GitHub adds `metadata: read` to every token. @@ -65,10 +65,12 @@ Workspace id + Workspace credential + repository (name or id) other Actions mutations in that repository; the token still receives no Checks write, administration, membership or cross-repository authority. - A read-tier token can clone, fetch and read the contents of that one - repository and list and view its pull requests and check runs. It receives - no contents, pull request, Checks, Actions or workflow write and no Actions - read. With `issues: write` declared it can also open issues, which is how an - Agent without write access proposes a change. GitHub has no create-only + repository and list and view its pull requests, check runs and GitHub + Actions workflow runs. `actions: read` also lets it read that repository's + workflow run logs and artifacts, as a person with read access can. It + receives no contents, pull request, Checks, Actions or workflow write. With + `issues: write` declared it can also open issues, which is how an Agent + without write access proposes a change. GitHub has no create-only issue permission: `issues: write` also lets the token edit, label, close, reopen and comment on existing issues in that repository, which is closer to the `triage` role for issues than to a person with read access. @@ -181,9 +183,9 @@ people who hold write access change them from their own Environments under their own GitHub accounts. Before `0.11.0` lowering such a grant to read cut the Team's Environment off the repository completely. -The read tier reads contents, pull requests and check runs, so people in a -read-only Environment see pull requests and CI results, but it writes nothing -except, where declared, issues. +The read tier reads contents, pull requests, check runs and GitHub Actions +workflow runs, so people in a read-only Environment see pull requests and CI +results, but it writes nothing except, where declared, issues. `triage` maps to the same read tier as `pull`. The installation permission that would add pull request triage is `pull_requests: write`, which also lets a @@ -439,7 +441,7 @@ Team binding; it needs no key and makes no GitHub call: ```text lazurio.github_app_broker.policy.v3 owner=example-org installation=2001 write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write issues=write pull_requests=write -read tier (pull/triage grant) asks for: checks=read contents=read issues=write pull_requests=read +read tier (pull/triage grant) asks for: actions=read checks=read contents=read issues=write pull_requests=read WORKSPACE TEAM_ID TEAM_SLUG alpha-team 4001 alpha-team ``` @@ -567,21 +569,19 @@ The adapter refuses no repository command by access tier. It sends the repository, receives whichever tier the broker minted and runs official `gh` with that token; GitHub decides. With a read-tier token, a Git credential helper built on `requestGitHubAppToken` serves `git clone`, `fetch` and `pull`, -and `gh repo view`, `gh api` reads, `gh pr list` and `gh pr view` and, where -the policy declares `issues: write`, `gh issue create` work. A `git push` fails +and `gh repo view`, `gh api` reads, `gh pr list`, `gh pr view`, `gh pr checks` +and `gh run view` and, where the policy declares `issues: write`, +`gh issue create` work. A `git push` fails with GitHub's own `403` permission error. A `gh` command GitHub refuses, such as `gh pr create`, keeps official `gh`'s error and exit code, and the adapter adds one stderr line saying that the Environment has read access to that repository and suggesting an issue, so a refusal does not look like a broker outage. -Check results need one caveat. `gh pr checks` and `--json statusCheckRollup` -(which T3's pull request list uses) also ask GitHub for the workflow run -behind each GitHub Actions check. That field is Actions data, and the read -tier asks for no `actions` access, so on a repository whose checks come from -GitHub Actions those views can fail under the read tier. `gh run view` fails -for the same reason. The write tier is not affected because `actions: write` -includes read. +`gh pr checks` and `--json statusCheckRollup`, which T3's pull request list +uses, also ask GitHub for the workflow run behind each GitHub Actions check. +That is Actions data, which is why the read tier asks for `actions: read`; the +write tier's `actions: write` already includes it. `requestGitHubAppToken` returns the minted `access`. It accepts `read` or `write`, treats a missing value as `write` (brokers before `0.11.0` mint only diff --git a/src/core.mjs b/src/core.mjs index 0a4486f..fbfb4ca 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -27,11 +27,14 @@ const WRITE_TOKEN_PERMISSIONS = Object.freeze({ pull_requests: "write", }); /** - * A read-tier token can clone, fetch and read the repository and see its pull requests and check - * runs (plus GitHub's automatic `metadata: read`). It never asks for any contents, pull request, - * Actions or workflow write, nor for Actions read. + * A read-tier token can clone, fetch and read the repository and see its pull requests, check runs + * and the workflow runs behind them (plus GitHub's automatic `metadata: read`). GitHub CLI check + * views and `statusCheckRollup` read the workflow run of each Actions check, which needs + * `actions: read`. The read tier never asks for any contents, pull request, Checks, Actions or + * workflow write. */ const READ_TOKEN_PERMISSIONS = Object.freeze({ + actions: "read", checks: "read", contents: "read", pull_requests: "read", diff --git a/test/broker.test.mjs b/test/broker.test.mjs index a53c07e..94619bb 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -707,7 +707,7 @@ test("the write tier adds issues: write only when the policy declares the accept assert.deepEqual(requested, { ...base, workflows: "write" }); }); -test("the read tier asks for contents, pull request and check reads, adds issues: write only when declared, and nothing else", async () => { +test("the read tier asks for contents, pull request, check and Actions reads, adds issues: write only when declared, and nothing else", async () => { const now = 1_700_000_000_000; let requests = 0; let requested; @@ -730,7 +730,7 @@ test("the read tier asks for contents, pull request and check reads, adds issues now: () => now, }); - const readBase = { checks: "read", contents: "read", pull_requests: "read" }; + const readBase = { actions: "read", checks: "read", contents: "read", pull_requests: "read" }; // Base policy: no issues accepted. A workflows acceptance never leaks into the read tier. const fixture = policyFixture(); @@ -762,12 +762,12 @@ test("the read tier asks for contents, pull request and check reads, adds issues { ...readBase, contents: "write", metadata: "read" }, { ...readBase, pull_requests: "write", metadata: "read" }, { ...readBase, checks: "write", metadata: "read" }, - { ...readBase, actions: "read", metadata: "read" }, { ...readBase, actions: "write", metadata: "read" }, { ...readBase, workflows: "write", metadata: "read" }, { ...readBase, issues: "write", metadata: "read" }, - { contents: "read", pull_requests: "read", metadata: "read" }, - { checks: "read", contents: "read", metadata: "read" }, + { actions: "read", contents: "read", pull_requests: "read", metadata: "read" }, + { actions: "read", checks: "read", contents: "read", metadata: "read" }, + { checks: "read", contents: "read", pull_requests: "read", metadata: "read" }, ]) { responsePermissions = permissions; await assert.rejects( @@ -1466,7 +1466,7 @@ test("end to end: a pull or triage grant mints a read-tier token; push mints wri assert.equal(json.repository, "example-org/alpha"); } // The installation has not accepted issues: write, so the read tier asks for reads only. - const readSet = { checks: "read", contents: "read", pull_requests: "read" }; + const readSet = { actions: "read", checks: "read", contents: "read", pull_requests: "read" }; assert.deepEqual(scopedMints, [readSet, readSet]); // Cross-Workspace: beta's Team holds nothing on alpha, whatever alpha's Team holds. @@ -1553,7 +1553,7 @@ test("policy check --live lists live Team grants as information and fails only o [ "lazurio.github_app_broker.policy.v3 owner=example-org installation=2001", "write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write pull_requests=write", - "read tier (pull/triage grant) asks for: checks=read contents=read pull_requests=read", + "read tier (pull/triage grant) asks for: actions=read checks=read contents=read pull_requests=read", "WORKSPACE TEAM_ID TEAM_SLUG", "alpha-team 4001 alpha-team", "beta-team 4002 -", diff --git a/test/brokered-gh.test.mjs b/test/brokered-gh.test.mjs index 7768e9f..eb2f7d4 100644 --- a/test/brokered-gh.test.mjs +++ b/test/brokered-gh.test.mjs @@ -639,6 +639,7 @@ test("a read-tier token runs official gh; reads succeed silently and a refused w assert.equal(await run(["api", "repos/Example/Alpha", "--jq", ".full_name"], 0), 0); assert.equal(await run(["pr", "view", "1", "--json", "state"], 0), 0); assert.equal(await run(["pr", "checks", "1"], 0), 0); + assert.equal(await run(["run", "view", "1"], 0), 0); assert.deepEqual(stderr, []); // A pull request is not refused up front: official gh runs, GitHub refuses, the exit code @@ -649,6 +650,7 @@ test("a read-tier token runs official gh; reads succeed silently and a refused w "api repos/Example/Alpha", "pr view", "pr checks", + "run view", "pr create", ]); assert.ok(children.every(({ token }) => token === TOKEN)); From 2e542ddec5d18b730132663b4c5db38f7b7373c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 18:37:31 +0200 Subject: [PATCH 5/6] Tokens read Organization membership; the read-tier notice is conditional Both tiers now ask for members: read, which the installation already holds for the Team gate. Without it GitHub answers the Owner and collaborator lists with an empty list, so an Environment could not find the Owners to mention in an issue (verified with a live brokered token). A failed read-tier command keeps official gh's error and exit code and adds a conditional notice: it names read access as the reason only if GitHub refused a change and says that issues need the accepted Issues permission. Tests cover a refused write and an unrelated failed read. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 6 +++--- ARCHITECTURE.md | 23 +++++++++++++--------- README.md | 17 ++++++++++++----- adapter/brokered-gh.mjs | 11 +++++++---- src/core.mjs | 7 +++++++ test/broker.test.mjs | 40 +++++++++++++++++++++++++++++---------- test/brokered-gh.test.mjs | 13 ++++++++++++- 7 files changed, 85 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 467e40b..b29ed4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,9 @@ dependency-light and customer-neutral. 4. A token is limited to one repository and to exactly one of two permission sets (plus GitHub's automatic `metadata: read`), and expires on GitHub's installation-token schedule. The write tier is `actions: write`, - `checks: read`, `contents: write` and `pull_requests: write`. The read tier - is `actions: read`, `checks: read`, `contents: read` and - `pull_requests: read`. Each tier + `checks: read`, `contents: write`, `members: read` and + `pull_requests: write`. The read tier is `actions: read`, `checks: read`, + `contents: read`, `members: read` and `pull_requests: read`. Each tier adds `issues: write`, and the write tier `workflows: write`, only when the reviewed policy declares that the installation accepted that permission. A mint without an explicit tier fails. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 405d50e..c3e8045 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -40,14 +40,16 @@ authorization on every valid token request. Every token request then asks GitHub to mint a new one-repository token with exactly the permission set of its tier, and refuses a response with any other scope or any other repository: -- write tier: `actions: write`, `checks: read`, `contents: write` and - `pull_requests: write`, plus `workflows: write` and `issues: write` each +- write tier: `actions: write`, `checks: read`, `contents: write`, + `members: read` and `pull_requests: write`, plus `workflows: write` and `issues: write` each when the policy declares that the installation accepted it; -- read tier: `actions: read`, `checks: read`, `contents: read` and - `pull_requests: read`, plus `issues: write` when the policy declares that - the installation accepted it. +- read tier: `actions: read`, `checks: read`, `contents: read`, + `members: read` and `pull_requests: read`, plus `issues: write` when the + policy declares that the installation accepted it. -GitHub adds `metadata: read` to both. There is no default tier: a mint without +GitHub adds `metadata: read` to both. `members: read` lets an Environment +list the Organization Owners it mentions in an issue; GitHub answers that list +with an empty list, not an error, to a token without it. There is no default tier: a mint without an explicit tier fails before reaching GitHub, so a lost tier can never become the write set. Between the installation gate and the mint, the runtime performs the Team gate described below; it is the step that turns a live @@ -246,9 +248,12 @@ and then run the real CLI. The adapter holds no repository list: it sends the repository name and accepts only a response for that same repository; the broker's live Team gate decides. It also refuses nothing by tier: with a read-tier token official `gh` runs and GitHub refuses what the token cannot -do, with GitHub's own error and exit code. After such a refusal the adapter -adds one stderr line naming the read access, so a permission refusal is not -mistaken for a broker outage. Installation tokens exist only in the child +do, with GitHub's own error and exit code. After any failed read-tier command +the adapter adds one conditional stderr line: it names the read access as the +reason if GitHub refused a change, leaves every other error as GitHub's own and +says that proposing through an issue needs the accepted Issues permission. The +adapter cannot tell a refusal from a bad argument or an outage, so it never +claims which one happened. Installation tokens exist only in the child environment and are never cached, rendered or written to GitHub CLI config. The official CLI remains responsible for pull requests, REST and GraphQL. diff --git a/README.md b/README.md index 1b8752a..a047da8 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,19 @@ immutable repository id. The Workspace's live GitHub Team grant on that repository selects one of two exact permission sets: - write tier (`push`, `maintain` or `admin` grant): `actions: write`, - `checks: read`, `contents: write` plus `pull_requests: write`, and + `checks: read`, `contents: write`, `members: read` plus + `pull_requests: write`, and `workflows: write` and `issues: write` where the Organization's policy declares them; - read tier (`pull` or `triage` grant): `actions: read`, `checks: read`, - `contents: read` plus `pull_requests: read`, and `issues: write` where the - Organization's policy declares it. + `contents: read`, `members: read` plus `pull_requests: read`, and + `issues: write` where the Organization's policy declares it. -GitHub adds `metadata: read` to every token. +GitHub adds `metadata: read` to every token. Both tiers read Organization +membership (`members: read`, which the installation already holds for the Team +gate) so an Environment can find the Organization Owners to mention in an +issue; without it GitHub answers the Owner and collaborator lists with an empty +list instead of an error. Lazurio for GitHub is an independent project. It is not affiliated with, sponsored by, or endorsed by GitHub, Inc. GitHub and GitHub CLI are trademarks @@ -63,7 +68,9 @@ Workspace id + Workspace credential + repository (name or id) and, with `issues: write` declared, open and manage issues there. GitHub has no rerun-only installation permission, so `actions: write` also permits other Actions mutations in that repository; the token still receives no - Checks write, administration, membership or cross-repository authority. + Checks write, administration, membership change or cross-repository + authority. Its `members: read` reads the Organization's members and Teams, + as any member of the Organization can. - A read-tier token can clone, fetch and read the contents of that one repository and list and view its pull requests, check runs and GitHub Actions workflow runs. `actions: read` also lets it read that repository's diff --git a/adapter/brokered-gh.mjs b/adapter/brokered-gh.mjs index 3343551..15b0560 100644 --- a/adapter/brokered-gh.mjs +++ b/adapter/brokered-gh.mjs @@ -256,18 +256,21 @@ export async function runBrokeredGh({ const repository = resolveGhRepository({ args, environment, readOrigin }); const credential = await requestToken({ repository, environment }); // The adapter refuses nothing up front by access tier: official gh runs and GitHub decides. - // After GitHub refuses a read-tier command, say why, so it does not read like an outage. + // A failed read-tier command adds one conditional notice after official gh's own error: the + // adapter cannot tell a refused change from a bad argument or an outage, so it never claims which. const exitCode = runRealGh(args, brokeredGhEnvironment(environment, credential.token, repository)); if (exitCode !== 0 && credential.access === "read") writeStderr(readAccessNotice(repository)); return exitCode; } -/** Explains a failed command under a read-tier token; contains no token or credential. */ +/** Context for a failed command under a read-tier token; contains no token or credential. */ export function readAccessNotice(repository) { return ( `Lazurio for GitHub: this Team Environment has read access to ${repository}. ` + - "GitHub refuses pushes, pull requests and other changes to it from here. " + - "Propose the change in an issue (gh issue create) or ask someone with write access.\n" + "If GitHub refused a change above (a push, pull request or other write), that is the reason; " + + "any other error above is GitHub's own. " + + "A change can be proposed in an issue (gh issue create) once the Organization has accepted " + + "the app's Issues permission, or by someone with write access.\n" ); } diff --git a/src/core.mjs b/src/core.mjs index fbfb4ca..1089c8c 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -20,10 +20,16 @@ const DUMMY_WORKSPACE_CREDENTIAL = "0".repeat(64); export const WRITE_ACCESS = "write"; export const READ_ACCESS = "read"; +/** + * Both tiers read Organization membership (`members: read`, which the installation already + * requires for the Team gate), so an Environment can find the Organization Owners to mention in an + * issue: without it GitHub answers the Owner and collaborator lists with an empty list, not an error. + */ const WRITE_TOKEN_PERMISSIONS = Object.freeze({ actions: "write", checks: "read", contents: "write", + members: "read", pull_requests: "write", }); /** @@ -37,6 +43,7 @@ const READ_TOKEN_PERMISSIONS = Object.freeze({ actions: "read", checks: "read", contents: "read", + members: "read", pull_requests: "read", }); diff --git a/test/broker.test.mjs b/test/broker.test.mjs index 94619bb..a677d62 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -525,6 +525,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", actions: "write", checks: "read", contents: "write", + members: "read", metadata: "read", pull_requests: "write", }; @@ -535,6 +536,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", actions: "write", checks: "read", contents: "write", + members: "read", pull_requests: "write", }, }); @@ -561,6 +563,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", responsePermissions = { checks: "read", contents: "write", + members: "read", metadata: "read", pull_requests: "write", }; @@ -572,6 +575,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", responsePermissions = { actions: "write", contents: "write", + members: "read", metadata: "read", pull_requests: "write", }; @@ -584,6 +588,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", actions: "write", checks: "read", contents: "write", + members: "read", metadata: "read", }; await assert.rejects( @@ -591,11 +596,24 @@ test("mints through repository_ids and rejects under- or over-scoped responses", /outside the requested repository or permission scope/, ); + responsePermissions = { + actions: "write", + checks: "read", + contents: "write", + metadata: "read", + pull_requests: "write", + }; + await assert.rejects( + () => github.mintToken(policy, 3001, "write"), + /outside the requested repository or permission scope/, + ); + responsePermissions = { administration: "write", actions: "write", checks: "read", contents: "write", + members: "read", metadata: "read", pull_requests: "write", }; @@ -607,7 +625,7 @@ test("mints through repository_ids and rejects under- or over-scoped responses", test("asks for workflows: write only when the policy declares the accepted permission", async () => { const now = 1_700_000_000_000; - const base = { actions: "write", checks: "read", contents: "write", pull_requests: "write" }; + const base = { actions: "write", checks: "read", contents: "write", members: "read", pull_requests: "write" }; let requested; let responsePermissions; const github = createGithubClient({ @@ -655,7 +673,7 @@ test("asks for workflows: write only when the policy declares the accepted permi test("the write tier adds issues: write only when the policy declares the accepted permission", async () => { const now = 1_700_000_000_000; - const base = { actions: "write", checks: "read", contents: "write", pull_requests: "write" }; + const base = { actions: "write", checks: "read", contents: "write", members: "read", pull_requests: "write" }; let requested; let responsePermissions; const github = createGithubClient({ @@ -730,7 +748,7 @@ test("the read tier asks for contents, pull request, check and Actions reads, ad now: () => now, }); - const readBase = { actions: "read", checks: "read", contents: "read", pull_requests: "read" }; + const readBase = { actions: "read", checks: "read", contents: "read", members: "read", pull_requests: "read" }; // Base policy: no issues accepted. A workflows acceptance never leaks into the read tier. const fixture = policyFixture(); @@ -765,9 +783,11 @@ test("the read tier asks for contents, pull request, check and Actions reads, ad { ...readBase, actions: "write", metadata: "read" }, { ...readBase, workflows: "write", metadata: "read" }, { ...readBase, issues: "write", metadata: "read" }, - { actions: "read", contents: "read", pull_requests: "read", metadata: "read" }, - { actions: "read", checks: "read", contents: "read", metadata: "read" }, - { checks: "read", contents: "read", pull_requests: "read", metadata: "read" }, + { ...readBase, members: "write", metadata: "read" }, + { actions: "read", contents: "read", members: "read", pull_requests: "read", metadata: "read" }, + { actions: "read", checks: "read", contents: "read", members: "read", metadata: "read" }, + { checks: "read", contents: "read", members: "read", pull_requests: "read", metadata: "read" }, + { actions: "read", checks: "read", contents: "read", pull_requests: "read", metadata: "read" }, ]) { responsePermissions = permissions; await assert.rejects( @@ -1466,7 +1486,7 @@ test("end to end: a pull or triage grant mints a read-tier token; push mints wri assert.equal(json.repository, "example-org/alpha"); } // The installation has not accepted issues: write, so the read tier asks for reads only. - const readSet = { actions: "read", checks: "read", contents: "read", pull_requests: "read" }; + const readSet = { actions: "read", checks: "read", contents: "read", members: "read", pull_requests: "read" }; assert.deepEqual(scopedMints, [readSet, readSet]); // Cross-Workspace: beta's Team holds nothing on alpha, whatever alpha's Team holds. @@ -1484,7 +1504,7 @@ test("end to end: a pull or triage grant mints a read-tier token; push mints wri await withLiveCoreServer(mintingFetch([], teamGrantFetch(), writeMints), async (origin) => { const response = await request(origin); assert.equal((await response.json()).access, "write"); - assert.deepEqual(writeMints, [{ actions: "write", checks: "read", contents: "write", pull_requests: "write" }]); + assert.deepEqual(writeMints, [{ actions: "write", checks: "read", contents: "write", members: "read", pull_requests: "write" }]); }); const noneMints = []; @@ -1552,8 +1572,8 @@ test("policy check --live lists live Team grants as information and fails only o formatPolicySummary(policy), [ "lazurio.github_app_broker.policy.v3 owner=example-org installation=2001", - "write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write pull_requests=write", - "read tier (pull/triage grant) asks for: actions=read checks=read contents=read pull_requests=read", + "write tier (push/maintain/admin grant) asks for: actions=write checks=read contents=write members=read pull_requests=write", + "read tier (pull/triage grant) asks for: actions=read checks=read contents=read members=read pull_requests=read", "WORKSPACE TEAM_ID TEAM_SLUG", "alpha-team 4001 alpha-team", "beta-team 4002 -", diff --git a/test/brokered-gh.test.mjs b/test/brokered-gh.test.mjs index eb2f7d4..8bc09ff 100644 --- a/test/brokered-gh.test.mjs +++ b/test/brokered-gh.test.mjs @@ -659,10 +659,21 @@ test("a read-tier token runs official gh; reads succeed silently and a refused w assert.match(stderr[0], /gh issue create/); assert.equal(stderr.join("").includes(TOKEN), false); + // An unrelated failed read (missing resource, bad argument, outage) runs official gh too, keeps + // its exit code and gets the same notice, which only explains a refusal conditionally and says + // that proposing through an issue needs the Organization's accepted Issues permission. + assert.equal(await run(["api", "repos/Example/Alpha/contents/missing"], 1), 1); + assert.deepEqual(children.at(-1).args, ["api", "repos/Example/Alpha/contents/missing"]); + assert.equal(stderr.length, 2); + assert.match(stderr[1], /If GitHub refused a change above/); + assert.match(stderr[1], /any other error above is GitHub's own/); + assert.match(stderr[1], /accepted the app's Issues permission/); + assert.doesNotMatch(stderr[1], /GitHub refuses pushes/); + // A write-tier failure, or one from a broker that reports no tier, stays official gh's own output. assert.equal(await run(["pr", "create"], 1, { ...validToken(), access: "write" }), 1); assert.equal(await run(["pr", "create"], 1, validToken()), 1); - assert.equal(stderr.length, 1); + assert.equal(stderr.length, 2); }); test("discovery inside a checkout succeeds with a read-tier repository proof", async () => { From 0ba2f2acf139ce2f6c369554a68c8c176be1c144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Mon, 5 Oct 2026 18:37:39 +0200 Subject: [PATCH 6/6] README: describe the conditional read-tier notice Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a047da8..f8df571 100644 --- a/README.md +++ b/README.md @@ -580,10 +580,12 @@ and `gh repo view`, `gh api` reads, `gh pr list`, `gh pr view`, `gh pr checks` and `gh run view` and, where the policy declares `issues: write`, `gh issue create` work. A `git push` fails with GitHub's own `403` permission error. A `gh` command GitHub refuses, such -as `gh pr create`, keeps official `gh`'s error and exit code, and the adapter -adds one stderr line saying that the Environment has read access to that -repository and suggesting an issue, so a refusal does not look like a broker -outage. +as `gh pr create`, keeps official `gh`'s error and exit code. After any failed +command under the read tier the adapter adds one conditional stderr line: if +GitHub refused a change, the read access is the reason; any other error is +GitHub's own; an issue can propose the change once the Organization accepted +the Issues permission. A refusal therefore does not look like a broker outage, +and an unrelated failure is not misdiagnosed as one. `gh pr checks` and `--json statusCheckRollup`, which T3's pull request list uses, also ask GitHub for the workflow run behind each GitHub Actions check.