From 305b1db16f7d0d3ee0c8a48c314f84c5d9988df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Fri, 2 Oct 2026 20:53:53 +0200 Subject: [PATCH 1/3] Ask for workflows: write when the policy declares the accepted permission A Team Workspace could not push commits touching .github/workflows/ because installation tokens never asked for workflows: write. The token now asks for it only when the reviewed policy's installation_permissions declares it, and refuses a response outside the requested set, so this release can roll out before any Organization accepts the new App permission. Refs #15 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 5 +++-- ARCHITECTURE.md | 12 ++++++++++- README.md | 10 +++++++++- src/core.mjs | 26 +++++++++++++----------- test/broker.test.mjs | 47 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5b840a..1e7ea71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,9 @@ dependency-light and customer-neutral. 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`), and expires on GitHub's installation-token - schedule. + 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. 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f3457b1..90e663b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,7 +24,9 @@ repository set. 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`; removal of a live +`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; removal of a live repository grant therefore fails the next issuance without waiting for a local cache. Between the installation gate and the mint, the runtime performs the Team gate @@ -38,6 +40,14 @@ Actions mutations in the same repository. The remaining boundaries therefore stay material: one immutable repository per short-lived token, an exact Workspace repository allowlist and no Checks write access. +`workflows: write` lets a Workspace push commits that change +`.github/workflows/`. A changed workflow runs with that repository's Actions +secrets, so this is the same authority 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. + ## Team binding and live grant verification A Hosted Team Workspace has no human GitHub identity; its GitHub identity is diff --git a/README.md b/README.md index e9f086c..e7e36b4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ 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`. +`contents: write` plus `pull_requests: write`, and `workflows: write` where the +Organization's policy declares it. 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 @@ -107,6 +108,13 @@ Schema v2 makes the Team binding part of the contract: the policy is reviewed, and the slug never selects a different Team. - `installation_permissions.members` must be `read`: the App reads Teams and Team repository grants through this Organization permission. +- `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. - `id`, `credential_file` and `repository_ids` keep their v1 meaning. Schema v1 is retired and rejected with a migration message. Existing v1 diff --git a/src/core.mjs b/src/core.mjs index 2ad139a..02f7e5f 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -13,6 +13,16 @@ const TOKEN_PERMISSIONS = Object.freeze({ contents: "write", 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. + */ +const WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...TOKEN_PERMISSIONS, workflows: "write" }); + +function tokenPermissions(policy) { + return policy.installation_permissions.workflows === "write" ? WORKFLOW_TOKEN_PERMISSIONS : TOKEN_PERMISSIONS; +} /** Organization `members: read` is the GitHub App permission that reads Teams and Team repository grants. */ const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read" }); /** Custom media type that makes the Team repository check return the repository with its `permissions`. */ @@ -415,11 +425,12 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () } async function mintToken(policy, repositoryId) { + const requested = tokenPermissions(policy); const result = await request(`/app/installations/${policy.installation_id}/access_tokens`, { method: "POST", body: { repository_ids: [repositoryId], - permissions: TOKEN_PERMISSIONS, + permissions: requested, }, }); const expiresAt = Date.parse(result?.expires_at ?? ""); @@ -433,19 +444,10 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () expiresAt > now() + 65 * 60 * 1000 || returnedRepositories.length !== 1 || returnedRepositories[0]?.id !== repositoryId || - returnedPermissions.actions !== "write" || - returnedPermissions.checks !== "read" || - returnedPermissions.contents !== "write" || - returnedPermissions.pull_requests !== "write" || + Object.entries(requested).some(([permission, level]) => returnedPermissions[permission] !== level) || Object.entries(returnedPermissions).some( ([permission, level]) => - !( - (permission === "actions" && level === "write") || - (permission === "checks" && level === "read") || - (permission === "contents" && level === "write") || - (permission === "pull_requests" && level === "write") || - (permission === "metadata" && level === "read") - ), + requested[permission] !== level && !(permission === "metadata" && level === "read"), ) ) { fail("GitHub returned a token outside the requested repository or permission scope"); diff --git a/test/broker.test.mjs b/test/broker.test.mjs index db0259b..1aa9f9d 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -594,6 +594,53 @@ 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" }; + 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, + }); + + const withoutWorkflows = parsePolicy(policyFixture()); + responsePermissions = { ...base, metadata: "read", workflows: "write" }; + await assert.rejects( + () => github.mintToken(withoutWorkflows, 3001), + /outside the requested repository or permission scope/, + ); + assert.deepEqual(requested, base); + + 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.deepEqual(requested, { ...base, workflows: "write" }); + + responsePermissions = { ...base, metadata: "read" }; + await assert.rejects( + () => github.mintToken(withWorkflows, 3001), + /outside the requested repository or permission scope/, + ); + + responsePermissions = { ...base, metadata: "read", workflows: "write", emails: "read" }; + await assert.rejects( + () => github.mintToken(withWorkflows, 3001), + /outside the requested repository or permission scope/, + ); +}); + test("rejects duplicated Workspace credential values and aliased paths before listening", async () => { const policy = parsePolicy(policyFixture()); const github = { verifyPolicy: async () => {} }; From 99bc49c2624ac0432093de6705bbe350acaf8cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Fri, 2 Oct 2026 20:59:36 +0200 Subject: [PATCH 2/3] Give the Team probe metadata: read so private repository grants are visible With members: read alone GitHub answers the Team repository check with 404 for every private repository the probe cannot see. A live policy check against the three production policies refused 170 of 178 real write grants; with metadata: read all 178 pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 3 ++- README.md | 10 +++++++--- src/core.mjs | 8 ++++++-- test/broker.test.mjs | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90e663b..7fd6869 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -65,7 +65,8 @@ is nevertheless a separate, deny-only admission gate that decides which live grants the broker will use at all: a repository outside the allowlist is refused before the Team grant is read, even when the Team holds a write grant on it. On every token request, after the credential and allowlist -checks, the runtime mints a `members: read` probe token, reads the Team by +checks, the runtime mints a `members: read` plus `metadata: read` probe token, +reads the Team by `/organizations/{org_id}/team/{team_id}`, checks the Team's grant on the exact repository with `/organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}` and the repository-permissions media type, revokes the probe, and only then diff --git a/README.md b/README.md index e7e36b4..91fb6ea 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,8 @@ only guarantees that no final repository token exists: | `502` | `token_unavailable` | GitHub was unreachable, rate-limited or over quota, returned an unexpected shape, or the mint left the requested scope. | | `415` | `unsupported_media_type` | Body is not JSON. | -The Team gate runs live on every mint with a short-lived `members: read` probe -token that is revoked immediately afterwards. Its two read requests are +The Team gate runs live on every mint with a short-lived `members: read` plus +`metadata: read` probe token that is revoked immediately afterwards. Its two read requests are addressed by immutable ids: 1. `GET /organizations/{org_id}/team/{team_id}` proves the Team still exists in @@ -165,6 +165,9 @@ addressed by immutable ids: repository id to match and `push`, `maintain` or `admin` to be `true`. A `404` means no grant (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 `{owner}/{repo}` path segment is the policy's asserted `full_name`, whose immutable id was already proven against the live installation. A repository @@ -253,7 +256,8 @@ 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 reads back every `repository_ids` entry of every Workspace with one -`members: read` probe token per Workspace, and prints one row per grant: +`members: read` plus `metadata: read` probe token per Workspace, and prints one +row per grant: ```text WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL diff --git a/src/core.mjs b/src/core.mjs index 02f7e5f..03f1937 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -23,8 +23,12 @@ const WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...TOKEN_PERMISSIONS, workflo function tokenPermissions(policy) { return policy.installation_permissions.workflows === "write" ? WORKFLOW_TOKEN_PERMISSIONS : TOKEN_PERMISSIONS; } -/** Organization `members: read` is the GitHub App permission that reads Teams and Team repository grants. */ -const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read" }); +/** + * Organization `members: read` reads Teams and Team repository grants. GitHub answers the Team + * repository check with 404 unless the token can also see the repository, which takes + * `metadata: read`; a `members: read`-only probe therefore refuses every private repository. + */ +const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read", metadata: "read" }); /** Custom media type that makes the Team repository check return the repository with its `permissions`. */ const REPOSITORY_PERMISSIONS_MEDIA_TYPE = "application/vnd.github.v3.repository+json"; diff --git a/test/broker.test.mjs b/test/broker.test.mjs index 1aa9f9d..52ef4bc 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -857,7 +857,7 @@ test("verifies the live Team binding and write-capable grant with a revoked memb "GET /organizations/1001/team/4001/repos/example-org/alpha", "DELETE /installation/token", ]); - assert.deepEqual(calls[0].body, { permissions: { members: "read" } }); + assert.deepEqual(calls[0].body, { permissions: { members: "read", metadata: "read" } }); assert.equal(calls[1].authorization, "Bearer ghs_team_probe"); assert.equal(calls[2].accept, "application/vnd.github.v3.repository+json"); assert.equal(calls[3].authorization, "Bearer ghs_team_probe"); From 3f9824f3a91685763e33188632ad8bfb6e973beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Fri, 2 Oct 2026 21:03:48 +0200 Subject: [PATCH 3/3] Describe workflows: write as a capability and name metadata: read everywhere Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 8 ++++++-- README.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7fd6869..0fc9788 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,8 +41,12 @@ stay material: one immutable repository per short-lived token, an exact Workspace repository allowlist and no Checks write access. `workflows: write` lets a Workspace push commits that change -`.github/workflows/`. A changed workflow runs with that repository's Actions -secrets, so this is the same authority a Team member with a write grant +`.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 +exfiltrate the repository and Organization Actions secrets available to that +run, subject to environment protections (fork-pull-request and Dependabot +events withhold ordinary secrets, and protected environment secrets need +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 diff --git a/README.md b/README.md index 91fb6ea..d852278 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ policy verification has succeeded. Every `POST /v1/token` passes these gates in order; a failed gate never issues a scoped repository token. The Team gate itself first mints a short-lived -`members: read` probe token, performs the Team read and revokes the probe, so +`members: read` plus `metadata: read` probe token, performs the Team read and revokes the probe, so a Team or grant refusal has already cost that probe mint and revocation — it only guarantees that no final repository token exists: