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..0fc9788 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,18 @@ 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/`. 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 +accepts. + ## Team binding and live grant verification A Hosted Team Workspace has no human GitHub identity; its GitHub identity is @@ -55,7 +69,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 e9f086c..d852278 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 @@ -132,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: @@ -144,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 @@ -157,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 @@ -245,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 2ad139a..03f1937 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -13,8 +13,22 @@ const TOKEN_PERMISSIONS = Object.freeze({ contents: "write", pull_requests: "write", }); -/** Organization `members: read` is the GitHub App permission that reads Teams and Team repository grants. */ -const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read" }); +/** + * 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` 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"; @@ -415,11 +429,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 +448,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..52ef4bc 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 () => {} }; @@ -810,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");