diff --git a/AGENTS.md b/AGENTS.md index faea018..b29ed4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,11 +13,15 @@ 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`, `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. 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 +29,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 +43,13 @@ 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. 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 `npm run check` before handoff. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 77e344d..c3e8045 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,23 @@ 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`, + `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`, + `members: read` and `pull_requests: read`, plus `issues: write` when the + policy declares that the installation accepted it. + +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 +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 +62,30 @@ 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 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, +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 secrets, but a workflow it writes can, in an eligible run, reference and @@ -59,7 +96,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 both tiers. ## Team binding and live grant verification @@ -83,17 +120,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 +155,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 +189,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 +246,14 @@ 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 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 95e4548..f8df571 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,23 @@ 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`, `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`, `members: read` plus `pull_requests: read`, and + `issues: write` where the Organization's policy declares it. + +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 @@ -30,8 +44,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 +54,33 @@ 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 + 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, + 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 + 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. - 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 +138,20 @@ 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 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 @@ -130,8 +169,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 +179,76 @@ 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. + +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 +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, 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 + 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, 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: + +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 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 + 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 +271,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 +308,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 +340,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,28 +441,40 @@ 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 +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: actions=read checks=read contents=read issues=write pull_requests=read +WORKSPACE TEAM_ID TEAM_SLUG +alpha-team 4001 alpha-team +``` + +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 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) ``` -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 -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 +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 the policy, never by the broker. @@ -441,6 +572,30 @@ 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, `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. 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. +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 +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 +655,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 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..15b0560 100644 --- a/adapter/brokered-gh.mjs +++ b/adapter/brokered-gh.mjs @@ -255,7 +255,23 @@ 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. + // 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; +} + +/** 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}. ` + + "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" + ); } 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..1089c8c 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -8,25 +8,69 @@ 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"; + +/** + * 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", }); /** - * 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, 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 WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...TOKEN_PERMISSIONS, workflows: "write" }); +const READ_TOKEN_PERMISSIONS = Object.freeze({ + actions: "read", + checks: "read", + contents: "read", + members: "read", + pull_requests: "read", +}); -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`. 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. + */ +export function tokenPermissions(policy, access) { + const accepted = policy.installation_permissions; + const issues = accepted.issues === "write" ? { issues: "write" } : {}; + if (access === WRITE_ACCESS) { + 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"); +} + +/** 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 +381,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 +439,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 +447,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 +457,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () repository_id: grant.id, full_name: returned.full_name, role, + access, }); } @@ -423,7 +465,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 +485,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 +504,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () repository_id: "-", full_name: "-", role: "-", + access: "-", status: "refused", detail: error.message, }), @@ -476,12 +520,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 +538,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 +555,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 +565,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 +597,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 +605,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 +628,7 @@ const POLICY_CHECK_COLUMNS = Object.freeze([ ["repository_id", "REPOSITORY_ID"], ["full_name", "REPOSITORY"], ["role", "ROLE"], + ["access", "ACCESS"], ["status", "STATUS"], ["detail", "DETAIL"], ]); @@ -592,11 +645,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 +713,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..a677d62 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, }; }, ); @@ -456,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", }; @@ -466,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", }, }); @@ -482,31 +553,34 @@ 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 = { checks: "read", contents: "write", + members: "read", metadata: "read", pull_requests: "write", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); responsePermissions = { actions: "write", contents: "write", + members: "read", metadata: "read", pull_requests: "write", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); @@ -514,10 +588,23 @@ 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( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), + /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/, ); @@ -526,18 +613,19 @@ 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", }; await assert.rejects( - () => github.mintToken(policy, 3001), + () => github.mintToken(policy, 3001, "write"), /outside the requested repository or permission scope/, ); }); 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({ @@ -558,7 +646,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 +654,160 @@ 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/, + ); + +}); + +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", members: "read", 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, 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, 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; + 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, + }); + + 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(); + fixture.installation_permissions.workflows = "write"; + const withoutIssues = parsePolicy(fixture); + 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, readBase); + + fixture.installation_permissions.issues = "write"; + const withIssues = parsePolicy(fixture); + responsePermissions = { ...readBase, issues: "write", metadata: "read" }; + assert.equal((await github.mintToken(withIssues, 3004, "read")).access, "read"); + 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 = { ...readBase, metadata: "read" }; + await github.mintToken(parsePolicy(fixture), 3004, "read"); + assert.deepEqual(requested, readBase); + + // Any write or read beyond the requested set, a missing read, or an undeclared issues grant is refused. + for (const permissions of [ + { ...readBase, contents: "write", metadata: "read" }, + { ...readBase, pull_requests: "write", metadata: "read" }, + { ...readBase, checks: "write", metadata: "read" }, + { ...readBase, actions: "write", metadata: "read" }, + { ...readBase, workflows: "write", metadata: "read" }, + { ...readBase, issues: "write", 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( + () => github.mintToken(withoutIssues, 3004, "read"), + /outside the requested repository or permission scope/, + JSON.stringify(permissions), + ); + } + responsePermissions = { ...readBase, 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 +863,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 +897,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 +1052,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 +1068,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 +1126,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 +1169,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 +1283,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 +1349,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 +1402,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 +1464,58 @@ 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 reads only. + 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. + 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", members: "read", 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 +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 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 -", @@ -1283,11 +1584,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 +1606,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 +1642,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 +1664,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..8bc09ff 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,119 @@ 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, 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.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 + // 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.slice(0, 2).join(" ")), [ + "issue create", + "api repos/Example/Alpha", + "pr view", + "pr checks", + "run view", + "pr create", + ]); + 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); + + // 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, 2); +}); + +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 () => {