diff --git a/AGENTS.md b/AGENTS.md index 3da66a5..e1d5ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,13 @@ dependency-light and customer-neutral. reviewed policy declares that the installation accepted that permission. A Workspace whose policy declares `permission_ceiling: "read"` mints only the read tier with every permission lowered to `read`, so its tokens carry no - write permission. A mint without an explicit tier or a Workspace of the - policy fails, and so does any set that asks for a permission or level the - installation has not accepted. A token of a GitHub Actions OIDC purpose is + write permission. A Workspace whose policy declares + `permission_ceiling: "contents"` (since `0.16.0`) mints exactly + `contents: write` for the write tier and exactly `contents: read` for the + read tier, never an opt-in, so its tokens carry no Actions, Checks, issue, + membership, pull request or workflow permission. A mint without an explicit + tier or a Workspace of the policy fails, and so does any set that asks for + a permission or level the installation has not accepted. A token of a GitHub Actions OIDC purpose is limited to the purpose's one repository and its exact reviewed permission set (plus `metadata: read`), which the parser checks against the installation's accepted permissions; a rule that admits an event other than @@ -57,7 +61,9 @@ dependency-light and customer-neutral. 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; a Workspace's read ceiling turns every - usable grant into the read tier and never creates access. The live Team + usable grant into the read tier, a contents ceiling keeps the grant's tier + except on the policy's `conglomerate_hosts` repository, where it selects + the read tier, and neither ceiling creates access. The live Team grant is the scope: the policy holds no repository list or tier and never narrows or widens which repositories it reaches. Nothing from that readback is cached; the policy, the Dashboard and any @@ -118,7 +124,9 @@ dependency-light and customer-neutral. plan without rulesets on private repositories, such Apps remain an accepted, stated precondition, never described as checked. This App's own tokens that can write that repository must already carry - `members: read`: the Workspace write tier always does, and the parser + `members: read`: the Workspace write tier always does, a contents-ceiling + Workspace gets only the read tier there (its Team gate selects it and its + mint refuses the write set), and the parser refuses a policy whose OIDC purpose could mint `contents: write` on it without `members: read`. Such a writer can still leave a key document that outlives its own token or grant; the product owner accepted that on @@ -151,6 +159,14 @@ including a read-ceiling token, as unable to open issues or as writing nothing on GitHub. The read tier's `actions: read` likewise reads more than check status: it also reads the repository's workflow run logs and artifacts. +`contents: write` is likewise GitHub's whole contents permission, also under +the contents ceiling: it pushes, force-pushes and deletes every branch no +ruleset or branch protection protects, creates and deletes tags and releases, +and creates `repository_dispatch` events, and a push or such an event runs +the repository's workflows. Never describe a contents-ceiling token as +limited to one branch or to fast-forward pushes, or as unable to start +workflows. + Describe an OIDC purpose's permissions exactly as GitHub defines them, and say which of them a live probe has not proven yet. `contents: write` on a Deployment Repo pushes any ref there, not only the Permit consumption, and diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a252ea..0041322 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,8 +12,8 @@ deployment-owned policy binds: - one installation id, its repository selection mode and the exact accepted permission set; - each immutable Workspace id to exactly one immutable GitHub Team id, a - separate deployment credential reference and an optional read permission - ceiling; and + separate deployment credential reference and an optional permission + ceiling (`read`, or since `0.16.0` `contents`); and - optionally, the OIDC purposes: this broker's audience, the Deployment Repos by immutable repository and owner id, and per purpose its one repository, optional callers among those Deployment Repos, workflow file, admission @@ -25,9 +25,9 @@ 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 never narrows the scope below GitHub. Its -only narrowing is the optional per-Workspace `permission_ceiling: "read"`, -described below, which lowers the permissions of that Workspace's tokens and -never changes which repositories it reaches. An +only narrowing is the optional per-Workspace `permission_ceiling` (`"read"` or +`"contents"`), described below, which lowers the permissions of that +Workspace's tokens and never changes which repositories it reaches. 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 @@ -59,11 +59,15 @@ its tier, and refuses a response with any other scope or any other repository: policy declares that the installation accepted it; - read tier of a Workspace with `permission_ceiling: "read"`: the read tier with every permission lowered to `read`, so an accepted `issues: write` - becomes `issues: read`. + becomes `issues: read`; +- a Workspace with `permission_ceiling: "contents"`: exactly + `contents: write` for the write tier and exactly `contents: read` for the + read tier, never an opt-in. Before asking GitHub, the runtime refuses any set that asks for a permission -or level the installation has not accepted, and any write tier for a -Workspace with a read ceiling; the installation gate itself stays an exact +or level the installation has not accepted, any write tier for a Workspace +with a read ceiling, and a contents write set for the policy's +`conglomerate_hosts` repository; the installation gate itself stays an exact comparison. GitHub adds `metadata: read` to both. `members: read` lets an Environment @@ -130,6 +134,36 @@ refused issue creation in public repositories there, so a Workspace used by untrusted people must decide at the Workspace whether it receives a token at all. +A Workspace that reads and writes nothing but repository contents, such as a +production applications host that pushes its applications' data, declares +`permission_ceiling: "contents"` (since `0.16.0`). Its Team gate keeps the +tier of each grant, with one exception below, and each tier asks for +repository contents only: +`contents: write` or `contents: read`, plus GitHub's `metadata: read`, with no +opt-in whatever the installation accepted. Its tokens therefore carry no +permission for pull requests, issues, check runs, workflow runs, workflow +files or Organization membership (opening an issue in a public repository, +above, is GitHub's exception for every token). `contents: write` stays GitHub's whole +contents permission: it pushes, force-pushes and deletes every unprotected +branch and creates tags, releases and `repository_dispatch` events, and a +push or such an event runs the workflows that listen for it. Keeping writes +to one branch or to fast-forward pushes is the caller's part, or a ruleset's +where the plan offers rulesets. One repository is the exception: on the +policy's `conglomerate_hosts` repository every grant of such a Workspace +mints the read tier, because a key document written there obtains +`members: read` (see [Conglomerate Host callers](#conglomerate-host-callers)), +and the mint refuses that write set there as a second check. Brokers from +`0.12.0` to `0.15.x` refuse a policy with this value as a whole and older +brokers ignore the key and mint the uncapped tiers, so it is deployed only +with `0.16.0` or later. + +The ceiling bounds each token, not who uses the Workspace's credential. Its +first user, a production applications host, runs all of its applications +under one account with one credential, so an application of that host can +push, through the host's data service, to the data repository of any other +writing application of the same host. Nothing reaches a repository the Team +holds no grant on, and a read grant never pushes. + `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 @@ -154,8 +188,8 @@ different Team for the broker. The live GitHub Team-to-repository grants are the only grant authority and the whole scope: the policy never grants anything GitHub has not granted and never -narrows which repositories GitHub has granted; a read ceiling only lowers the -tier. A request names its repository either as +narrows which repositories GitHub has granted; a permission ceiling only +lowers the tier or narrows its set. A request names its repository either as `Owner/name` or, for adapters released before `0.10.0`, as an immutable id. A name whose owner is not the policy's Organization login is refused with `403 repository_denied` before any GitHub traffic. Otherwise, after the @@ -169,7 +203,8 @@ 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, and a Workspace's read ceiling turns every one of them into the -read tier. A missing Team, an identity that differs from the policy +read tier, as a contents ceiling does on the policy's `conglomerate_hosts` +repository. 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 @@ -202,9 +237,9 @@ 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 repository grants with -the tier each one mints, after any read ceiling, next to the live role. +the tier each one mints, after any ceiling, next to the live role. Without `--live` it prints the exact permission set each tier asks for under -the policy and, when a Workspace declares a read ceiling, that set and a +the policy and, when a Workspace declares a ceiling, its sets and a `CEILING` column. 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 @@ -451,9 +486,10 @@ a Deployment Repo: every key change stays in history, also against an Owner or this App. This App itself is a bypass actor of the update ruleset, so every token it mints that can write the repository is a writer too: the Workspace write - tier always carries `members: read`, and the parser refuses a policy with - an OIDC purpose that could mint `contents: write` on the key repository - without `members: read`. Such a writer holds `members: read` already, but + tier always carries `members: read`, a Workspace with the contents ceiling + mints only `contents: read` on the key repository, and the parser refuses a + policy with an OIDC purpose that could mint `contents: write` on the key + repository without `members: read`. Such a writer holds `members: read` already, but only while its token or grant lasts: a key document it wrote keeps minting after that, because the broker checks the current writers, never who wrote a document. The product owner accepted this durable access on 2026-11-03 @@ -495,6 +531,11 @@ What remains: caller's default branch can read all of it, not only the file its runner needs. Removing the caller, or the purpose, stops it at the next request. - Audit lines name the workflow's actor and are kept by the deployment owner. +- A Workspace credential is one identity for whatever uses it. A production + applications host keeps one for all of its applications, which run under + one account, so any of them can push, through the host's data service, to + the data repository of another writing application of that host, within + the Team's grants and the contents ceiling. ## What this service does not do diff --git a/README.md b/README.md index 2e30b75..f2534b6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ the read tier, for every grant, with each permission lowered to `read` (an accepted `issues: write` becomes `issues: read`): its tokens carry no write permission. See [Read-only Workspaces](#read-only-workspaces-permission_ceiling). +Since `0.16.0` a Workspace whose policy entry declares +`permission_ceiling: "contents"` keeps the tier of each grant but asks for +repository contents only: `contents: write` for a `push`, `maintain` or +`admin` grant, `contents: read` for a `pull` or `triage` grant, and only +`contents: read` on the policy's `conglomerate_hosts` repository. Its tokens +never carry an Actions, Checks, issue, membership, pull request or workflow +permission. See +[Contents-only Workspaces](#contents-only-workspaces-permission_ceiling-contents). + Since `0.13.0` the Cloudflare Worker adapter also serves short-lived GitHub Actions runners: a job of an allowlisted Deployment Repo exchanges its GitHub Actions OIDC token for one installation token of a configured purpose, limited @@ -45,7 +54,7 @@ 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. +list instead of an error. A contents-ceiling token does not read it. Lazurio for GitHub is an independent project. It is not affiliated with, sponsored by, or endorsed by GitHub, Inc. GitHub and GitHub CLI are trademarks @@ -72,7 +81,8 @@ Workspace id + Workspace credential + repository (name or id) -> live GitHub App installation identity and permissions -> live grant of the Workspace's immutable GitHub Team on that repository (push/maintain/admin -> write tier, pull/triage -> read tier, none -> refused) - -> the Workspace's optional permission ceiling (read: every grant -> read-only set) + -> the Workspace's optional permission ceiling (read: every grant -> read-only set; + contents: the grant's tier -> repository contents only) -> one-repository installation token of that tier for the repository id GitHub returned ``` @@ -87,8 +97,9 @@ Workspace id + Workspace credential + repository (name or id) `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 widens what GitHub has granted. Its only narrowing is the optional - per-Workspace read ceiling, which lowers permissions and never changes which - repositories a Workspace reaches. An Organization Admin who grants + per-Workspace permission ceiling (`read` or `contents`), which lowers + permissions and never changes which repositories a Workspace reaches. 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. @@ -118,6 +129,27 @@ Workspace id + Workspace credential + repository (name or id) reopen issues, to create refs and pull requests and to push, but let it open an issue in a public repository (see [Opening issues in public repositories](#opening-issues-in-public-repositories)). +- A token of a Workspace with `permission_ceiling: "contents"` carries + repository contents only, at the level of the Team's grant: + `contents: write` for `push`, `maintain` or `admin`, `contents: read` for + `pull` or `triage`, and only `contents: read` on the policy's + `conglomerate_hosts` repository. It carries no permission for pull + requests, issues, check runs, GitHub Actions, workflow files or + Organization membership: in the live check GitHub refused it opening and + listing pull requests, opening an issue in a private repository, rerunning + and dispatching workflows, creating a check run and pushing a change to + `.github/workflows/`. + `contents: write` is still GitHub's whole contents permission, not a branch + or fast-forward permission: it pushes, force-pushes and deletes every branch + that no rule protects (GitHub refuses only the deletion of the default + branch), creates and deletes tags and releases, and creates + `repository_dispatch` events; a push or such an event runs the repository's + workflows that listen for it. Known limitation: a production applications + host runs all of its applications under one account with one Workspace + credential, so any application of that host can push, through the host's + data service, to the data repository of any other writing application of + the same host that the Team may write (see + [Contents-only Workspaces](#contents-only-workspaces-permission_ceiling-contents)). - 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. @@ -217,10 +249,13 @@ Schema v3 binds each Workspace to its GitHub Team and names no repositories: exists per Team. The optional slug is a human-readback assertion: when present, the live Team slug must match; a renamed Team fails closed until the policy is reviewed, and the slug never selects a different Team. -- `workspaces[].permission_ceiling` is optional and its only value is - `"read"`. Absent, the Workspace mints exactly what its live grant selects, - as before `0.12.0`. With `"read"` every token of that Workspace is - read-only; see [Read-only Workspaces](#read-only-workspaces-permission_ceiling). +- `workspaces[].permission_ceiling` is optional; its values are `"read"` + (since `0.12.0`) and `"contents"` (since `0.16.0`). Absent, the Workspace + mints exactly what its live grant selects, as before `0.12.0`. With + `"read"` every token of that Workspace is read-only; see + [Read-only Workspaces](#read-only-workspaces-permission_ceiling). With + `"contents"` every token carries repository contents only; see + [Contents-only Workspaces](#contents-only-workspaces-permission_ceiling-contents). Any other value, including `null`, is rejected. - `oidc` is optional (since `0.13.0`). It declares the purposes for which GitHub Actions jobs of listed Deployment Repos may obtain a token; see @@ -422,6 +457,130 @@ Workspace's broker back below `0.12.0` while its credential is in use; rotate that Workspace's credential first. A read-only Workspace's adapter needs no change. +#### Contents-only Workspaces (`permission_ceiling: "contents"`) + +Since `0.16.0` a Workspace can be limited to repository contents. The ceiling +is meant for a Workspace that reads and writes nothing but repository +contents, for example a production applications host that clones its +applications' data repositories and pushes their changes. Its policy entry +declares: + +```json +{ + "id": "example-apps-host", + "github_team_id": 4005, + "github_team_slug": "apps-host", + "credential_file": "/run/secrets/workspace-example-apps-host", + "permission_ceiling": "contents" +} +``` + +For such a Workspace: + +- every grant keeps its tier, except on the policy's `conglomerate_hosts` + repository (last item): `push`, `maintain` or `admin` mints exactly + `contents: write`, `pull` or `triage` exactly `contents: read`, and GitHub + adds `metadata: read`. The token response says `access: "write"` or + `"read"` as for any Workspace. No opt-in reaches it: whatever the + installation accepted and the policy declares, it never asks for + `actions`, `checks`, `issues`, `members`, `pull_requests` or `workflows`; +- no grant, a missing or drifted Team and a foreign owner are refused exactly + as without a ceiling. The ceiling never creates access and never changes + which repositories the Workspace reaches; +- the mint still revokes and refuses a token whose repository or permissions + GitHub returns other than requested (`502 token_unavailable`); +- on the policy's `conglomerate_hosts` repository every grant mints only + `contents: read`. A key document written there obtains a host token with + `members: read`, so every token that can write that repository must carry + `members: read` (see + [Precondition: who writes the Deployment Repo](#precondition-who-writes-the-deployment-repo)), + and this ceiling never asks for it. The Team gate selects the read tier + there, and the mint refuses the write set for that repository before + GitHub is asked, should a gate ever select it. + +`contents: write` is GitHub's whole permission for repository contents, and +the ceiling cannot narrow it further: GitHub has no installation permission +for one branch or for fast-forward pushes only. Such a token pushes, +force-pushes and deletes every branch that no ruleset or branch protection +protects (GitHub refuses only the deletion of the default branch), creates and +deletes tags and releases, and creates `repository_dispatch` events. A push or +such an event runs the repository's workflows that listen for it, although +the token cannot change a workflow file. Keeping writes to one branch and to +fast-forward pushes is the caller's part, for example a host service that +composes every refspec itself, or a ruleset's. GitHub offers rulesets and +branch protection for private repositories only on paid plans; on GitHub Free +a private repository has neither, and nothing on GitHub stops any of its +writers from force-pushing or deleting a branch. + +**Known limitation: one account for every application of a host.** A +production applications host runs all of its applications under one account +and keeps one Workspace credential for them. The service that fetches and +pushes their data cannot tell the applications apart, so any application of +that host can push, through that service, to the data repository of any other +application of the same host that writes data, as far as the Team's grants +allow. The ceiling bounds what each token can do, not which application of +the host asks for it. Nothing reaches a repository the Team holds no grant on, +and a `pull` or `triage` grant never pushes. + +In the live check on 2026-11-14 the broker built from this source served one +contents-ceiling Workspace through `POST /v1/token` for a one-off private +repository of a test Organization on GitHub Free, whose installation had also +accepted Actions, Issues, Pull requests and Workflows write. GitHub answered +each mint with exactly the requested set plus `metadata: read`, and: + +- with a `push` grant (`contents: write`) the token cloned, pushed a + fast-forward to the default branch, created, force-pushed and deleted + another branch, force-pushed the default branch back to an earlier commit + and forward again, created a `repository_dispatch` event (`204`), and + created a release with its tag (`201`) and deleted both. Its pushes to the + default branch started the repository's push workflow. GitHub refused the + deletion of the default branch and a push that changed a file in + `.github/workflows/` (no `workflows` permission), and answered `403` to + opening and to listing pull requests, opening an issue, rerunning a + workflow run, dispatching a workflow and creating a check run; +- with a `pull` grant (`contents: read`) the token cloned; GitHub refused its + push (`403`) and answered `403` to opening an issue and to a + `repository_dispatch` event; +- with no grant the broker refused with `403 team_grant_missing` after its + revoked probe and minted nothing. + +Every token was revoked after its probes, and GitHub then answered `401`. In a +public repository the provider limitation in +[Opening issues in public repositories](#opening-issues-in-public-repositories) +applies to these tokens too; it was observed for every permission set tested, +`contents: read` among them, and was not tested again for this ceiling. + +Offline `policy check` prints both sets and a `CEILING` column: + +```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 members=read pull_requests=write +read tier (pull/triage grant) asks for: actions=read checks=read contents=read issues=write members=read pull_requests=read +contents ceiling (push/maintain/admin grant of a CEILING contents Workspace) asks for: contents=write +contents ceiling (pull/triage grant of a CEILING contents Workspace) asks for: contents=read +WORKSPACE TEAM_ID TEAM_SLUG CEILING +alpha-team 4001 alpha-team - +example-apps-host 4005 apps-host contents +``` + +With `--live` the rows of such a Workspace keep the tier of each grant, so +there `ACCESS` `write` means `contents: write` and `read` means +`contents: read`; on the `conglomerate_hosts` repository the row shows the +live `ROLE` next to `read`. + +**Rollout.** Brokers `0.12.0` to `0.15.x` refuse a policy with +`permission_ceiling: "contents"` as a whole (the Worker answers every request +with `503 configuration_unavailable`; the Node broker does not start), and a +broker before `0.12.0` ignores the key and mints the uncapped tiers. Deploy +such a policy only with broker `0.16.0` or later; one Worker upload can carry +both. A Workspace that moves from `"read"` to `"contents"` keeps reading every +repository its Team may read and starts writing contents wherever its Team +holds `push`, `maintain` or `admin`, so review its live grants with +`policy check --live` before the deploy. Never roll such a Workspace's broker +back below `0.16.0` while its credential is in use: change its ceiling to +`"read"` or remove the Workspace first. Deleting only the key gives its Team's +write grants the whole write tier. + ### API `POST /v1/token` accepts exactly one of two bodies: @@ -521,6 +680,8 @@ requests are: `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 read ceiling selects the read tier for every grant, and so does a + contents ceiling on the policy's `conglomerate_hosts` repository. 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). GitHub answers @@ -531,8 +692,9 @@ requests are: 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. +permission set of the tier that step selected within the Workspace's +permission ceiling. 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 @@ -630,8 +792,9 @@ WORKSPACE TEAM_ID TEAM_SLUG alpha-team 4001 alpha-team ``` -A policy with a [read permission ceiling](#read-only-workspaces-permission_ceiling) -adds that set and a `CEILING` column. A policy with +A policy with a [read](#read-only-workspaces-permission_ceiling) or +[contents](#contents-only-workspaces-permission_ceiling-contents) permission +ceiling adds its sets and a `CEILING` column. A policy with [OIDC purposes](#the-oidc-purpose-table) adds them after the table, one line per rule with the exact set it asks for (a policy without Workspaces prints `no Workspaces` instead of the table): @@ -1236,8 +1399,10 @@ first two live and refuses otherwise (`deployment_repository_writer_not_approved `key_ref_protection: "ruleset"` requires on a paid plan (`key_ref_unprotected`); on a plan without rulesets for private repositories (`"none"`) it is the Organization's accepted precondition. Tokens of this App -that can write the repository already carry `members: read` (the parser -refuses an OIDC purpose that could write it without). A key document such a +that can write the repository already carry `members: read` (a Workspace with +the contents ceiling, whose write set does not, mints only `contents: read` +there, and the parser refuses an OIDC purpose that could write it without). A +key document such a token wrote keeps minting after the token or grant ends: the broker checks the current writers, never who wrote a document. That durable access is accepted with its boundary: the document mints until it is removed or replaced, which diff --git a/package-lock.json b/package-lock.json index e81027e..178fb57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lazurio/github-app-broker", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lazurio/github-app-broker", - "version": "0.15.0", + "version": "0.16.0", "license": "Apache-2.0", "dependencies": { "jose": "6.2.12" diff --git a/package.json b/package.json index d5fb283..a80811a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lazurio/github-app-broker", - "version": "0.15.0", + "version": "0.16.0", "private": true, "license": "Apache-2.0", "repository": { diff --git a/src/broker.mjs b/src/broker.mjs index 293f55e..7bcd639 100644 --- a/src/broker.mjs +++ b/src/broker.mjs @@ -5,6 +5,7 @@ import fs from "node:fs"; import http from "node:http"; import { + CONTENTS_CEILING, MAX_BODY_BYTES, READ_ACCESS, READ_CEILING, @@ -203,17 +204,20 @@ function formatHostsSummary(hosts) { * 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. A policy whose Workspaces declare no * permission ceiling prints exactly the two tier lines; a read ceiling adds the one set every - * grant of such a Workspace mints and a CEILING column. OIDC purposes, when declared, follow. + * grant of such a Workspace mints, a contents ceiling the two sets its write and read grants mint, + * and either adds a CEILING column. OIDC purposes, when declared, follow. */ export function formatPolicySummary(policy) { const uncapped = policy.workspaces.find((workspace) => workspace.permission_ceiling === undefined); const capped = policy.workspaces.find((workspace) => workspace.permission_ceiling === READ_CEILING); - const header = ["WORKSPACE", "TEAM_ID", "TEAM_SLUG", ...(capped ? ["CEILING"] : [])]; + const contentsOnly = policy.workspaces.find((workspace) => workspace.permission_ceiling === CONTENTS_CEILING); + const ceilingColumn = capped !== undefined || contentsOnly !== undefined; + const header = ["WORKSPACE", "TEAM_ID", "TEAM_SLUG", ...(ceilingColumn ? ["CEILING"] : [])]; const rows = policy.workspaces.map((workspace) => [ workspace.id, String(workspace.github_team_id), workspace.github_team_slug ?? "-", - ...(capped ? [workspace.permission_ceiling ?? "-"] : []), + ...(ceilingColumn ? [workspace.permission_ceiling ?? "-"] : []), ]); const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column].length))); const line = (values) => values.map((value, column) => value.padEnd(widths[column])).join(" ").trimEnd(); @@ -228,6 +232,12 @@ export function formatPolicySummary(policy) { ...(capped ? [`read ceiling (any grant of a CEILING read Workspace) asks for: ${formatPermissions(tokenPermissions(policy, READ_ACCESS, capped))}`] : []), + ...(contentsOnly + ? [ + `contents ceiling (push/maintain/admin grant of a CEILING contents Workspace) asks for: ${formatPermissions(tokenPermissions(policy, WRITE_ACCESS, contentsOnly))}`, + `contents ceiling (pull/triage grant of a CEILING contents Workspace) asks for: ${formatPermissions(tokenPermissions(policy, READ_ACCESS, contentsOnly))}`, + ] + : []), ...(rows.length > 0 ? [line(header), ...rows.map(line)] : ["no Workspaces"]), ...formatOidcSummary(policy.oidc), ...formatHostsSummary(policy.conglomerate_hosts), diff --git a/src/core.mjs b/src/core.mjs index 0f0c7c6..535c51f 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -25,7 +25,7 @@ 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.15.0"; +const USER_AGENT = "lazurio-github-app-broker/0.16.0"; /** Upper bound for `policy check --live` Team repository pages (100 repositories per page). */ const MAX_TEAM_REPOSITORY_PAGES = 100; const MAX_HOST_ADMISSION_PAGES = 10; @@ -65,9 +65,28 @@ const READ_TOKEN_PERMISSIONS = Object.freeze({ pull_requests: "read", }); -/** The only `workspaces[].permission_ceiling` value: every token of the Workspace is read-only. */ +/** `workspaces[].permission_ceiling: "read"`: every token of the Workspace is read-only. */ export const READ_CEILING = "read"; +/** + * `workspaces[].permission_ceiling: "contents"` (since 0.16.0): every token of the Workspace + * carries repository contents only, at the level the Team gate selects for its live grant (see + * grantAccess for the one repository where that is always read). It is meant for a + * Workspace that reads and writes nothing but repository contents, such as a production + * applications host. + */ +export const CONTENTS_CEILING = "contents"; + +const PERMISSION_CEILINGS = Object.freeze([READ_CEILING, CONTENTS_CEILING]); + +/** + * The two exact sets of a contents-ceiling Workspace (plus GitHub's automatic `metadata: read`). + * No opt-in reaches them, so such a token never carries an Actions, Checks, issue, membership, + * pull request or workflow permission. + */ +const CONTENTS_WRITE_TOKEN_PERMISSIONS = Object.freeze({ contents: "write" }); +const CONTENTS_READ_TOKEN_PERMISSIONS = Object.freeze({ contents: "read" }); + const PERMISSION_LEVELS = Object.freeze({ read: 1, write: 2 }); /** @@ -84,13 +103,22 @@ const PERMISSION_LEVELS = Object.freeze({ read: 1, write: 2 }); * write of any kind. A write tier for such a Workspace is refused, never silently lowered here; the * Team gate already selects the read tier for it. Any set that asks for a permission or level the * installation has not accepted is refused before GitHub is asked. + * + * A Workspace with `permission_ceiling: "contents"` keeps the tier the Team gate selects for its + * grant (see grantAccess) and asks for repository contents only, at that level: `contents: write` + * for the write tier and `contents: read` for the read tier. Neither opt-in reaches it, whatever + * the installation accepted. */ export function tokenPermissions(policy, access, workspace) { if (!policy.workspaces.includes(workspace)) fail("token permissions need a Workspace of the policy"); const accepted = policy.installation_permissions; const issues = accepted.issues === "write" ? { issues: "write" } : {}; let permissions; - if (access === WRITE_ACCESS) { + if (workspace.permission_ceiling === CONTENTS_CEILING) { + if (access === WRITE_ACCESS) permissions = { ...CONTENTS_WRITE_TOKEN_PERMISSIONS }; + else if (access === READ_ACCESS) permissions = { ...CONTENTS_READ_TOKEN_PERMISSIONS }; + else fail("token access tier must be read or write"); + } else if (access === WRITE_ACCESS) { if (workspace.permission_ceiling === READ_CEILING) { fail(`Workspace ${workspace.id} permission ceiling is read; it never mints the write tier`); } @@ -113,13 +141,24 @@ export function tokenPermissions(policy, access, workspace) { return Object.freeze(permissions); } +/** Whether `repositoryId` is the policy's host key repository (`conglomerate_hosts`, host.mjs). */ +function isHostKeyRepository(policy, repositoryId) { + return policy.conglomerate_hosts !== undefined && policy.conglomerate_hosts.repository_id === repositoryId; +} + /** - * Maps a live Team grant role to the access tier it mints for `workspace`; undefined means no usable - * grant. A Workspace with a read permission ceiling gets the read tier for every usable grant, so a - * Team that is later given push still mints nothing but reads. + * Maps a live Team grant role on `repositoryId` to the access tier it mints for `workspace` of + * `policy`; undefined means no usable grant. A Workspace with a read permission ceiling gets the read + * tier for every usable grant, so a Team that is later given push still mints nothing but reads. A + * contents ceiling keeps the tier of the grant: it narrows the permission set (see tokenPermissions), + * never the level, except on the policy's host key repository. A key document written there obtains + * a host token with `members: read`, so every token that can write it must carry `members: read` + * (host.mjs); the contents ceiling's write set never does, so there every grant mints the read tier. */ -function grantAccess(role, workspace) { - const ceiling = workspace.permission_ceiling === READ_CEILING; +function grantAccess(role, workspace, policy, repositoryId) { + const ceiling = + workspace.permission_ceiling === READ_CEILING || + (workspace.permission_ceiling === CONTENTS_CEILING && isHostKeyRepository(policy, repositoryId)); if (role === "admin" || role === "maintain" || role === "write") return ceiling ? READ_ACCESS : WRITE_ACCESS; if (role === "triage" || role === "read") return READ_ACCESS; return undefined; @@ -641,8 +680,8 @@ export function parsePolicy(raw) { } // Optional and absent by default: a Workspace without it mints exactly what its live grant selects. const ceiling = workspace?.permission_ceiling; - if (ceiling !== undefined && ceiling !== READ_CEILING) { - fail(`workspaces[${index}].permission_ceiling must be "${READ_CEILING}" when present`); + if (ceiling !== undefined && !PERMISSION_CEILINGS.includes(ceiling)) { + fail(`workspaces[${index}].permission_ceiling must be "${READ_CEILING}" or "${CONTENTS_CEILING}" when present`); } return Object.freeze({ id, @@ -960,7 +999,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, workspace); + const access = grantAccess(role, workspace, policy, grant?.id); const returned = parseRepositoryCoordinate(grant?.full_name); if ( grant?.owner?.id !== policy.github_owner.id || @@ -1041,7 +1080,7 @@ 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, workspace); + const access = grantAccess(role, workspace, policy, repository?.id); const coordinate = parseRepositoryCoordinate(repository?.full_name); if ( repository?.owner?.id !== policy.github_owner.id || @@ -1136,6 +1175,16 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () async function mintToken(policy, workspace, repositoryId, access) { const requested = tokenPermissions(policy, access, workspace); + // The Team gate already selects the read tier there for a contents-ceiling Workspace; a gate + // that lost that rule fails closed here instead of minting such a write (see grantAccess). + if ( + isHostKeyRepository(policy, repositoryId) && + requested.contents === "write" && + requested.members !== "read" && + requested.members !== "write" + ) { + fail(`Workspace ${workspace.id} never writes the host key repository without members: read`); + } const result = await mintScopedToken(policy, repositoryId, requested); return { token: result.token, expires_at: result.expires_at, repository_id: repositoryId, access }; } diff --git a/src/host.mjs b/src/host.mjs index 746d400..a700f98 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -206,7 +206,9 @@ export function parseConglomerateHosts(input, installationPermissions) { * Every token this App mints that can write the host key repository must already carry * `members: read`, so an App-mediated writer holds that read while its token lasts anyway. A key * document it writes still keeps minting after the token or grant ends (accepted durable access, - * see ARCHITECTURE.md). A Workspace's write tier always carries it; this refuses a policy + * see ARCHITECTURE.md). A Workspace's write tier always carries it, and a Workspace with the + * contents ceiling, whose write set does not, mints only the read tier on that repository (core.mjs + * grantAccess); this refuses a policy * with an OIDC purpose that can mint `contents: write` on that repository without it. A purpose can * reach the repository when it names it, or serves the caller's own repository and that repository * is a listed Deployment Repo of the policy's Organization that the purpose admits. diff --git a/test/broker.test.mjs b/test/broker.test.mjs index f1bf457..893b7ba 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -1854,7 +1854,7 @@ test("policy accepts an optional read permission ceiling per Workspace and rejec for (const value of ["write", "none", "READ", "", null, true, 0, { issues: "read" }]) { const fixture = policyFixture(); fixture.workspaces[1].permission_ceiling = value; - assert.throws(() => parsePolicy(fixture), /workspaces\[1\]\.permission_ceiling must be "read" when present/, JSON.stringify(value)); + assert.throws(() => parsePolicy(fixture), /workspaces\[1\]\.permission_ceiling must be "read" or "contents" when present/, JSON.stringify(value)); } }); @@ -2071,3 +2071,368 @@ test("policy check prints the read-ceiling set and a CEILING column, and live ro assert.deepEqual(rows.map(({ repository_id, role, access }) => [repository_id, role, access]), [[3001, "write", "read"], [3004, "read", "read"]]); assert.match(formatPolicyCheckTable(rows), /2 repository grants \(0 write, 2 read\)/); }); + +/** + * alpha-team declares the contents permission ceiling unless `contentsWorkspace` says otherwise; + * `readWorkspace` adds a read ceiling to the other Workspace. The installation accepted every + * opt-in an Organization may hold (Issues, workflows, administration and the Organization plan), + * so a test can tell that none of them reaches a contents-ceiling token. + */ +function contentsPolicyFixture({ contentsWorkspace = 0, readWorkspace } = {}) { + const fixture = policyFixture(); + fixture.installation_permissions = { + ...fixture.installation_permissions, + administration: "read", + issues: "write", + organization_plan: "read", + workflows: "write", + }; + fixture.workspaces[contentsWorkspace].permission_ceiling = "contents"; + if (readWorkspace !== undefined) fixture.workspaces[readWorkspace].permission_ceiling = "read"; + return fixture; +} + +const CONTENTS_WRITE_SET = { contents: "write" }; +const CONTENTS_READ_SET = { contents: "read" }; + +test("policy accepts a contents permission ceiling next to a read one and rejects every other value", () => { + const parsed = parsePolicy(contentsPolicyFixture({ readWorkspace: 1 })); + assert.equal(parsed.workspaces[0].permission_ceiling, "contents"); + assert.equal(parsed.workspaces[1].permission_ceiling, "read"); + for (const value of ["CONTENTS", "Contents", "contents ", "contents:write", "metadata", ["contents"], { contents: "write" }]) { + const fixture = policyFixture(); + fixture.workspaces[0].permission_ceiling = value; + assert.throws( + () => parsePolicy(fixture), + /workspaces\[0\]\.permission_ceiling must be "read" or "contents" when present/, + JSON.stringify(value), + ); + } +}); + +test("a contents ceiling asks for exactly contents: write or contents: read, whatever the installation accepted", () => { + const policy = parsePolicy(contentsPolicyFixture()); + const [alpha, beta] = policy.workspaces; + assert.deepEqual(tokenPermissions(policy, "write", alpha), CONTENTS_WRITE_SET); + assert.deepEqual(tokenPermissions(policy, "read", alpha), CONTENTS_READ_SET); + // The Workspace without a ceiling in the same policy keeps both tiers with every declared opt-in. + assert.deepEqual(tokenPermissions(policy, "write", beta), { ...WRITE_SET, issues: "write", workflows: "write" }); + assert.deepEqual(tokenPermissions(policy, "read", beta), { ...READ_SET, issues: "write" }); + // There is no default tier, and a copy of the Workspace, with or without its ceiling, is not a Workspace of the policy. + for (const access of [undefined, "", "admin", "contents", "WRITE"]) { + assert.throws(() => tokenPermissions(policy, access, alpha), /access tier must be read or write/, String(access)); + } + for (const outsider of [undefined, { ...alpha }, { ...alpha, permission_ceiling: undefined }, structuredClone(beta)]) { + assert.throws(() => tokenPermissions(policy, "write", outsider), /need a Workspace of the policy/); + } +}); + +test("a contents-ceiling mint asks GitHub for exactly that set and revokes and refuses every other answer", async () => { + const now = 1_700_000_000_000; + const requests = []; + const revocations = []; + let responsePermissions; + let responseRepositories = [{ id: 3001 }]; + const github = createGithubClient({ + appId: "42", + privateKey: testPrivateKey(), + fetchImpl: async (url, init) => { + if (new URL(url).pathname === "/installation/token" && init.method === "DELETE") { + revocations.push(init.headers.Authorization); + return jsonResponse(null, 204); + } + requests.push(JSON.parse(init.body)); + return jsonResponse({ + token: `ghs_contents_synthetic_${requests.length}`, + expires_at: new Date(now + 60 * 60 * 1000).toISOString(), + repositories: responseRepositories, + permissions: responsePermissions, + }); + }, + now: () => now, + }); + const policy = parsePolicy(contentsPolicyFixture()); + const alpha = policy.workspaces[0]; + + for (const [access, set] of [["write", CONTENTS_WRITE_SET], ["read", CONTENTS_READ_SET]]) { + // GitHub's own answer: the set plus its automatic metadata: read. + responsePermissions = { ...set, metadata: "read" }; + const minted = await github.mintToken(policy, alpha, 3001, access); + assert.deepEqual( + [minted.token, minted.repository_id, minted.access], + [`ghs_contents_synthetic_${requests.length}`, 3001, access], + ); + assert.deepEqual(requests.at(-1), { repository_ids: [3001], permissions: set }); + } + assert.deepEqual(revocations, [], "a token inside its scope is delivered, not revoked"); + + // Any extra permission, another level, a missing contents or another repository is revoked with + // its own token and refused. + const refused = [ + ["write", { contents: "write", metadata: "read", issues: "write" }], + ["write", { contents: "write", metadata: "read", pull_requests: "write" }], + ["write", { contents: "write", metadata: "read", actions: "write" }], + ["write", { contents: "write", metadata: "read", checks: "read" }], + ["write", { contents: "write", metadata: "read", members: "read" }], + ["write", { contents: "write", metadata: "read", workflows: "write" }], + ["write", { contents: "write", metadata: "read", administration: "read" }], + ["write", { contents: "write", metadata: "write" }], + ["write", { contents: "read", metadata: "read" }], + ["write", { metadata: "read" }], + ["read", { contents: "write", metadata: "read" }], + ["read", { contents: "read", metadata: "read", issues: "read" }], + ["read", { contents: "read", metadata: "read", pull_requests: "read" }], + ["read", { contents: "read", metadata: "read", actions: "read" }], + ["read", {}], + ]; + for (const [access, permissions] of refused) { + responsePermissions = permissions; + await assert.rejects( + () => github.mintToken(policy, alpha, 3001, access), + /outside the requested repository or permission scope/, + `${access} ${JSON.stringify(permissions)}`, + ); + assert.equal(revocations.at(-1), `Bearer ghs_contents_synthetic_${requests.length}`, JSON.stringify(permissions)); + } + responsePermissions = { ...CONTENTS_WRITE_SET, metadata: "read" }; + responseRepositories = [{ id: 3002 }]; + await assert.rejects(() => github.mintToken(policy, alpha, 3001, "write"), /outside the requested repository or permission scope/); + assert.equal(revocations.length, refused.length + 1); + assert.equal(new Set(revocations).size, revocations.length, "each refused token is revoked with its own credential"); + // Every mint asked for exactly one of the two contents sets. + assert.deepEqual( + [...new Set(requests.map(({ permissions }) => JSON.stringify(permissions)))].sort(), + [JSON.stringify(CONTENTS_READ_SET), JSON.stringify(CONTENTS_WRITE_SET)], + ); +}); + +test("the Team gate keeps every grant's tier for a contents-ceiling Workspace and still refuses a missing grant", async () => { + const policy = parsePolicy(contentsPolicyFixture()); + const privateKey = testPrivateKey(); + 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"], + ]) { + for (const target of [{ repository: "example-org/alpha" }, { repository_id: 3001 }]) { + const github = createGithubClient({ + appId: "42", + privateKey, + fetchImpl: teamGrantFetch({ + 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], [role, access], role); + } + } + // No grant is still refused; the ceiling never creates access. + const none = createGithubClient({ appId: "42", privateKey, fetchImpl: teamGrantFetch({ grant: null }) }); + await assert.rejects( + () => none.verifyTeamGrant(policy, policy.workspaces[0], { repository: "example-org/alpha" }), + (error) => error.code === "team_grant_missing", + ); +}); + +test("end to end: a contents-ceiling Workspace mints contents only for push and read grants; other Workspaces, repositories and owners mint nothing", async () => { + const grantOn = (permissions) => teamGrantFetch({ + grant: { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" }, permissions }, + }); + for (const [permissions, access, set] of [ + [{ admin: false, maintain: false, pull: true, push: true, triage: true }, "write", CONTENTS_WRITE_SET], + [{ admin: false, maintain: false, pull: true, push: false, triage: false }, "read", CONTENTS_READ_SET], + ]) { + const scopedMints = []; + await withLiveCoreServer(mintingFetch([], grantOn(permissions), 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, access); + const json = await response.json(); + assert.deepEqual([json.access, json.repository_id, json.repository], [access, 3001, "example-org/alpha"]); + } + assert.deepEqual(scopedMints, [set, set]); + // Cross-Workspace: the other Team holds nothing on alpha. + 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" }); + // Cross-repository and foreign owner are refused exactly as without a ceiling. + const crossRepository = await request(origin, { body: { repository: "example-org/beta" } }); + assert.equal(crossRepository.status, 403); + assert.deepEqual(await crossRepository.json(), { error: "team_grant_missing" }); + const foreign = await request(origin, { body: { repository: "other-org/alpha" } }); + assert.equal(foreign.status, 403); + assert.deepEqual(await foreign.json(), { error: "repository_denied" }); + assert.equal(scopedMints.length, 2); + }, parsePolicy(contentsPolicyFixture())); + } + + 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, []); + }, parsePolicy(contentsPolicyFixture())); + + // Next to a contents-ceiling Workspace, a Workspace without a ceiling still mints its whole write + // tier and a read-ceiling Workspace still mints only reads. + for (const [ceiling, access, set] of [ + [undefined, "write", { ...WRITE_SET, issues: "write", workflows: "write" }], + ["read", "read", { ...READ_SET, issues: "read" }], + ]) { + const fixture = contentsPolicyFixture({ contentsWorkspace: 1 }); + if (ceiling !== undefined) fixture.workspaces[0].permission_ceiling = ceiling; + const mints = []; + await withLiveCoreServer(mintingFetch([], teamGrantFetch(), mints), async (origin) => { + const response = await request(origin); + assert.equal(response.status, 200); + assert.equal((await response.json()).access, access); + assert.deepEqual(mints, [set]); + }, parsePolicy(fixture)); + } +}); + +test("policy check prints the contents-ceiling sets and a CEILING column, and live rows keep the grant's tier", async () => { + assert.equal( + formatPolicySummary(parsePolicy(contentsPolicyFixture())), + [ + "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 members=read pull_requests=write workflows=write", + "read tier (pull/triage grant) asks for: actions=read checks=read contents=read issues=write members=read pull_requests=read", + "contents ceiling (push/maintain/admin grant of a CEILING contents Workspace) asks for: contents=write", + "contents ceiling (pull/triage grant of a CEILING contents Workspace) asks for: contents=read", + "WORKSPACE TEAM_ID TEAM_SLUG CEILING", + "alpha-team 4001 alpha-team contents", + "beta-team 4002 - -", + "", + ].join("\n"), + ); + // Next to a read ceiling both ceilings print their sets; with no uncapped Workspace the two tier lines go. + assert.equal( + formatPolicySummary(parsePolicy(contentsPolicyFixture({ readWorkspace: 1 }))), + [ + "lazurio.github_app_broker.policy.v3 owner=example-org installation=2001", + "read ceiling (any grant of a CEILING read Workspace) asks for: actions=read checks=read contents=read issues=read members=read pull_requests=read", + "contents ceiling (push/maintain/admin grant of a CEILING contents Workspace) asks for: contents=write", + "contents ceiling (pull/triage grant of a CEILING contents Workspace) asks for: contents=read", + "WORKSPACE TEAM_ID TEAM_SLUG CEILING", + "alpha-team 4001 alpha-team contents", + "beta-team 4002 - read", + "", + ].join("\n"), + ); + + // Live rows keep the tier of the grant: under the contents ceiling `write` means contents: write. + const policy = parsePolicy(contentsPolicyFixture()); + const page = [ + { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" }, permissions: { pull: true, push: true } }, + { id: 3004, full_name: "example-org/handbook", owner: { id: 1001, login: "example-org" }, permissions: { pull: true } }, + ]; + const github = createGithubClient({ + appId: "42", + privateKey: testPrivateKey(), + fetchImpl: async (url, init) => { + const path = new URL(url).pathname + new URL(url).search; + if (path === "/organizations/1001/team/4001/repos?per_page=100&page=1") return jsonResponse(page); + return teamGrantFetch()(url, init); + }, + }); + const rows = await github.readWorkspaceTeamGrants(policy, policy.workspaces[0]); + assert.equal( + formatPolicyCheckTable(rows), + [ + "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 3004 example-org/handbook read read ok", + "1 Workspaces checked, 1 Teams ok, 0 refused; 2 repository grants (1 write, 1 read)", + "", + ].join("\n"), + ); +}); + +/** contentsPolicyFixture whose host key repository (`conglomerate_hosts`) is alpha unless `repositoryId` says otherwise. */ +function contentsHostsPolicyFixture({ repositoryId = 3001, contentsWorkspace = 0 } = {}) { + return { + ...contentsPolicyFixture({ contentsWorkspace }), + conglomerate_hosts: { + audience: "https://github-broker.example.com", + repository_id: repositoryId, + ref: "main", + directory: "access/conglomerate-hosts", + approved_writers: [], + key_ref_protection: "none", + }, + }; +} + +test("a contents-ceiling Workspace mints only the read tier on the host key repository, whatever its grant", async () => { + const privateKey = testPrivateKey(); + const pushGrant = teamGrantFetch(); + const onKeys = parsePolicy(contentsHostsPolicyFixture()); + const uncapped = parsePolicy(contentsHostsPolicyFixture({ contentsWorkspace: 1 })); + const elsewhere = parsePolicy(contentsHostsPolicyFixture({ repositoryId: 3999 })); + const gate = createGithubClient({ appId: "42", privateKey, fetchImpl: pushGrant }); + + // A key document written there obtains members: read, which the contents write set never carries. + for (const target of [{ repository: "example-org/alpha" }, { repository_id: 3001 }]) { + const grant = await gate.verifyTeamGrant(onKeys, onKeys.workspaces[0], target); + assert.deepEqual([grant.role, grant.access], ["write", "read"]); + } + // The write tier, which carries members: read, keeps writing it; elsewhere the contents ceiling writes. + assert.equal((await gate.verifyTeamGrant(uncapped, uncapped.workspaces[0], { repository: "example-org/alpha" })).access, "write"); + assert.equal((await gate.verifyTeamGrant(elsewhere, elsewhere.workspaces[0], { repository: "example-org/alpha" })).access, "write"); + + // A gate that lost the rule fails closed before GitHub is asked; the other mints are unchanged. + const requests = []; + const github = createGithubClient({ + appId: "42", + privateKey, + fetchImpl: async (_url, init) => { + const { permissions } = JSON.parse(init.body); + requests.push(permissions); + return jsonResponse({ + token: "ghs_scoped_synthetic_token", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + repositories: [{ id: 3001 }], + permissions: { ...permissions, metadata: "read" }, + }); + }, + }); + await assert.rejects( + () => github.mintToken(onKeys, onKeys.workspaces[0], 3001, "write"), + /never writes the host key repository without members: read/, + ); + assert.deepEqual(requests, []); + assert.equal((await github.mintToken(onKeys, onKeys.workspaces[0], 3001, "read")).access, "read"); + assert.equal((await github.mintToken(uncapped, uncapped.workspaces[0], 3001, "write")).access, "write"); + assert.equal((await github.mintToken(elsewhere, elsewhere.workspaces[0], 3001, "write")).access, "write"); + assert.deepEqual(requests, [CONTENTS_READ_SET, { ...WRITE_SET, issues: "write", workflows: "write" }, CONTENTS_WRITE_SET]); + + // End to end, a push grant on the host key repository mints contents: read. + const scopedMints = []; + await withLiveCoreServer(mintingFetch([], pushGrant, scopedMints), async (origin) => { + const response = await request(origin); + assert.equal(response.status, 200); + assert.equal((await response.json()).access, "read"); + assert.deepEqual(scopedMints, [CONTENTS_READ_SET]); + }, onKeys); + + // policy check --live shows the live role next to the read tier on that repository only. + const rows = await createGithubClient({ + appId: "42", + privateKey, + fetchImpl: async (url, init) => { + const path = new URL(url).pathname + new URL(url).search; + if (path === "/organizations/1001/team/4001/repos?per_page=100&page=1") { + return jsonResponse([ + { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" }, permissions: { pull: true, push: true } }, + { id: 3004, full_name: "example-org/handbook", owner: { id: 1001, login: "example-org" }, permissions: { pull: true, push: true } }, + ]); + } + return pushGrant(url, init); + }, + }).readWorkspaceTeamGrants(onKeys, onKeys.workspaces[0]); + assert.deepEqual(rows.map(({ repository_id, role, access }) => [repository_id, role, access]), [[3001, "write", "read"], [3004, "write", "write"]]); +}); diff --git a/test/worker.test.mjs b/test/worker.test.mjs index bf1762f..94e47a3 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -517,3 +517,88 @@ test("Worker with the real core: a read-ceiling Workspace mints only reads; the assert.equal(invalid.status, 503); assert.deepEqual(await invalid.json(), { error: "configuration_unavailable" }); }); + +test("Worker with the real core: a contents-ceiling Workspace mints contents only; the other Workspace keeps its tiers", async () => { + const fixture = policyFixture(); + fixture.installation_permissions = { + ...fixture.installation_permissions, + administration: "read", + issues: "write", + organization_plan: "read", + workflows: "write", + }; + fixture.workspaces[0].permission_ceiling = "contents"; + // alpha's Team holds push on alpha and pull on handbook; beta's Team holds push on alpha. + const grants = { + "4001/alpha": { id: 3001, full_name: "example-org/alpha", permissions: { pull: true, push: true } }, + "4001/handbook": { id: 3004, full_name: "example-org/handbook", permissions: { pull: true } }, + "4002/alpha": { id: 3001, full_name: "example-org/alpha", permissions: { pull: true, push: true } }, + }; + const scopedMints = []; + const github = async (url, init) => { + const path = new URL(url).pathname; + const body = init.body ? JSON.parse(init.body) : undefined; + if (path === "/app/installations/2001" && init.method === "GET") { + return Response.json({ + app_id: 42, + app_slug: "example-app", + account: { id: 1001, login: "example-org" }, + target_type: "Organization", + repository_selection: "selected", + permissions: fixture.installation_permissions, + }); + } + if (path === "/app/installations/2001/access_tokens" && !body.repository_ids) return Response.json({ token: "ghs_probe" }); + if (path === "/app/installations/2001/access_tokens") { + scopedMints.push(body.permissions); + return Response.json({ + token: "ghs_scoped_synthetic", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + repositories: [{ id: body.repository_ids[0] }], + permissions: { ...body.permissions, metadata: "read" }, + }); + } + const team = path.match(/^\/organizations\/1001\/team\/(4001|4002)$/); + if (team) return Response.json({ id: Number(team[1]), slug: `team-${team[1]}`, organization: { id: 1001, login: "example-org" } }); + const grant = path.match(/^\/organizations\/1001\/team\/(400[12])\/repos\/example-org\/([a-z]+)$/); + if (grant) { + const entry = grants[`${grant[1]}/${grant[2]}`]; + return entry + ? Response.json({ ...entry, owner: { id: 1001, login: "example-org" } }) + : Response.json({ message: "Not Found" }, { status: 404 }); + } + if (path === "/installation/token" && init.method === "DELETE") return new Response(null, { status: 204 }); + throw new Error(`unexpected request ${init.method} ${path}`); + }; + const worker = createWorkerEntrypoint({ + createGithub: ({ appId }) => createGithubClient({ appId, signJwt: async () => "c2lnbmF0dXJl", fetchImpl: github }), + }); + const env = environment({ BROKER_POLICY_JSON: JSON.stringify(fixture) }); + + const pushGrant = await worker.fetch(tokenRequest(), env); + assert.equal(pushGrant.status, 200); + assert.equal((await pushGrant.json()).access, "write"); + const readGrant = await worker.fetch(tokenRequest({ body: { repository: "example-org/handbook" } }), env); + assert.equal(readGrant.status, 200); + assert.equal((await readGrant.json()).access, "read"); + const uncapped = await worker.fetch(tokenRequest({ workspace: "beta-team", credential: BETA_CREDENTIAL }), env); + assert.equal(uncapped.status, 200); + assert.equal((await uncapped.json()).access, "write"); + assert.deepEqual(scopedMints, [ + { contents: "write" }, + { contents: "read" }, + { actions: "write", checks: "read", contents: "write", issues: "write", members: "read", pull_requests: "write", workflows: "write" }, + ]); + + // Cross-repository: no grant of alpha's Team, no mint. + const noGrant = await worker.fetch(tokenRequest({ body: { repository: "example-org/gamma" } }), env); + assert.equal(noGrant.status, 403); + assert.deepEqual(await noGrant.json(), { error: "team_grant_missing" }); + assert.equal(scopedMints.length, 3); + + // A miscased or unknown ceiling is a configuration error, never an ignored key. + fixture.workspaces[0].permission_ceiling = "Contents"; + const invalid = await worker.fetch(tokenRequest(), environment({ BROKER_POLICY_JSON: JSON.stringify(fixture) })); + assert.equal(invalid.status, 503); + assert.deepEqual(await invalid.json(), { error: "configuration_unavailable" }); +});