From 74d083e4b35d17ae834704c146c5e9b94b3b3705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 3 Oct 2026 12:57:11 +0200 Subject: [PATCH 1/3] Broker scope from live Team grants: policy v3 drops the repository allowlist (#14) The live GitHub Team grant is now the whole scope of a Workspace. Policy schema v3 removes `repositories` and `workspaces[].repository_ids`; v2 and v1 are rejected with a migration message, and a half-migrated v3 policy that still carries either key fails closed. - The installation gate keeps identity, target, selection mode and exact permissions and no longer enumerates installation repositories. - `POST /v1/token` accepts exactly one of `{"repository": "Owner/name"}` or `{"repository_id": n}` (kept for deployed 0.9.0 adapters); anything else is `400 invalid_request`. A name of another owner is `403 repository_denied` without GitHub traffic; the id form resolves the live coordinate first and refuses another owner the same way. - The Team gate mints for the repository id GitHub returns from the live grant and requires the Organization owner id and a push/maintain/admin grant. The response adds the canonical `repository` name. - `policy check --live` verifies each Team and lists its live write-capable repositories as information; it fails only on Team or installation drift. - The adapter drops the client-side repository policy: it sends the name, accepts only a response for the same repository and has no default repository outside a checkout. - Version 0.10.0; README, ARCHITECTURE and AGENTS invariants updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 18 +- ARCHITECTURE.md | 136 +++--- README.md | 294 ++++++++----- adapter/broker-client.mjs | 65 +-- adapter/brokered-gh.mjs | 7 +- examples/policy.example.json | 11 +- package-lock.json | 4 +- package.json | 2 +- src/broker.mjs | 3 +- src/core.mjs | 369 ++++++++++------ src/worker.mjs | 6 +- test/broker.test.mjs | 809 +++++++++++++++++++++++------------ test/brokered-gh.test.mjs | 111 +++-- test/worker.test.mjs | 108 +++-- 14 files changed, 1233 insertions(+), 710 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e7ea71..faea018 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,13 @@ dependency-light and customer-neutral. ## Invariants -1. GitHub is the only authority for installation repositories and permissions. -2. Authorization uses immutable Workspace and GitHub repository ids. Provider - names may be asserted but never select a different object after a rename. +1. GitHub is the only authority for installation repositories, Team + repository grants and permissions. +2. Authorization uses immutable keys: the Workspace id, the GitHub Team id, + the GitHub Organization id and the repository id GitHub returns from the + live Team grant. Repository and Team names are resolved live on every + 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 @@ -19,12 +23,14 @@ dependency-light and customer-neutral. 6. The broker authorizes a Workspace and repository, never a browser user. 7. No customer-specific policy, ids or credentials belong in this public repo. 8. Every behavior change requires focused tests for the positive path and - cross-Workspace / cross-repository denial. + 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`. - Nothing from that readback is cached; the policy, the Dashboard and any - derived mapping are reviewed inputs, never a second ACL. + 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. `actions: write` is GitHub's smallest installation-token permission that can rerun a workflow. GitHub does not provide a rerun-only token capability, so diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0fc9788..e775cbd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,10 +6,21 @@ The broker holds one GitHub App private key and mints installation tokens for predeclared Team Workspaces. Its deployment-owned policy binds: - one immutable GitHub Organization id and asserted login; -- one installation id and the exact accepted permission set; -- the exact selected repository ids and asserted full names; and -- each immutable Workspace id to exactly one immutable GitHub Team id, a - separate deployment credential reference and a repository-id allowlist. +- one installation id, its repository selection mode and the exact accepted + permission set; and +- 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 +broker policy change or redeploy. Credential references and credential values must be unique across all Workspaces. The Node adapter proves file-path uniqueness and snapshots all @@ -19,16 +30,14 @@ A duplicated or aliased secret fails the whole issuance path closed instead of enabling cross-Workspace impersonation. Before minting a token, the active runtime verifies the live installation -owner, target type, selected-repository mode, exact permissions and exact -repository set. The Node adapter performs this gate before listening; the -Worker performs it after local authorization on every valid token request. -Every token request then asks GitHub to mint a new one-repository token with -exactly `actions: write`, +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; removal of a live -repository grant therefore fails the next issuance without waiting for a local -cache. +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. @@ -37,8 +46,8 @@ render check runs and their workflow-run context and explicitly rerun a failed workflow without a synthetic commit or human credential. GitHub exposes no rerun-only installation permission: `actions: write` also authorizes other Actions mutations in the same repository. The remaining boundaries therefore -stay material: one immutable repository per short-lived token, an exact -Workspace repository allowlist and no Checks write access. +stay material: one immutable repository per short-lived token, a live Team +grant on exactly that repository and no Checks write access. `workflows: write` lets a Workspace push commits that change `.github/workflows/`. Editing a workflow does not by itself run it or hand it @@ -62,46 +71,55 @@ Team or two Workspaces claim the same Team. Team ids are immutable on GitHub; a Team deleted and recreated under the same name has a new id and is a different Team for the broker. -The live GitHub Team membership and Team-to-repository grants are the only -grant authority: the policy never grants anything GitHub has not granted and -never replaces GitHub's ACL. The policy's `workspace.repository_ids` allowlist -is nevertheless a separate, deny-only admission gate that decides which live -grants the broker will use at all: a repository outside the allowlist is -refused before the Team grant is read, even when the Team holds a write grant -on it. On every token request, after the credential and allowlist -checks, the runtime mints a `members: read` plus `metadata: read` probe token, -reads the Team by -`/organizations/{org_id}/team/{team_id}`, checks the Team's grant on the exact -repository with `/organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}` -and the repository-permissions media type, revokes the probe, and only then -mints the one-repository token. A missing Team, an identity that differs from -the policy assertions, a missing grant, a `pull`/`triage`-only grant or a -repository id mismatch is refused with `403 team_grant_missing`; a GitHub -outage, rate-limit or quota response is `502 token_unavailable`. Neither -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 -the only window in which a revoked grant can still act is the lifetime of a -token already issued. +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 what GitHub has granted. 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 +installation gate, the runtime mints a `members: read` plus `metadata: read` +probe token, reads the Team by `/organizations/{org_id}/team/{team_id}`, for +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. + +Names are inputs, never keys. The authorization keys are the immutable +Workspace id, Team id, Organization id and the repository id GitHub returns +from the live grant; a renamed repository is resolved by GitHub on the next +request, and the response tells the client the canonical name GitHub returned. This convergence is paid in GitHub requests, deliberately without a cache: a -successful Team gate is four requests (probe mint, two reads, probe -revocation), so an accepted Node request costs five GitHub requests and an -accepted Worker request nine, because the Worker also repeats the installation -gate. A missing Team refuses early after three requests (probe mint, Team read, -probe revocation) and never reads the repository grant. Rate -limiting therefore degrades issuance to fail-closed refusals rather than to -stale allows; the README documents the per-path budget. +successful Team gate is four requests for the name form (probe mint, Team +read, grant read, probe revocation) and five for the id form, so an accepted +Node request costs five or six GitHub requests and an accepted Worker request +six or seven, because the Worker also repeats the one-request installation +gate. A missing Team refuses early after three requests. Rate limiting +therefore degrades issuance to fail-closed refusals rather than to stale +allows; the README documents the per-path budget. The Node adapter still performs the installation gate once before listening; the Team gate is per request in both adapters because it is the revocation 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` is the same verification run over every Workspace and -every allowlisted repository at once. It is the migration and readback gate an -operator runs before deploying a policy and exits non-zero on any refused -row; the rows never contain a token or credential. +`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. 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 @@ -129,11 +147,10 @@ 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; the reviewed policy then converges by - removing the stale repository id or Workspace. -- Remove a repository id from a Workspace policy to stop its next issuance. - Removing a repository from the global policy also requires removing the - matching live App installation grant because the global set is exact. + policy or restarting the broker. Granting a Team a repository 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 or publish a new Worker secret version. - Keep the prior immutable image or Worker version available for source @@ -156,7 +173,8 @@ The Cloudflare adapter has no filesystem or reliable startup phase. It maps each immutable Workspace id to one independently rotatable Worker secret, validates the complete binding snapshot on each request and performs exact live installation verification immediately before every authorized token mint. -Unknown credentials and denied repositories fail before this provider call. +Unknown credentials, invalid bodies and foreign-owner names fail before this +provider call. This deliberately trades extra GitHub readback latency for convergence without module-state caches. Missing or duplicated secrets fail the whole Worker configuration closed; neither errors nor observability contain secret values. @@ -183,21 +201,23 @@ separate credential for their exact Workspace broker identity. The adapter is a deny-first launcher around the official GitHub CLI, not a 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. Installation tokens exist only in the child +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 environment and are never cached, rendered or written to GitHub CLI config. The official CLI remains responsible for pull requests, REST and GraphQL. T3 Code performs two host-level discovery calls that are not repository API operations. The adapter recognizes only their exact argument envelopes: -- `gh auth status --json hosts` performs a live token proof for the first - deterministic repository in the validated Team policy, discards the token, - and emits the official JSON host shape for `lazurio-for-github[bot]`; +- `gh auth status --json hosts` performs a live token proof for the + repository of the current checkout (or `GH_REPO`), discards the token, and + emits the official JSON host shape for `lazurio-for-github[bot]`; - `gh api user --jq .login` performs the same proof and emits that machine actor without calling GitHub's user endpoint. -A failed proof returns the official unauthenticated host shape and a non-zero -exit. The result is deliberately not cached, so repository or installation +A failed proof, including one run outside any Team repository, returns the +official unauthenticated host shape and a non-zero exit. The result is deliberately not cached, so repository or installation revocation affects the next discovery and operation without restarting the Workspace. This host signal never substitutes for per-repository authorization. diff --git a/README.md b/README.md index d852278..a1f02aa 100644 --- a/README.md +++ b/README.md @@ -26,34 +26,33 @@ with a fresh, one-repository installation token for each operation. The broker makes one machine authorization decision: ```text -Workspace id + Workspace credential + repository id - -> exact deployment policy - -> immutable GitHub App id and slug - -> live GitHub App installation grant - -> live GitHub Team -> repository grant of the Workspace's immutable Team - -> one-repository installation token +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 ``` - It never asks which human last used the Workspace. - It never receives a browser session or OAuth token. - The GitHub App private key exists only in the broker workload. -- A Workspace credential authorizes only that Workspace's configured - repository ids. -- Every Workspace is bound to exactly one immutable GitHub Team id, and every - token requires that Team to hold a live write-capable grant on the exact - repository. GitHub Team membership and repository grants are the only - grant authority; the policy never grants anything GitHub has not granted - and never replaces GitHub's ACL. Its `repository_ids` allowlist is still - part of the admission decision as a separate deny-only gate: a repository - outside the allowlist is refused before the live Team grant is even read, - even if the GitHub Team holds a write grant on it. +- Every Workspace is bound to exactly one immutable GitHub Team id. The live + GitHub Team grant is the scope: a Workspace can obtain a token for exactly + the repositories of the policy's Organization on which its Team holds a + `push`, `maintain` or `admin` grant at the moment of the request. The policy + holds no repository list, so it never grants anything GitHub has not granted + and never narrows what GitHub has granted. An Organization Admin who grants + a Team write access to a new repository makes it available to that Team's + Environment without a broker policy change or redeploy. - A minted token can read checks, rerun workflows, change repository contents and create or update pull requests only for that one repository. GitHub has no rerun-only installation permission, so `actions: write` also permits other Actions mutations in that repository; the token still receives no Checks write, administration, membership or cross-repository authority. -- Repository names are assertions for human readback; immutable GitHub ids are - the authorization keys. +- 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. - Installation tokens are neither logged nor written to disk by this service. - The client credential helper must also avoid disk persistence. @@ -96,18 +95,17 @@ The policy schema is demonstrated in contain one high-entropy value and are referenced by path; their values never belong in the policy or Git. -### Policy schema `lazurio.github_app_broker.policy.v2` +### Policy schema `lazurio.github_app_broker.policy.v3` -Schema v2 makes the Team binding part of the contract: +Schema v3 binds each Workspace to its GitHub Team and names no repositories: -- `workspaces[].github_team_id` is required: the immutable numeric id of the - GitHub Team this Workspace acts for. Ids must be unique across Workspaces, - so exactly one broker Workspace exists per Team. -- `workspaces[].github_team_slug` is an optional 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. -- `installation_permissions.members` must be `read`: the App reads Teams and - Team repository grants through this Organization permission. +- `github_app {id, slug}`, `github_owner {id, login}`, `installation_id` and + `installation_repository_selection` (`selected` or `all`; omitted means + `selected`) assert the exact App installation. +- `installation_permissions` is the exact accepted permission set. It must + include `actions: write`, `checks: read`, `contents: write`, + `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 @@ -115,113 +113,166 @@ Schema v2 makes the Team binding part of the contract: with a write grant. Without it the token keeps the base set. Because the installation gate compares permissions exactly, accept the permission and deploy the policy that declares it in one short window. -- `id`, `credential_file` and `repository_ids` keep their v1 meaning. - -Schema v1 is retired and rejected with a migration message. Existing v1 -policies migrate by setting the new `schema_version`, adding one -`github_team_id` per Workspace and passing `policy check --live` before the -deployment is switched. +- `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 + 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. + +`repositories` and `workspaces[].repository_ids` no longer exist. A v3 policy +that still contains either key is rejected, so a half-migrated policy fails +closed instead of silently ignoring a list someone believes is enforced. + +#### Migration from v2 + +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 +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. + +Roll out the broker before the client adapter: broker `0.10.0` accepts both +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. ### API -`POST /v1/token` +`POST /v1/token` accepts exactly one of two bodies: ```http Authorization: Bearer X-Lazurio-Workspace-ID: customer-team Content-Type: application/json +{"repository": "example-organization/example-repository"} +``` + +```http {"repository_id": 2345} ``` -The response contains the installation token and GitHub expiry timestamp with -`Cache-Control: no-store`. `GET /health` returns `204` only after startup -policy verification has succeeded. +The name form is what the adapter sends. The id form keeps already deployed +`0.9.0` adapters working during rollout and costs one extra GitHub read. A +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`: + +```json +{"token": "", "expires_at": "2030-01-01T00:00:00Z", "repository_id": 2345, "repository": "example-organization/example-repository"} +``` + +`GET /health` returns `204` only after startup policy verification has +succeeded. Every `POST /v1/token` passes these gates in order; a failed gate never issues a scoped repository token. The Team gate itself first mints a short-lived -`members: read` plus `metadata: read` probe token, performs the Team read and revokes the probe, so -a Team or grant refusal has already cost that probe mint and revocation — it -only guarantees that no final repository token exists: +`members: read` plus `metadata: read` probe token, performs its reads and +revokes the probe, so a Team or grant refusal has already cost that probe mint +and revocation; it only guarantees that no final repository token exists: + +1. credential: unknown Workspace or wrong credential, no GitHub traffic; +2. body shape and, for the name form, owner: the owner must equal + `github_owner.login` case-insensitively, no GitHub traffic; +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. | Status | `error` | Meaning | | --- | --- | --- | | `401` | `workspace_unauthorized` | Unknown Workspace id or wrong credential. No GitHub traffic. | -| `403` | `repository_denied` | Repository id is outside the Workspace allowlist. No GitHub traffic. | -| `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 exact repository. | -| `502` | `token_unavailable` | GitHub was unreachable, rate-limited or over quota, returned an unexpected shape, or the mint left the requested scope. | +| `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. | +| `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. | The Team gate runs live on every mint with a short-lived `members: read` plus -`metadata: read` probe token that is revoked immediately afterwards. Its two read requests are -addressed by immutable ids: +`metadata: read` probe token that is revoked immediately afterwards. Its read +requests are: 1. `GET /organizations/{org_id}/team/{team_id}` proves the Team still exists in the installation's Organization and matches the asserted identity (Organization `members: read`). -2. `GET /organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}` with +2. Id form only: `GET /repositories/{id}` learns the repository's current + `full_name` and `owner.id`; an owner other than `github_owner.id` is + `repository_denied`, and a repository the installation cannot see is + `team_grant_missing`. +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 immutable - repository id to match and `push`, `maintain` or `admin` to be `true`. A - `404` means no grant (Organization `members: read`; GitHub also lists this - endpoint under repository `administration: read`, which is not required). - The probe also needs repository `metadata: read`: without it GitHub - answers `404` for every private repository the probe cannot see, which - would look like a missing grant. - -The `{owner}/{repo}` path segment is the policy's asserted `full_name`, whose -immutable id was already proven against the live installation. A repository -renamed after that proof fails closed until the policy is reviewed. Nothing -from these readbacks is cached: revoking a Team grant on GitHub refuses the -next token without a restart, and a token already issued expires on GitHub's -installation-token schedule. + 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` + (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. +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 +schedule. + +In `selected` installation mode a repository must also be added to the App +installation on GitHub before a token can be minted for it; until then the +probe cannot see it and the request is refused. In `all` mode every +Organization repository is installed, so the Team grant alone decides. Both +are GitHub-side actions an Organization Admin performs without the broker +operator. #### GitHub request budget -A successful Team gate costs four GitHub requests: the probe token mint, the -two reads above and the probe revocation. A missing Team refuses early after -three (probe mint, Team read, probe revocation) and never reads the repository -grant. Including the final scoped mint, one accepted token request costs: +The installation gate is one request. A successful Team gate costs four GitHub +requests for the name form (probe mint, Team read, grant read, probe +revocation) and five for the id form (plus the repository read). A missing +Team refuses early after three (probe mint, Team read, probe revocation). +Including the final scoped mint, one accepted token request costs: | Path | GitHub requests | | --- | --- | -| Node adapter, accepted `POST /v1/token` | 5 (Team gate 4 + scoped mint 1) | -| Worker adapter, accepted `POST /v1/token` | 9 (live installation gate 4 + Team gate 4 + scoped mint 1) | -| `policy check --live`, fully successful (maximum) | 4 + 3 × Workspaces + allowlisted grants (installation gate, then one probe mint and revocation plus one Team read per Workspace, plus one grant read per `repository_ids` entry) | -| `policy check --live`, Workspace with a missing Team | 4 + 3 × Workspaces and no `/repos/` grant reads for that Workspace: the Team read refuses every grant row early, so the per-repository GETs are skipped (a one-Workspace, one-repository missing-Team policy costs 7 requests) | +| Node adapter, accepted name form | 5 (Team gate 4 + scoped mint 1) | +| Node adapter, accepted id form | 6 (Team gate 5 + scoped mint 1) | +| Worker adapter, accepted name form | 6 (installation gate 1 + Team gate 4 + scoped mint 1) | +| Worker adapter, accepted id form | 7 (installation gate 1 + Team gate 5 + scoped mint 1) | +| `policy check --live` | 1 + per Workspace: probe mint, Team read, one Team repository list page per 100 repositories, probe revocation (a missing Team skips the list) | -The installation gate counts one `/installation/repositories` page; an -installation with more than 100 repositories adds one request per further -page. Denied credentials and denied repositories cost zero GitHub requests. +Denied credentials, invalid bodies and foreign owners in the name form cost +zero GitHub requests. All of these requests draw on the installation's GitHub rate limit. A rate-limit or quota response, like any other GitHub failure, fails closed as `502 token_unavailable` (or a non-zero `policy check --live` exit) and issues no scoped repository token; the Team gate's probe token may already have been -minted and revoked by then. There is no readback cache to fall back on. Operators sizing a -deployment must budget the per-mint Worker cost and the readback cost of a -large policy accordingly. +minted and revoked by then. There is no readback cache to fall back on. Both runtime adapters reject an invalid route, media type or Workspace credential before consuming the request body. An authenticated token body is read with a hard `1024`-byte streaming limit before JSON parsing. -The broker parses the policy and Workspace credentials once, verifies the exact -live GitHub installation and only then starts listening. Runtime requests never -trigger installation-wide GitHub verification. Policy or credential changes -become active only through a verified service restart; deployment automation -must recreate the workload whenever either mounted input changes. - -`installation_repository_selection` is an explicit policy assertion with the -values `selected` or `all`; an omitted value remains backward-compatible with -`selected`. In `selected` mode, the live installation repository set must equal -the policy exactly. In `all` mode, verification still requires every immutable -repository id and asserted full name in the policy to exist, while additional -installation repositories are expected and do not become broker-authorized. -Every token request remains constrained to one policy repository id and one -Workspace allowlist. An `all` installation nevertheless increases the private -key's provider-side blast radius and should be a reviewed deployment decision, -not an implicit fallback. +The Node broker parses the policy and Workspace credentials once, verifies the +exact live GitHub installation and only then starts listening. Policy or +credential changes become active only through a verified service restart; +deployment automation must recreate the workload whenever either mounted input +changes. A repository newly granted to a Team needs neither. + +`installation_repository_selection` is an explicit assertion with the values +`selected` or `all`. The installation gate compares the mode, not a +repository set. An `all` installation lets the Team grant alone decide and +increases the private key's provider-side blast radius to every Organization +repository; it should be a reviewed deployment decision, not an implicit +fallback. Operators can revalidate the same live contract without starting another listener: @@ -231,9 +282,9 @@ node src/broker.mjs --verify-only ``` The command reads the same mounted policy, App key and Workspace credentials, -performs the exact live GitHub readback, emits no secret or token, and exits -non-zero on drift. Deployment automation should run it on every desired-state -apply in addition to the startup gate. +performs the exact live installation readback, emits no secret or token, and +exits non-zero on drift. Deployment automation should run it on every +desired-state apply in addition to the startup gate. ### Policy check and migration gate @@ -254,24 +305,26 @@ 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 -reads back every `repository_ids` entry of every Workspace with one -`members: read` plus `metadata: read` probe token per Workspace, and prints one -row per grant: +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: ```text WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL alpha-team 4001 alpha-team 3001 example-org/alpha write ok -beta-team 4002 beta 3002 example-org/beta none refused Workspace beta-team GitHub Team has no live grant on repository example-org/beta -2 grants checked, 1 ok, 1 refused +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 ``` -The output contains no token or credential. The command exits non-zero when -any grant is refused or the installation differs from the policy, which is the -migration gate for existing v1 deployments: migrate the policy, run -`policy check --live`, and only then switch the deployment to a broker release -that requires schema v2. The check never edits GitHub; a refused row is fixed by -changing the live Team grant or the reviewed policy, never by the broker. +The repository rows are information: they show what each Team's Environment +can reach right now. 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 +GitHub; a refused row is fixed by restoring the Team on GitHub or reviewing +the policy, never by the broker. ### Cloudflare Workers @@ -301,10 +354,10 @@ failed. Unlike the always-on Node process, a Worker has no trustworthy startup phase. It therefore parses the policy and credential snapshot on each request and, -after a valid Workspace credential and repository allowlist match, verifies -the exact live GitHub installation and then the live Team grant immediately -before every token mint. Bad credentials and denied repositories never trigger -GitHub traffic. `GET +after a valid Workspace credential, a valid body and (for the name form) the +owner check, verifies the exact live GitHub installation and then the live +Team grant immediately before every token mint. Bad credentials, invalid +bodies and foreign-owner names never trigger GitHub traffic. `GET /health` proves only that the current Worker bindings form a valid local configuration, including an importable RSA PKCS#8 App key; a successful token request is the live installation proof. @@ -355,9 +408,12 @@ The adapter has four command classes: `lazurio-for-github[bot]`; 3. all other `auth`, `config`, `alias`, `extension`, host override and cross-repository `search` surfaces fail closed; and -4. repository commands resolve one exact approved repository, mint a fresh +4. repository commands resolve one exact repository from `--repo`/`-R`, + `GH_REPO` or the current checkout's brokered HTTPS origin, mint a fresh scoped token, and invoke real `gh` with the token only in the child process - environment. + environment. Without a target the command fails closed and asks to run + inside a Team repository or pass `--repo OWNER/REPO`; there is no default + repository. `gh run rerun --repo OWNER/REPO` follows the fourth class. It uses the same one-repository token as other repository commands and does not require a @@ -371,14 +427,18 @@ denied, and the argument is forwarded unchanged rather than interpreted as a second authority. The host-level authenticated indicator means only that the broker accepted a -fresh proof for the first deterministic repository in the validated Team -policy. It is not an Organization-wide capability claim. Every actual command +fresh proof for the repository of the current checkout (or `GH_REPO`). It is +not an Organization-wide capability claim. Outside a Team repository the proof +fails closed and reports the unauthenticated host shape. Every actual command resolves and authorizes its own repository again. +The adapter holds no repository list. It sends the repository name, and it +accepts only a broker response whose `repository` names the same repository +(case-insensitively) and whose `repository_id` is a positive integer. A +leftover `GITHUB_REPOSITORY_POLICY_JSON` from a `0.9.0` Workspace is ignored. + Required Workspace environment: -- `GITHUB_REPOSITORY_POLICY_JSON`: exact `OWNER/REPO` to immutable repository - id mapping; - `GITHUB_TOKEN_BROKER_URL`: the exact deployment-owned broker origin; - `GITHUB_BROKER_WORKSPACE_ID`: immutable lowercase Workspace id; and - `GITHUB_BROKER_CLIENT_CREDENTIAL_FILE=/run/secrets/github_broker_client_token`. @@ -415,7 +475,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.9.0` | +| Adapter | `0.10.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 77936bb..d7de1d2 100644 --- a/adapter/broker-client.mjs +++ b/adapter/broker-client.mjs @@ -37,46 +37,6 @@ export function requireHttpsGitHubOrigin(value) { return normalizeRepository(candidate); } -export function parseRepositoryPolicy(environment = process.env) { - let input; - try { - input = JSON.parse(environment.GITHUB_REPOSITORY_POLICY_JSON ?? "null"); - } catch { - fail("Team repository policy is invalid"); - } - if (!input || typeof input !== "object" || Array.isArray(input)) { - fail("Team repository policy is invalid"); - } - - const entries = Object.entries(input).map(([repository, repositoryId]) => { - const coordinate = normalizeRepository(repository); - if ( - coordinate !== repository || - !Number.isSafeInteger(repositoryId) || - repositoryId <= 0 - ) { - fail("Team repository policy is invalid"); - } - return Object.freeze({ repository: coordinate, repositoryId }); - }); - if (entries.length === 0) fail("Team repository policy is invalid"); - if ( - new Set(entries.map(({ repository }) => repositoryIdentityKey(repository))).size !== - entries.length || - new Set(entries.map(({ repositoryId }) => repositoryId)).size !== entries.length - ) { - fail("Team repository policy is invalid"); - } - entries.sort(({ repository: left }, { repository: right }) => - left < right ? -1 : left > right ? 1 : 0, - ); - return Object.freeze(entries); -} - -export function firstPolicyRepository(environment = process.env) { - return parseRepositoryPolicy(environment)[0].repository; -} - export function parseBrokerClientConfig(raw) { let input; try { @@ -151,13 +111,8 @@ export async function requestGitHubAppToken({ now = () => Date.now(), timeoutMs = 5_000, }) { + // The broker decides the scope from the live GitHub Team grant; the client holds no allowlist. const coordinate = normalizeRepository(repository); - const policy = parseRepositoryPolicy(environment); - const coordinateKey = repositoryIdentityKey(coordinate); - const policyEntry = policy.find( - (entry) => repositoryIdentityKey(entry.repository) === coordinateKey, - ); - if (!policyEntry) fail("repository is outside the Team Workspace policy"); const { workspaceId, brokerOrigin } = requireBrokerRuntime(environment, readFile, exists); const clientCredential = readFile(CREDENTIAL_FILE, "utf8").trim(); @@ -174,7 +129,7 @@ export async function requestGitHubAppToken({ "Content-Type": "application/json", "X-Lazurio-Workspace-ID": workspaceId, }, - body: JSON.stringify({ repository_id: policyEntry.repositoryId }), + body: JSON.stringify({ repository: coordinate }), signal: AbortSignal.timeout(timeoutMs), }); } catch { @@ -189,10 +144,20 @@ export async function requestGitHubAppToken({ fail("GitHub token broker returned an invalid scoped response"); } const expiresAt = Date.parse(result?.expires_at ?? ""); + let returnedRepository; + try { + returnedRepository = normalizeRepository(result?.repository); + } catch { + returnedRepository = undefined; + } if ( typeof result?.token !== "string" || result.token.length < 20 || - result.repository_id !== policyEntry.repositoryId || + returnedRepository === undefined || + returnedRepository !== result.repository || + repositoryIdentityKey(returnedRepository) !== repositoryIdentityKey(coordinate) || + !Number.isSafeInteger(result?.repository_id) || + result.repository_id <= 0 || !Number.isFinite(expiresAt) || expiresAt <= now() || expiresAt > now() + MAX_TOKEN_LIFETIME_MS @@ -201,8 +166,8 @@ export async function requestGitHubAppToken({ } return Object.freeze({ token: result.token, - repository: coordinate, - repositoryId: policyEntry.repositoryId, + repository: returnedRepository, + repositoryId: result.repository_id, expiresAt: result.expires_at, }); } diff --git a/adapter/brokered-gh.mjs b/adapter/brokered-gh.mjs index 316e37a..2920d10 100644 --- a/adapter/brokered-gh.mjs +++ b/adapter/brokered-gh.mjs @@ -4,7 +4,6 @@ import fs from "node:fs"; import { spawnSync } from "node:child_process"; import { - firstPolicyRepository, normalizeRepository, repositoryIdentityKey, requestGitHubAppToken, @@ -137,7 +136,7 @@ export function resolveGhRepository({ // Prefer the checkout's canonical spelling when gh/T3 supplied the same GitHub identity in a // different case. The original gh arguments remain untouched and official gh still executes. const repository = originRepository ?? selected; - if (!repository) fail("run gh inside an approved Team repository or pass --repo OWNER/REPO"); + if (!repository) fail("run gh inside a Team repository checkout or pass --repo OWNER/REPO"); return repository; } @@ -217,7 +216,9 @@ export async function runBrokeredGh({ } if (classification === "auth-status" || classification === "viewer-login") { try { - const repository = firstPolicyRepository(environment); + // The proof targets the current checkout (or GH_REPO); there is no policy default to fall + // back to, so outside a Team repository the proof fails closed. + const repository = resolveGhRepository({ args: [], environment, readOrigin }); await requestToken({ repository, environment }); } catch { if (classification === "auth-status") { diff --git a/examples/policy.example.json b/examples/policy.example.json index 9fb3ec7..755ff4e 100644 --- a/examples/policy.example.json +++ b/examples/policy.example.json @@ -1,5 +1,5 @@ { - "schema_version": "lazurio.github_app_broker.policy.v2", + "schema_version": "lazurio.github_app_broker.policy.v3", "github_app": { "id": 1234, "slug": "customer-app" @@ -18,19 +18,12 @@ "metadata": "read", "pull_requests": "write" }, - "repositories": [ - { - "id": 3001, - "full_name": "example-organization/example-repository" - } - ], "workspaces": [ { "id": "example-team", "github_team_id": 4001, "github_team_slug": "example-team", - "credential_file": "/run/secrets/workspace-example-team", - "repository_ids": [3001] + "credential_file": "/run/secrets/workspace-example-team" } ] } diff --git a/package-lock.json b/package-lock.json index 38e5af5..b8087e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lazurio/github-app-broker", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lazurio/github-app-broker", - "version": "0.9.0", + "version": "0.10.0", "license": "Apache-2.0", "devDependencies": { "wrangler": "4.127.1" diff --git a/package.json b/package.json index 40a4e2f..2abcea3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lazurio/github-app-broker", - "version": "0.9.0", + "version": "0.10.0", "private": true, "license": "Apache-2.0", "repository": { diff --git a/src/broker.mjs b/src/broker.mjs index 108ffb9..d713d85 100644 --- a/src/broker.mjs +++ b/src/broker.mjs @@ -151,12 +151,11 @@ export function parseCommand(arguments_) { /** Offline summary of the parsed Team binding; no GitHub traffic and no secret. */ export function formatPolicySummary(policy) { - const header = ["WORKSPACE", "TEAM_ID", "TEAM_SLUG", "REPOSITORIES"]; + const header = ["WORKSPACE", "TEAM_ID", "TEAM_SLUG"]; const rows = policy.workspaces.map((workspace) => [ workspace.id, String(workspace.github_team_id), workspace.github_team_slug ?? "-", - String(workspace.repository_ids.length), ]); 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(); diff --git a/src/core.mjs b/src/core.mjs index 03f1937..445dce0 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -2,10 +2,15 @@ export const MAX_BODY_BYTES = 1024; -const POLICY_SCHEMA = "lazurio.github_app_broker.policy.v2"; -const RETIRED_POLICY_SCHEMA = "lazurio.github_app_broker.policy.v1"; +const POLICY_SCHEMA = "lazurio.github_app_broker.policy.v3"; +const RETIRED_POLICY_SCHEMA_V1 = "lazurio.github_app_broker.policy.v1"; +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.9.0"; +const USER_AGENT = "lazurio-github-app-broker/0.10.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({ actions: "write", @@ -33,6 +38,8 @@ const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read", metadata: "read" const REPOSITORY_PERMISSIONS_MEDIA_TYPE = "application/vnd.github.v3.repository+json"; export const TEAM_GRANT_MISSING = "team_grant_missing"; +export const REPOSITORY_DENIED = "repository_denied"; +export const INVALID_REQUEST = "invalid_request"; /** A live GitHub Team binding or repository grant diverged from the policy; the broker mints nothing. */ export class TeamGrantError extends Error { @@ -43,6 +50,15 @@ export class TeamGrantError extends Error { } } +/** The requested repository belongs to another owner than the policy's Organization. */ +export class RepositoryDeniedError extends Error { + constructor(message) { + super(message); + this.name = "RepositoryDeniedError"; + this.code = REPOSITORY_DENIED; + } +} + function fail(message) { throw new Error(message); } @@ -51,6 +67,57 @@ function refuse(message) { throw new TeamGrantError(message); } +function deny(message) { + throw new RepositoryDeniedError(message); +} + +const OWNER_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const REPOSITORY_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,100}$/; + +/** + * Parses an exact `Owner/name` coordinate. `.` and `..` are refused because they would change the + * meaning of the GitHub API path the name is placed into. + */ +export function parseRepositoryCoordinate(value) { + if (typeof value !== "string") return undefined; + const parts = value.split("/"); + if ( + parts.length !== 2 || + !OWNER_LOGIN_PATTERN.test(parts[0]) || + !REPOSITORY_NAME_PATTERN.test(parts[1]) || + parts[1] === "." || + parts[1] === ".." + ) { + return undefined; + } + return Object.freeze({ owner: parts[0], name: parts[1], full_name: `${parts[0]}/${parts[1]}` }); +} + +/** + * Parses the `POST /v1/token` body: exactly one of `{"repository": "Owner/name"}` or + * `{"repository_id": }`. Returns undefined for anything else. + */ +export function parseTokenRequest(bodyText) { + let body; + try { + body = JSON.parse(bodyText); + } catch { + return undefined; + } + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const keys = Object.keys(body); + if (keys.length !== 1) return undefined; + if (keys[0] === "repository") { + const coordinate = parseRepositoryCoordinate(body.repository); + return coordinate ? Object.freeze({ repository: coordinate.full_name }) : undefined; + } + if (keys[0] === "repository_id") { + const id = body.repository_id; + return Number.isSafeInteger(id) && id > 0 ? Object.freeze({ repository_id: id }) : undefined; + } + return undefined; +} + function positiveInteger(value, label) { if (!Number.isSafeInteger(value) || value <= 0) fail(`${label} must be a positive integer`); return value; @@ -65,12 +132,18 @@ function canonicalObject(value) { export function parsePolicy(raw) { const input = typeof raw === "string" ? JSON.parse(raw) : structuredClone(raw); if (!input || typeof input !== "object" || Array.isArray(input)) fail("policy must be an object"); - if (input.schema_version === RETIRED_POLICY_SCHEMA) { + if (input.schema_version === RETIRED_POLICY_SCHEMA_V1) { fail( - `policy schema ${RETIRED_POLICY_SCHEMA} is retired; migrate to ${POLICY_SCHEMA} with one immutable workspaces[].github_team_id per Workspace`, + `policy schema ${RETIRED_POLICY_SCHEMA_V1} is retired; add one immutable workspaces[].github_team_id per Workspace and ${V3_MIGRATION}`, ); } + if (input.schema_version === RETIRED_POLICY_SCHEMA_V2) { + fail(`policy schema ${RETIRED_POLICY_SCHEMA_V2} is retired; ${V3_MIGRATION}`); + } if (input.schema_version !== POLICY_SCHEMA) fail("unsupported policy schema"); + if (Object.hasOwn(input, "repositories")) { + fail(`policy v3 must not contain \`repositories\`; ${V3_MIGRATION}`); + } const appId = positiveInteger(input.github_app?.id, "github_app.id"); const appSlug = input.github_app?.slug; @@ -79,7 +152,7 @@ export function parsePolicy(raw) { } const ownerId = positiveInteger(input.github_owner?.id, "github_owner.id"); const ownerLogin = input.github_owner?.login; - if (typeof ownerLogin !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(ownerLogin)) { + if (typeof ownerLogin !== "string" || !OWNER_LOGIN_PATTERN.test(ownerLogin)) { fail("github_owner.login is invalid"); } const installationId = positiveInteger(input.installation_id, "installation_id"); @@ -107,33 +180,13 @@ export function parsePolicy(raw) { ); } - if (!Array.isArray(input.repositories) || input.repositories.length === 0) { - fail("repositories must be a non-empty array"); - } - const repositories = input.repositories.map((repository, index) => { - const id = positiveInteger(repository?.id, `repositories[${index}].id`); - const fullName = repository?.full_name; - if ( - typeof fullName !== "string" || - !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fullName) || - fullName.split("/", 1)[0].toLowerCase() !== ownerLogin.toLowerCase() - ) { - fail(`repositories[${index}].full_name is invalid for the asserted owner`); - } - return Object.freeze({ id, full_name: fullName }); - }); - if (new Set(repositories.map(({ id }) => id)).size !== repositories.length) { - fail("repository ids must be unique"); - } - if (new Set(repositories.map(({ full_name }) => full_name.toLowerCase())).size !== repositories.length) { - fail("repository full names must be unique"); - } - const repositoryIds = new Set(repositories.map(({ id }) => id)); - if (!Array.isArray(input.workspaces) || input.workspaces.length === 0) { fail("workspaces must be a non-empty array"); } const workspaces = input.workspaces.map((workspace, index) => { + if (workspace && typeof workspace === "object" && Object.hasOwn(workspace, "repository_ids")) { + fail(`policy v3 must not contain \`workspaces[${index}].repository_ids\`; ${V3_MIGRATION}`); + } const id = workspace?.id; if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(id)) { fail(`workspaces[${index}].id is invalid`); @@ -150,24 +203,11 @@ export function parsePolicy(raw) { ) { fail(`workspaces[${index}].credential_file must be a canonical file below /run/secrets`); } - if (!Array.isArray(workspace.repository_ids) || workspace.repository_ids.length === 0) { - fail(`workspaces[${index}].repository_ids must be non-empty`); - } - const ids = workspace.repository_ids.map((repositoryId, repositoryIndex) => { - const result = positiveInteger( - repositoryId, - `workspaces[${index}].repository_ids[${repositoryIndex}]`, - ); - if (!repositoryIds.has(result)) fail(`workspace ${id} references an unknown repository id`); - return result; - }); - if (new Set(ids).size !== ids.length) fail(`workspace ${id} repeats a repository id`); return Object.freeze({ id, github_team_id: teamId, ...(teamSlug === undefined ? {} : { github_team_slug: teamSlug }), credential_file: credentialFile, - repository_ids: Object.freeze(ids), }); }); if (new Set(workspaces.map(({ id }) => id)).size !== workspaces.length) { @@ -187,7 +227,6 @@ export function parsePolicy(raw) { installation_id: installationId, installation_repository_selection: installationRepositorySelection, installation_permissions: Object.freeze({ ...installationPermissions }), - repositories: Object.freeze(repositories), workspaces: Object.freeze(workspaces), }); } @@ -266,6 +305,11 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () return result; } + /** + * Installation gate: exact App identity, Organization target, repository selection mode and + * permission set. The policy names no repositories, so the gate enumerates none; the live Team + * grant decides which repository a Workspace may use. + */ async function verifyPolicy(policy) { if (String(policy.github_app.id) !== String(appId)) { fail("configured GitHub App id differs from policy"); @@ -282,32 +326,6 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () ) { fail("live GitHub installation identity, selection or permissions differ from policy"); } - - await withProbeToken(policy, { contents: "read" }, async (authorization) => { - const actual = new Map(); - let page = 1; - let total = Number.POSITIVE_INFINITY; - while (actual.size < total && page <= 100) { - const response = await request(`/installation/repositories?per_page=100&page=${page}`, { - authorization, - }); - total = response?.total_count; - if (!Number.isSafeInteger(total) || total < 0) fail("GitHub returned an invalid repository total"); - for (const repository of response.repositories ?? []) { - actual.set(repository.id, repository.full_name); - } - page += 1; - } - const expected = new Map(policy.repositories.map(({ id, full_name }) => [id, full_name])); - const configuredRepositoryMissing = [...expected].some( - ([id, fullName]) => actual.get(id) !== fullName, - ); - const selectedInstallationHasDrift = - policy.installation_repository_selection === "selected" && actual.size !== expected.size; - if (actual.size !== total || configuredRepositoryMissing || selectedInstallationHasDrift) { - fail("live GitHub installation repository grants differ from policy"); - } - }); } function grantRole(permissions) { @@ -319,6 +337,10 @@ 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, @@ -339,90 +361,150 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () return Object.freeze({ id: team.id, slug: team.slug }); } - async function readTeamGrant(policy, workspace, repositoryId, authorization) { - const repository = policy.repositories.find(({ id }) => id === repositoryId); - if (!repository || !workspace.repository_ids.includes(repositoryId)) { - fail(`repository ${repositoryId} is outside the Workspace ${workspace.id} policy`); + /** Resolves an immutable repository id to its live coordinate inside the policy's Organization. */ + async function resolveRepositoryId(policy, workspace, repositoryId, authorization) { + const repository = await request(`/repositories/${repositoryId}`, { authorization, tolerateNotFound: true }); + if (repository === undefined) { + refuse(`Workspace ${workspace.id} cannot see repository ${repositoryId}`); + } + if (repository?.owner?.id !== policy.github_owner.id) { + deny(`repository ${repositoryId} belongs to another owner than the policy's Organization`); + } + const coordinate = parseRepositoryCoordinate(repository?.full_name); + if (repository?.id !== repositoryId || !coordinate) { + fail("GitHub returned an invalid repository identity"); + } + return coordinate; + } + + async function readTeamGrant(policy, workspace, target, authorization) { + let coordinate; + if (target?.repository_id !== undefined) { + coordinate = await resolveRepositoryId(policy, workspace, target.repository_id, authorization); + } else { + coordinate = parseRepositoryCoordinate(target?.repository); + if (!coordinate) fail("repository coordinate is invalid"); + if (coordinate.owner.toLowerCase() !== policy.github_owner.login.toLowerCase()) { + deny(`repository ${coordinate.full_name} belongs to another owner than the policy's Organization`); + } } const grant = await request( - `/organizations/${policy.github_owner.id}/team/${workspace.github_team_id}/repos/${repository.full_name}`, + `/organizations/${policy.github_owner.id}/team/${workspace.github_team_id}/repos/${encodeURIComponent(coordinate.owner)}/${encodeURIComponent(coordinate.name)}`, { authorization, accept: REPOSITORY_PERMISSIONS_MEDIA_TYPE, tolerateNotFound: true }, ); if (grant === undefined) { - refuse(`Workspace ${workspace.id} GitHub Team has no live grant on repository ${repository.full_name}`); + refuse(`Workspace ${workspace.id} GitHub Team has no live grant on repository ${coordinate.full_name}`); + } + if (grant?.owner && grant.owner.id !== policy.github_owner.id) { + deny(`repository ${coordinate.full_name} belongs to another owner than the policy's Organization`); } const role = grantRole(grant?.permissions); - if (grant?.id !== repositoryId || !["admin", "maintain", "write"].includes(role)) { - refuse( - `Workspace ${workspace.id} GitHub Team grant on repository ${repository.full_name} is not write-capable`, - ); + const returned = parseRepositoryCoordinate(grant?.full_name); + if ( + grant?.owner?.id !== policy.github_owner.id || + !Number.isSafeInteger(grant?.id) || + grant.id <= 0 || + !returned || + (target?.repository_id !== undefined && grant.id !== target.repository_id) || + !isWriteCapable(role) + ) { + refuse(`Workspace ${workspace.id} GitHub Team grant on repository ${coordinate.full_name} is not write-capable`); } return Object.freeze({ workspace_id: workspace.id, github_team_id: workspace.github_team_id, - repository_id: repositoryId, - full_name: repository.full_name, + repository_id: grant.id, + full_name: returned.full_name, role, }); } /** - * Live per-mint Team binding check. Throws 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 exact repository. Nothing is cached across calls. + * 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. */ - async function verifyTeamGrant(policy, workspace, repositoryId) { + async function verifyTeamGrant(policy, workspace, target) { return withProbeToken(policy, TEAM_PROBE_PERMISSIONS, async (authorization) => { const team = await readTeam(policy, workspace, authorization); - const grant = await readTeamGrant(policy, workspace, repositoryId, authorization); + const grant = await readTeamGrant(policy, workspace, target, authorization); return Object.freeze({ ...grant, github_team_slug: team.slug }); }); } - /** Readback of every repository grant of one Workspace with a single probe token; never throws per row. */ + /** + * 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. + */ async function readWorkspaceTeamGrants(policy, workspace) { return withProbeToken(policy, TEAM_PROBE_PERMISSIONS, async (authorization) => { - const rows = []; let team; try { team = await readTeam(policy, workspace, authorization); } catch (error) { if (error?.code !== TEAM_GRANT_MISSING) throw error; - return policy.repositories - .filter(({ id }) => workspace.repository_ids.includes(id)) - .map(({ id, full_name }) => - Object.freeze({ - workspace_id: workspace.id, - github_team_id: workspace.github_team_id, - github_team_slug: workspace.github_team_slug ?? "?", - repository_id: id, - full_name, - role: "none", - status: "refused", - detail: error.message, - }), - ); + return Object.freeze([ + Object.freeze({ + workspace_id: workspace.id, + github_team_id: workspace.github_team_id, + github_team_slug: workspace.github_team_slug ?? "?", + repository_id: "-", + full_name: "-", + role: "-", + status: "refused", + detail: error.message, + }), + ]); } - for (const repositoryId of workspace.repository_ids) { - try { - const grant = await readTeamGrant(policy, workspace, repositoryId, authorization); - rows.push(Object.freeze({ ...grant, github_team_slug: team.slug, status: "ok", detail: "" })); - } catch (error) { - if (error?.code !== TEAM_GRANT_MISSING) throw error; - const repository = policy.repositories.find(({ id }) => id === repositoryId); + const rows = []; + for (let page = 1; ; page += 1) { + if (page > MAX_TEAM_REPOSITORY_PAGES) fail("GitHub Team repository list exceeds the readback bound"); + const repositories = await request( + `/organizations/${policy.github_owner.id}/team/${workspace.github_team_id}/repos?per_page=100&page=${page}`, + { authorization }, + ); + if (!Array.isArray(repositories)) fail("GitHub returned an invalid Team repository list"); + for (const repository of repositories) { + const role = grantRole(repository?.permissions); + const coordinate = parseRepositoryCoordinate(repository?.full_name); + if ( + repository?.owner?.id !== policy.github_owner.id || + !Number.isSafeInteger(repository?.id) || + !coordinate || + !isWriteCapable(role) + ) { + continue; + } rows.push( Object.freeze({ workspace_id: workspace.id, github_team_id: workspace.github_team_id, github_team_slug: team.slug, - repository_id: repositoryId, - full_name: repository.full_name, - role: "none", - status: "refused", - detail: error.message, + repository_id: repository.id, + full_name: coordinate.full_name, + role, + status: "ok", + detail: "", }), ); } + if (repositories.length < 100) break; + } + if (rows.length === 0) { + rows.push( + Object.freeze({ + workspace_id: workspace.id, + github_team_id: workspace.github_team_id, + github_team_slug: team.slug, + repository_id: "-", + full_name: "-", + role: "-", + status: "ok", + detail: "Team holds no write-capable repository grant", + }), + ); } return Object.freeze(rows); }); @@ -464,7 +546,8 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () /** * Migration and readback gate: verifies the live installation, then every Workspace's Team - * binding and every `repository_ids` grant. Returns rows for a human table; emits no secret. + * 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. */ export async function checkPolicyLive({ policy, github }) { if (typeof github?.verifyPolicy !== "function" || typeof github?.readWorkspaceTeamGrants !== "function") { @@ -495,14 +578,24 @@ export function formatPolicyCheckTable(rows) { Math.max(header.length, ...cells.map((line) => line[column].length)), ); const line = (values) => values.map((value, column) => value.padEnd(widths[column])).join(" ").trimEnd(); - const refused = rows.filter(({ status }) => status !== "ok").length; + const workspaces = new Map(); + for (const row of rows) { + const refused = workspaces.get(row.workspace_id) === "refused" || row.status !== "ok"; + 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; return [ line(POLICY_CHECK_COLUMNS.map(([, header]) => header)), ...cells.map(line), - `${rows.length} grants checked, ${rows.length - refused} ok, ${refused} refused`, + `${workspaces.size} Workspaces checked, ${workspaces.size - refusedTeams} Teams ok, ${refusedTeams} refused; ${repositories} write-capable repository grants`, ].join("\n") + "\n"; } +function result(status, body) { + return Object.freeze({ status, body }); +} + export function createBrokerHandler({ policy, github, credentials, secretMatches }) { if (!(credentials instanceof Map)) fail("Workspace credentials must be a Map"); if (typeof secretMatches !== "function") fail("secret matcher is missing"); @@ -510,6 +603,7 @@ export function createBrokerHandler({ policy, github, credentials, secretMatches fail("GitHub client must verify the Team grant and mint tokens"); } const workspaces = new Map(policy.workspaces.map((workspace) => [workspace.id, workspace])); + const ownerKey = policy.github_owner.login.toLowerCase(); return async ({ method, @@ -520,13 +614,13 @@ export function createBrokerHandler({ policy, github, credentials, secretMatches readBody = async () => "", }) => { if (method === "GET" && path === "/health") { - return Object.freeze({ status: 204, body: null }); + return result(204, null); } if (method !== "POST" || path !== "/v1/token") { - return Object.freeze({ status: 404, body: { error: "not_found" } }); + return result(404, { error: "not_found" }); } if (!contentType.toLowerCase().startsWith("application/json")) { - return Object.freeze({ status: 415, body: { error: "unsupported_media_type" } }); + return result(415, { error: "unsupported_media_type" }); } const presentedCredential = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; @@ -538,7 +632,7 @@ export function createBrokerHandler({ policy, github, credentials, secretMatches DUMMY_WORKSPACE_CREDENTIAL, ); if (!workspace || !presentedCredential || !credentialMatches) { - return Object.freeze({ status: 401, body: { error: "workspace_unauthorized" } }); + return result(401, { error: "workspace_unauthorized" }); } if (typeof readBody !== "function") fail("request body reader is missing"); @@ -547,22 +641,37 @@ export function createBrokerHandler({ policy, github, credentials, secretMatches if (new TextEncoder().encode(bodyText).byteLength > MAX_BODY_BYTES) { fail("request body is too large"); } - const body = JSON.parse(bodyText); - const repositoryId = body?.repository_id; - if (!Number.isSafeInteger(repositoryId) || !workspace.repository_ids.includes(repositoryId)) { - return Object.freeze({ status: 403, body: { error: "repository_denied" } }); + const target = parseTokenRequest(bodyText); + if (!target) return result(400, { error: INVALID_REQUEST }); + // A name outside the policy's Organization is refused before any GitHub traffic. + if (target.repository !== undefined && target.repository.split("/", 1)[0].toLowerCase() !== ownerKey) { + return result(403, { error: REPOSITORY_DENIED }); } + + let grant; try { - await github.verifyTeamGrant(policy, workspace, repositoryId); + grant = await github.verifyTeamGrant(policy, workspace, target); } catch (error) { - if (error?.code === TEAM_GRANT_MISSING) { - return Object.freeze({ status: 403, body: { error: TEAM_GRANT_MISSING } }); + if (error?.code === TEAM_GRANT_MISSING || error?.code === REPOSITORY_DENIED) { + return result(403, { error: error.code }); } throw error; } - return Object.freeze({ status: 200, body: await github.mintToken(policy, repositoryId) }); + const repositoryId = grant?.repository_id; + const repository = parseRepositoryCoordinate(grant?.full_name); + if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0 || !repository) { + fail("Team gate returned an invalid repository"); + } + const minted = await github.mintToken(policy, repositoryId); + if (minted?.repository_id !== repositoryId) fail("minted token is outside the verified repository"); + return result(200, { + token: minted.token, + expires_at: minted.expires_at, + repository_id: repositoryId, + repository: repository.full_name, + }); } catch { - return Object.freeze({ status: 502, body: { error: "token_unavailable" } }); + return result(502, { error: "token_unavailable" }); } }; } diff --git a/src/worker.mjs b/src/worker.mjs index 8a8238b..c9476d1 100644 --- a/src/worker.mjs +++ b/src/worker.mjs @@ -168,11 +168,11 @@ export function createWorkerEntrypoint({ createGithub = defaultGithub, fetchImpl } // No trustworthy startup phase: the exact live installation is verified before the - // Team grant on every authorized request, and both precede the one-repository mint. + // Team gate on every authorized request, and both precede the one-repository mint. const verifiedGithub = Object.freeze({ - async verifyTeamGrant(activePolicy, workspace, repositoryId) { + async verifyTeamGrant(activePolicy, workspace, target) { await github.verifyPolicy(activePolicy); - return github.verifyTeamGrant(activePolicy, workspace, repositoryId); + return github.verifyTeamGrant(activePolicy, workspace, target); }, mintToken(activePolicy, repositoryId) { return github.mintToken(activePolicy, repositoryId); diff --git a/test/broker.test.mjs b/test/broker.test.mjs index 52ef4bc..439c3f5 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -23,7 +23,7 @@ const secretByPath = new Map([ function policyFixture() { return { - schema_version: "lazurio.github_app_broker.policy.v2", + schema_version: "lazurio.github_app_broker.policy.v3", github_app: { id: 42, slug: "example-app" }, github_owner: { id: 1001, login: "example-org" }, installation_id: 2001, @@ -36,35 +36,42 @@ function policyFixture() { metadata: "read", pull_requests: "write", }, - repositories: [ - { id: 3001, full_name: "example-org/alpha" }, - { id: 3002, full_name: "example-org/beta" }, - ], workspaces: [ { id: "alpha-team", github_team_id: 4001, github_team_slug: "alpha-team", credential_file: "/run/secrets/workspace-alpha", - repository_ids: [3001], }, { id: "beta-team", github_team_id: 4002, credential_file: "/run/secrets/workspace-beta", - repository_ids: [3002], }, ], }; } -const grantedTeam = async (_policy, workspace, repositoryId) => ({ - workspace_id: workspace.id, - github_team_id: workspace.github_team_id, - github_team_slug: workspace.github_team_slug ?? workspace.id, - repository_id: repositoryId, - role: "write", -}); +const SYNTHETIC_REPOSITORIES = new Map([ + ["example-org/alpha", 3001], + ["example-org/beta", 3002], +]); + +/** Synthetic Team gate: resolves both body forms to the canonical repository id and name. */ +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); + if (!entry) throw Object.assign(new Error("no grant"), { code: "team_grant_missing" }); + 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", + }; +}; async function withServer(run, mintToken = async (_policy, repositoryId) => ({ token: `ghs_synthetic_${repositoryId}`, @@ -88,7 +95,12 @@ async function withServer(run, mintToken = async (_policy, repositoryId) => ({ } } -function request(origin, { workspace = "alpha-team", secret = secretByPath.get("/run/secrets/workspace-alpha"), repositoryId = 3001 } = {}) { +function request(origin, { + workspace = "alpha-team", + secret = secretByPath.get("/run/secrets/workspace-alpha"), + body = { repository: "example-org/alpha" }, + rawBody, +} = {}) { return fetch(`${origin}/v1/token`, { method: "POST", headers: { @@ -96,7 +108,7 @@ function request(origin, { workspace = "alpha-team", secret = secretByPath.get(" "Content-Type": "application/json", "X-Lazurio-Workspace-ID": workspace, }, - body: JSON.stringify({ repository_id: repositoryId }), + body: rawBody ?? JSON.stringify(body), }); } @@ -126,16 +138,19 @@ function requestHeadersWithoutBody(origin, { contentType, secret }) { }); } -test("mints a non-cacheable token only for the Workspace repository id", async () => { +test("mints a non-cacheable token for the Team-granted repository in both body forms", async () => { await withServer(async (origin) => { - const response = await request(origin); - assert.equal(response.status, 200); - assert.equal(response.headers.get("cache-control"), "no-store"); - assert.deepEqual(await response.json(), { - token: "ghs_synthetic_3001", - expires_at: "2030-01-01T00:00:00Z", - repository_id: 3001, - }); + for (const body of [{ repository: "example-org/alpha" }, { repository: "Example-Org/Alpha" }, { repository_id: 3001 }]) { + 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_3001", + expires_at: "2030-01-01T00:00:00Z", + repository_id: 3001, + repository: "example-org/alpha", + }); + } }); }); @@ -171,19 +186,65 @@ test("rejects media type and credentials before reading a token body", async () }); }); -test("rejects cross-Workspace repository access before calling GitHub", async () => { +test("rejects a repository of another owner by name before any GitHub traffic", async () => { let calls = 0; + const count = async () => { + calls += 1; + throw new Error("must not be called"); + }; await withServer( async (origin) => { - const response = await request(origin, { repositoryId: 3002 }); - assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "repository_denied" }); + for (const repository of ["other-org/alpha", "example-org-two/alpha", "Other-Org/beta"]) { + const response = await request(origin, { body: { repository } }); + assert.equal(response.status, 403, repository); + assert.deepEqual(await response.json(), { error: "repository_denied" }); + } assert.equal(calls, 0); }, - async () => { - calls += 1; - throw new Error("must not be called"); + count, + count, + ); +}); + +test("rejects every body other than exactly one repository form with 400 and no GitHub traffic", async () => { + let calls = 0; + const count = async () => { + calls += 1; + throw new Error("must not be called"); + }; + await withServer( + async (origin) => { + for (const rawBody of [ + "{", + "[]", + "null", + "\"example-org/alpha\"", + "{}", + JSON.stringify({ repository: "example-org/alpha", repository_id: 3001 }), + JSON.stringify({ repository_id: 3001, padding: "x" }), + JSON.stringify({ repository_id: "3001" }), + JSON.stringify({ repository_id: 0 }), + JSON.stringify({ repository_id: -1 }), + JSON.stringify({ repository_id: 1.5 }), + JSON.stringify({ repository_id: 2 ** 53 }), + JSON.stringify({ repository: "example-org" }), + JSON.stringify({ repository: "example-org/alpha/extra" }), + JSON.stringify({ repository: "example-org/.." }), + JSON.stringify({ repository: "example-org/." }), + JSON.stringify({ repository: "example-org/al%2Fpha" }), + JSON.stringify({ repository: "https://github.com/example-org/alpha" }), + JSON.stringify({ repository: " example-org/alpha" }), + JSON.stringify({ repository: 3001 }), + JSON.stringify({ full_name: "example-org/alpha" }), + ]) { + const response = await request(origin, { rawBody }); + assert.equal(response.status, 400, rawBody); + assert.deepEqual(await response.json(), { error: "invalid_request" }); + } + assert.equal(calls, 0); }, + count, + count, ); }); @@ -219,11 +280,7 @@ test("fails closed when GitHub stops issuing a repository token", async () => { ); }); -test("policy rejects mutable-name drift and ambiguous Workspace credentials", () => { - const wrongOwner = policyFixture(); - wrongOwner.repositories[0].full_name = "other-org/alpha"; - assert.throws(() => parsePolicy(wrongOwner), /asserted owner/); - +test("policy rejects ambiguous Workspace credentials and incomplete permissions", () => { const duplicateSecret = policyFixture(); duplicateSecret.workspaces[1].credential_file = duplicateSecret.workspaces[0].credential_file; assert.throws(() => parsePolicy(duplicateSecret), /credential files must be unique/); @@ -272,179 +329,57 @@ function testPrivateKey() { }); } -test("verifies the exact live installation and immutable repository set", async () => { - const calls = []; - const policy = parsePolicy(policyFixture()); - const fetchImpl = async (url, init) => { - const path = new URL(url).pathname + new URL(url).search; - calls.push({ path, method: init.method, body: init.body && JSON.parse(init.body) }); - if (path === "/app/installations/2001" && init.method === "GET") { - return jsonResponse({ - app_id: 42, - app_slug: "example-app", - account: { id: 1001, login: "example-org" }, - target_type: "Organization", - repository_selection: "selected", - permissions: { - actions: "write", - checks: "read", - contents: "write", - members: "read", - metadata: "read", - pull_requests: "write", - }, - }); - } - if (path === "/app/installations/2001/access_tokens" && init.method === "POST") { - assert.deepEqual(JSON.parse(init.body), { permissions: { contents: "read" } }); - return jsonResponse({ token: "ghs_repository_probe" }); - } - if (path === "/installation/repositories?per_page=100&page=1" && init.method === "GET") { - assert.equal(init.headers.Authorization, "Bearer ghs_repository_probe"); - return jsonResponse({ - total_count: 2, - repositories: [ - { id: 3001, full_name: "example-org/alpha" }, - { id: 3002, full_name: "example-org/beta" }, - ], - }); - } - if (path === "/installation/token" && init.method === "DELETE") { - return jsonResponse(null, 204); - } - throw new Error(`unexpected request ${init.method} ${path}`); - }; - const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); - await github.verifyPolicy(policy); - assert.equal(calls.at(-1).path, "/installation/token"); -}); - -test("accepts an all-repository installation without authorizing unlisted repositories", async () => { - const fixture = policyFixture(); - fixture.installation_repository_selection = "all"; - const policy = parsePolicy(fixture); - const fetchImpl = async (url, init) => { - const path = new URL(url).pathname + new URL(url).search; - if (path === "/app/installations/2001" && init.method === "GET") { - return jsonResponse({ - app_id: 42, - app_slug: "example-app", - account: { id: 1001, login: "example-org" }, - target_type: "Organization", - repository_selection: "all", - permissions: policy.installation_permissions, - }); - } - if (path === "/app/installations/2001/access_tokens" && init.method === "POST") { - assert.deepEqual(JSON.parse(init.body), { permissions: { contents: "read" } }); - return jsonResponse({ token: "ghs_repository_probe" }); - } - if (path === "/installation/repositories?per_page=100&page=1" && init.method === "GET") { - return jsonResponse({ - total_count: 3, - repositories: [ - { id: 3001, full_name: "example-org/alpha" }, - { id: 3002, full_name: "example-org/beta" }, - { id: 3999, full_name: "example-org/not-broker-authorized" }, - ], - }); - } - if (path === "/installation/token" && init.method === "DELETE") { - return jsonResponse(null, 204); - } - throw new Error(`unexpected request ${init.method} ${path}`); - }; - const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); - await github.verifyPolicy(policy); - - const server = createBrokerServer({ - policy, - github: { - verifyTeamGrant: grantedTeam, - mintToken: async () => { - throw new Error("must not mint an unlisted repository token"); - }, - }, - readFile: (file) => secretByPath.get(file) ?? "", - realpath: (file) => file, - }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - try { - const address = server.address(); - const response = await request(`http://127.0.0.1:${address.port}`, { - repositoryId: 3999, - }); - assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "repository_denied" }); - } finally { - server.close(); - await once(server, "close"); - } -}); - -test("rejects selection drift and repository-set drift in either installation mode", async () => { - const privateKey = testPrivateKey(); - const installation = (selection) => ({ +function liveInstallation(selection = "selected", overrides = {}) { + return { app_id: 42, app_slug: "example-app", account: { id: 1001, login: "example-org" }, target_type: "Organization", repository_selection: selection, - permissions: parsePolicy(policyFixture()).installation_permissions, - }); - - const allFixture = policyFixture(); - allFixture.installation_repository_selection = "all"; - const allPolicy = parsePolicy(allFixture); - const selectionDrift = createGithubClient({ - appId: "42", - privateKey, - fetchImpl: async () => jsonResponse(installation("selected")), - }); - await assert.rejects( - () => selectionDrift.verifyPolicy(allPolicy), - /identity, selection or permissions differ from policy/, - ); + permissions: { + actions: "write", + checks: "read", + contents: "write", + members: "read", + metadata: "read", + pull_requests: "write", + }, + ...overrides, + }; +} - async function verifyRepositorySet(policy, repositories) { +test("verifies the exact live installation with one read and no repository enumeration", async () => { + for (const selection of ["selected", "all"]) { + const fixture = policyFixture(); + fixture.installation_repository_selection = selection; + const policy = parsePolicy(fixture); + const calls = []; const fetchImpl = async (url, init) => { const path = new URL(url).pathname + new URL(url).search; - if (path === "/app/installations/2001") { - return jsonResponse(installation(policy.installation_repository_selection)); - } - if (path === "/app/installations/2001/access_tokens") { - return jsonResponse({ token: "ghs_repository_probe" }); - } - if (path === "/installation/repositories?per_page=100&page=1") { - return jsonResponse({ total_count: repositories.length, repositories }); - } - if (path === "/installation/token" && init.method === "DELETE") { - return jsonResponse(null, 204); - } + calls.push(`${init.method} ${path}`); + if (path === "/app/installations/2001" && init.method === "GET") return jsonResponse(liveInstallation(selection)); throw new Error(`unexpected request ${init.method} ${path}`); }; - const github = createGithubClient({ appId: "42", privateKey, fetchImpl }); - return github.verifyPolicy(policy); + const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); + await github.verifyPolicy(policy); + assert.deepEqual(calls, ["GET /app/installations/2001"], selection); } +}); - await assert.rejects( - () => - verifyRepositorySet(parsePolicy(policyFixture()), [ - { id: 3001, full_name: "example-org/alpha" }, - { id: 3002, full_name: "example-org/beta" }, - { id: 3999, full_name: "example-org/extra" }, - ]), - /repository grants differ from policy/, - ); - await assert.rejects( - () => - verifyRepositorySet(allPolicy, [ - { id: 3001, full_name: "example-org/alpha" }, - { id: 3999, full_name: "example-org/extra" }, - ]), - /repository grants differ from policy/, - ); +test("rejects selection, owner and target drift of the live installation", async () => { + const privateKey = testPrivateKey(); + const allFixture = policyFixture(); + allFixture.installation_repository_selection = "all"; + for (const [policy, installation] of [ + [parsePolicy(allFixture), liveInstallation("selected")], + [parsePolicy(policyFixture()), liveInstallation("all")], + [parsePolicy(policyFixture()), liveInstallation("selected", { account: { id: 1002, login: "example-org" } })], + [parsePolicy(policyFixture()), liveInstallation("selected", { account: { id: 1001, login: "other-org" } })], + [parsePolicy(policyFixture()), liveInstallation("selected", { target_type: "User" })], + ]) { + const github = createGithubClient({ appId: "42", privateKey, fetchImpl: async () => jsonResponse(installation) }); + await assert.rejects(() => github.verifyPolicy(policy), /identity, selection or permissions differ from policy/); + } }); test("rejects live permission expansion even when required contents access remains", async () => { @@ -800,19 +735,59 @@ test("policy requires exactly one broker Workspace per immutable GitHub Team", ( assert.equal("github_team_slug" in parsed.workspaces[1], false); }); -test("policy retires schema v1 with an explicit migration message", () => { - const legacy = policyFixture(); - legacy.schema_version = "lazurio.github_app_broker.policy.v1"; - assert.throws(() => parsePolicy(legacy), /policy\.v1 is retired; migrate to lazurio\.github_app_broker\.policy\.v2/); +test("policy retires schemas v1 and v2 with an explicit v3 migration message", () => { + const v1 = policyFixture(); + v1.schema_version = "lazurio.github_app_broker.policy.v1"; + assert.throws( + () => parsePolicy(v1), + /policy\.v1 is retired; .*migrate to lazurio\.github_app_broker\.policy\.v3 by deleting `repositories` and every `workspaces\[\]\.repository_ids`; the live Team grant is the scope/, + ); + + const v2 = policyFixture(); + v2.schema_version = "lazurio.github_app_broker.policy.v2"; + v2.repositories = [{ id: 3001, full_name: "example-org/alpha" }]; + v2.workspaces[0].repository_ids = [3001]; + assert.throws( + () => parsePolicy(v2), + /policy\.v2 is retired; migrate to lazurio\.github_app_broker\.policy\.v3 by deleting `repositories` and every `workspaces\[\]\.repository_ids`; the live Team grant is the scope/, + ); const unknown = policyFixture(); - unknown.schema_version = "lazurio.github_app_broker.policy.v3"; + unknown.schema_version = "lazurio.github_app_broker.policy.v4"; assert.throws(() => parsePolicy(unknown), /unsupported policy schema/); }); +test("a half-migrated v3 policy with repositories or repository_ids fails closed", () => { + const withRepositories = policyFixture(); + withRepositories.repositories = [{ id: 3001, full_name: "example-org/alpha" }]; + assert.throws(() => parsePolicy(withRepositories), /v3 must not contain `repositories`; migrate/); + + const emptyRepositories = policyFixture(); + emptyRepositories.repositories = []; + assert.throws(() => parsePolicy(emptyRepositories), /v3 must not contain `repositories`/); + + const withRepositoryIds = policyFixture(); + withRepositoryIds.workspaces[1].repository_ids = [3002]; + assert.throws(() => parsePolicy(withRepositoryIds), /v3 must not contain `workspaces\[1\]\.repository_ids`; migrate/); + + const parsed = parsePolicy(policyFixture()); + assert.equal("repositories" in parsed, false); + assert.equal("repository_ids" in parsed.workspaces[0], false); +}); + +const PROBE_PERMISSIONS = { members: "read", metadata: "read" }; + function teamGrantFetch({ team = { id: 4001, slug: "alpha-team", organization: { id: 1001, login: "example-org" } }, - grant = { id: 3001, full_name: "example-org/alpha", permissions: { admin: false, maintain: false, pull: true, push: true, triage: true } }, + grant = { + id: 3001, + full_name: "example-org/alpha", + owner: { id: 1001, login: "example-org" }, + permissions: { admin: false, maintain: false, pull: true, push: true, triage: true }, + }, + repositories = { + 3001: { id: 3001, full_name: "example-org/alpha", owner: { id: 1001, login: "example-org" } }, + }, calls = [], } = {}) { return async (url, init) => { @@ -824,9 +799,24 @@ function teamGrantFetch({ if (path === "/organizations/1001/team/4001" && init.method === "GET") { return team === null ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse(team); } + // The second Team exists and holds no repository grant. + if (path === "/organizations/1001/team/4002" && init.method === "GET") { + return jsonResponse({ id: 4002, slug: "beta", organization: { id: 1001, login: "example-org" } }); + } + if (path.startsWith("/organizations/1001/team/4002/repos/") && init.method === "GET") { + return jsonResponse({ message: "Not Found" }, 404); + } + const repositoryMatch = path.match(/^\/repositories\/(\d+)$/); + if (repositoryMatch && init.method === "GET") { + const repository = repositories[repositoryMatch[1]]; + return repository ? jsonResponse(repository) : jsonResponse({ message: "Not Found" }, 404); + } if (path === "/organizations/1001/team/4001/repos/example-org/alpha" && init.method === "GET") { return grant === null ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse(grant); } + if (path.startsWith("/organizations/1001/team/4001/repos/") && init.method === "GET") { + return jsonResponse({ message: "Not Found" }, 404); + } if (path === "/installation/token" && init.method === "DELETE") { return jsonResponse(null, 204); } @@ -834,7 +824,7 @@ function teamGrantFetch({ }; } -test("verifies the live Team binding and write-capable grant with a revoked members-only probe", async () => { +test("verifies the live Team binding and write-capable grant by name with a revoked probe", async () => { const policy = parsePolicy(policyFixture()); const calls = []; const github = createGithubClient({ @@ -842,7 +832,7 @@ test("verifies the live Team binding and write-capable grant with a revoked memb privateKey: testPrivateKey(), fetchImpl: teamGrantFetch({ calls }), }); - const grant = await github.verifyTeamGrant(policy, policy.workspaces[0], 3001); + const grant = await github.verifyTeamGrant(policy, policy.workspaces[0], { repository: "example-org/alpha" }); assert.deepEqual(grant, { workspace_id: "alpha-team", github_team_id: 4001, @@ -857,51 +847,130 @@ test("verifies the live Team binding and write-capable grant with a revoked memb "GET /organizations/1001/team/4001/repos/example-org/alpha", "DELETE /installation/token", ]); - assert.deepEqual(calls[0].body, { permissions: { members: "read", metadata: "read" } }); + assert.deepEqual(calls[0].body, { permissions: PROBE_PERMISSIONS }); assert.equal(calls[1].authorization, "Bearer ghs_team_probe"); assert.equal(calls[2].accept, "application/vnd.github.v3.repository+json"); assert.equal(calls[3].authorization, "Bearer ghs_team_probe"); }); -test("refuses with team_grant_missing when the Team is absent, renamed under a new id, or lacks write", async () => { +test("the id form resolves the live coordinate first and costs one extra read", async () => { + const policy = parsePolicy(policyFixture()); + const calls = []; + const github = createGithubClient({ + appId: "42", + privateKey: testPrivateKey(), + fetchImpl: teamGrantFetch({ calls }), + }); + const grant = await github.verifyTeamGrant(policy, policy.workspaces[0], { repository_id: 3001 }); + assert.equal(grant.repository_id, 3001); + assert.equal(grant.full_name, "example-org/alpha"); + assert.deepEqual(calls.map(({ method, path }) => `${method} ${path}`), [ + "POST /app/installations/2001/access_tokens", + "GET /organizations/1001/team/4001", + "GET /repositories/3001", + "GET /organizations/1001/team/4001/repos/example-org/alpha", + "DELETE /installation/token", + ]); + assert.equal(calls[2].authorization, "Bearer ghs_team_probe"); +}); + +test("returns the canonical repository name and id GitHub reports for a differently cased name", async () => { + const policy = parsePolicy(policyFixture()); + const calls = []; + const fetchImpl = async (url, init) => { + const path = new URL(url).pathname; + calls.push(path); + if (path === "/organizations/1001/team/4001/repos/Example-Org/ALPHA") { + return jsonResponse({ + id: 3001, + full_name: "example-org/alpha", + owner: { id: 1001, login: "example-org" }, + permissions: { admin: false, maintain: false, pull: true, push: true, triage: true }, + }); + } + return teamGrantFetch()(url, init); + }; + const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); + const grant = await github.verifyTeamGrant(policy, policy.workspaces[0], { repository: "Example-Org/ALPHA" }); + assert.equal(grant.full_name, "example-org/alpha"); + assert.equal(grant.repository_id, 3001); + 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 () => { const policy = parsePolicy(policyFixture()); const workspace = policy.workspaces[0]; const privateKey = testPrivateKey(); + const writeGrant = (overrides) => ({ + id: 3001, + full_name: "example-org/alpha", + owner: { id: 1001, login: "example-org" }, + permissions: { admin: false, maintain: false, pull: true, push: true, triage: true }, + ...overrides, + }); + const byName = { repository: "example-org/alpha" }; + const byId = { repository_id: 3001 }; const cases = [ - { name: "team absent", fetch: teamGrantFetch({ team: null }), message: /no longer exists/ }, + { name: "team absent", fetch: teamGrantFetch({ team: null }), target: byName, message: /no longer exists/ }, { name: "team id differs", fetch: teamGrantFetch({ team: { id: 4999, slug: "alpha-team", organization: { id: 1001, login: "example-org" } } }), + target: byName, message: /identity differs from policy/, }, { name: "team belongs to another organization", fetch: teamGrantFetch({ team: { id: 4001, slug: "alpha-team", organization: { id: 1002, login: "other-org" } } }), + target: byName, message: /identity differs from policy/, }, { name: "asserted slug drifted", fetch: teamGrantFetch({ team: { id: 4001, slug: "renamed-team", organization: { id: 1001, login: "example-org" } } }), + target: byName, message: /identity differs from policy/, }, - { name: "no grant", fetch: teamGrantFetch({ grant: null }), message: /no live grant on repository example-org\/alpha/ }, + { name: "no grant by name", fetch: teamGrantFetch({ grant: null }), target: byName, message: /no live grant on repository example-org\/alpha/ }, + { name: "no grant by id", fetch: teamGrantFetch({ grant: null }), target: byId, message: /no live grant on repository example-org\/alpha/ }, { - name: "read-only grant", - fetch: teamGrantFetch({ grant: { id: 3001, full_name: "example-org/alpha", permissions: { admin: false, maintain: false, pull: true, push: false, triage: true } } }), + name: "repository not in the Team (cross-repository)", + fetch: teamGrantFetch(), + target: { repository: "example-org/beta" }, + message: /no live grant on repository example-org\/beta/, + }, + { 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 } }) }), + target: byName, + message: /not write-capable/, + }, + { + name: "triage-only grant", + fetch: teamGrantFetch({ grant: writeGrant({ permissions: { admin: false, maintain: false, pull: true, push: false, triage: true } }) }), + target: byId, message: /not write-capable/, }, { - name: "grant on a different repository id", - fetch: teamGrantFetch({ grant: { id: 3999, full_name: "example-org/alpha", permissions: { admin: true, maintain: true, pull: true, push: true, triage: true } } }), + name: "id form grant returns a different repository id", + fetch: teamGrantFetch({ grant: writeGrant({ id: 3999 }) }), + target: byId, + message: /not write-capable/, + }, + { + name: "grant without a canonical full name", + fetch: teamGrantFetch({ grant: writeGrant({ full_name: undefined }) }), + target: byName, message: /not write-capable/, }, { name: "204 without permissions payload", - fetch: teamGrantFetch({ grant: undefined }), + fetch: teamGrantFetch(), + target: byName, message: /not write-capable/, }, ]; - for (const { name, fetch: fetchImpl, message } of cases) { + for (const { name, fetch: fetchImpl, target, message } of cases) { const calls = []; const wrapped = async (url, init) => { calls.push(`${init.method} ${new URL(url).pathname}`); @@ -912,7 +981,7 @@ test("refuses with team_grant_missing when the Team is absent, renamed under a n }; const github = createGithubClient({ appId: "42", privateKey, fetchImpl: wrapped }); await assert.rejects( - () => github.verifyTeamGrant(policy, workspace, 3001), + () => github.verifyTeamGrant(policy, workspace, target), (error) => { assert.equal(error.code, "team_grant_missing", name); assert.match(error.message, message, name); @@ -924,6 +993,61 @@ test("refuses with team_grant_missing when the Team is absent, renamed under a n } }); +test("refuses a repository of another owner with repository_denied in both forms", async () => { + const policy = parsePolicy(policyFixture()); + const workspace = policy.workspaces[0]; + const privateKey = testPrivateKey(); + const foreign = { id: 9001, full_name: "other-org/alpha", owner: { id: 1002, login: "other-org" } }; + const cases = [ + { + name: "id form resolves to a public repository of another owner", + fetch: teamGrantFetch({ repositories: { 9001: foreign } }), + target: { repository_id: 9001 }, + reads: ["GET /repositories/9001"], + }, + { + name: "grant readback reports another owner", + fetch: teamGrantFetch({ + grant: { + id: 3001, + full_name: "example-org/alpha", + owner: { id: 1002, login: "example-org" }, + permissions: { admin: true, maintain: true, pull: true, push: true, triage: true }, + }, + }), + target: { repository: "example-org/alpha" }, + reads: ["GET /organizations/1001/team/4001/repos/example-org/alpha"], + }, + { + name: "direct client call with a name of another owner", + fetch: teamGrantFetch(), + target: { repository: "other-org/alpha" }, + reads: [], + }, + ]; + for (const { name, fetch: fetchImpl, target, reads } of cases) { + const calls = []; + const github = createGithubClient({ + appId: "42", + privateKey, + fetchImpl: async (url, init) => { + calls.push(`${init.method} ${new URL(url).pathname}`); + return fetchImpl(url, init); + }, + }); + await assert.rejects( + () => github.verifyTeamGrant(policy, workspace, target), + (error) => { + assert.equal(error.code, "repository_denied", name); + return true; + }, + ); + for (const read of reads) assert.ok(calls.includes(read), `${name} reads ${read}`); + assert.equal(calls.some((call) => call.includes("/repos/other-org/")), false, name); + assert.equal(calls.at(-1), "DELETE /installation/token", `${name} must revoke the probe`); + } +}); + test("accepts maintain and admin grants and distinguishes outages from refusals", async () => { const policy = parsePolicy(policyFixture()); const privateKey = testPrivateKey(); @@ -934,9 +1058,11 @@ test("accepts maintain and admin grants and distinguishes outages from refusals" const github = createGithubClient({ appId: "42", privateKey, - fetchImpl: teamGrantFetch({ grant: { id: 3001, full_name: "example-org/alpha", permissions } }), + 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], 3001)).role, role); + assert.equal((await github.verifyTeamGrant(policy, policy.workspaces[0], { repository_id: 3001 })).role, role); } const outage = createGithubClient({ @@ -948,15 +1074,9 @@ test("accepts maintain and admin grants and distinguishes outages from refusals" }, }); await assert.rejects( - () => outage.verifyTeamGrant(policy, policy.workspaces[0], 3001), + () => outage.verifyTeamGrant(policy, policy.workspaces[0], { repository: "example-org/alpha" }), (error) => error.code === undefined && /HTTP 503/.test(error.message), ); - - const outsidePolicy = createGithubClient({ appId: "42", privateKey, fetchImpl: teamGrantFetch() }); - await assert.rejects( - () => outsidePolicy.verifyTeamGrant(policy, policy.workspaces[0], 3002), - (error) => error.code === undefined && /outside the Workspace alpha-team policy/.test(error.message), - ); }); test("mints only after a live Team grant and refuses with team_grant_missing otherwise", async () => { @@ -965,16 +1085,16 @@ test("mints only after a live Team grant and refuses with team_grant_missing oth const server = createBrokerServer({ policy: parsePolicy(policyFixture()), github: { - verifyTeamGrant: async (_policy, workspace, repositoryId) => { + verifyTeamGrant: async (policy, workspace, target) => { checks += 1; assert.equal(workspace.github_team_id, 4001); - assert.equal(repositoryId, 3001); - if (workspace.id === "alpha-team" && checks > 1) { + assert.deepEqual(target, { repository: "example-org/alpha" }); + if (checks > 1) { const error = new Error("grant revoked"); error.code = "team_grant_missing"; throw error; } - return { role: "write" }; + return grantedTeam(policy, workspace, target); }, mintToken: async (_policy, repositoryId) => { mints += 1; @@ -1009,7 +1129,101 @@ test("mints only after a live Team grant and refuses with team_grant_missing oth } }); -test("Team grant outages fail closed as token_unavailable and cross-Workspace checks never run", async () => { +/** Runs the real core client behind the Node server with a synthetic GitHub. */ +async function withLiveCoreServer(fetchImpl, run) { + const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); + const server = createBrokerServer({ + policy: parsePolicy(policyFixture()), + github, + readFile: (file) => secretByPath.get(file) ?? "", + realpath: (file) => file, + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + await run(`http://127.0.0.1:${server.address().port}`); + } finally { + server.close(); + await once(server, "close"); + } +} + +function mintingFetch(calls, teamFetch = teamGrantFetch()) { + 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; + 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" }, + }); + } + return teamFetch(url, init); + }; +} + +test("end to end: a Team-granted repository mints for its id; other Teams, repositories and owners mint nothing", async () => { + const calls = []; + await withLiveCoreServer(mintingFetch(calls), 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.repository_id, 3001); + assert.equal(json.repository, "example-org/alpha"); + } + const mints = () => calls.filter((call) => call === "POST /app/installations/2001/access_tokens").length; + const before = mints(); + + // Cross-repository: alpha's Team holds no grant on beta. + const crossRepository = await request(origin, { body: { repository: "example-org/beta" } }); + assert.equal(crossRepository.status, 403); + assert.deepEqual(await crossRepository.json(), { error: "team_grant_missing" }); + + // Cross-Workspace: beta's Team (4002) holds no grant 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.ok(calls.includes("GET /organizations/1001/team/4002/repos/example-org/alpha")); + + // Foreign owner by id: GitHub resolves a public repository of another owner. + const trafficBefore = calls.length; + const foreignName = await request(origin, { body: { repository: "other-org/alpha" } }); + assert.equal(foreignName.status, 403); + assert.equal(calls.length, trafficBefore, "a foreign owner name never reaches GitHub"); + + // Every refusal minted only its revoked probe, never a scoped repository token. + assert.equal( + calls.filter((call) => call === "DELETE /installation/token").length, + mints() - 2, + "each probe is revoked; only the two accepted requests minted a scoped token", + ); + assert.equal(before, 4); + }); +}); + +test("end to end: an id of another owner is refused with repository_denied and mints nothing", async () => { + const calls = []; + const foreign = teamGrantFetch({ + repositories: { 9001: { id: 9001, full_name: "other-org/public", owner: { id: 1002, login: "other-org" } } }, + }); + await withLiveCoreServer(mintingFetch(calls, foreign), async (origin) => { + const response = await request(origin, { body: { repository_id: 9001 } }); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "repository_denied" }); + assert.equal(calls.filter((call) => call.startsWith("GET /organizations/1001/team/4001/repos/")).length, 0); + assert.equal(calls.filter((call) => call === "POST /app/installations/2001/access_tokens").length, 1); + assert.equal(calls.at(-1), "DELETE /installation/token"); + }); +}); + +test("Team grant outages fail closed as token_unavailable and foreign owners never reach the gate", async () => { let checks = 0; await withServer( async (origin) => { @@ -1018,7 +1232,7 @@ test("Team grant outages fail closed as token_unavailable and cross-Workspace ch assert.deepEqual(await outage.json(), { error: "token_unavailable" }); assert.equal(checks, 1); - const denied = await request(origin, { repositoryId: 3002 }); + const denied = await request(origin, { body: { repository: "other-org/alpha" } }); assert.equal(denied.status, 403); assert.deepEqual(await denied.json(), { error: "repository_denied" }); assert.equal(checks, 1); @@ -1033,34 +1247,45 @@ test("Team grant outages fail closed as token_unavailable and cross-Workspace ch ); }); -test("policy check --live reads back every Team grant and prints a secret-free table", async () => { +test("refuses a mint whose result names another repository than the Team gate verified", async () => { + await withServer( + async (origin) => { + const response = await request(origin); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "token_unavailable" }); + }, + async () => ({ token: "ghs_synthetic_other", expires_at: "2030-01-01T00:00:00Z", repository_id: 3002 }), + ); +}); + +test("policy check --live lists live Team grants as information and fails only on Team drift", async () => { const policy = parsePolicy(policyFixture()); assert.equal( formatPolicySummary(policy), [ - "lazurio.github_app_broker.policy.v2 owner=example-org installation=2001", - "WORKSPACE TEAM_ID TEAM_SLUG REPOSITORIES", - "alpha-team 4001 alpha-team 1", - "beta-team 4002 - 1", + "lazurio.github_app_broker.policy.v3 owner=example-org installation=2001", + "WORKSPACE TEAM_ID TEAM_SLUG", + "alpha-team 4001 alpha-team", + "beta-team 4002 -", "", ].join("\n"), ); 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: "" }, + ], + "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" }, + ], + }; const github = { verifyPolicy: async () => probes.push("verify"), readWorkspaceTeamGrants: async (_policy, workspace) => { probes.push(`grants:${workspace.id}`); - return workspace.repository_ids.map((repositoryId) => ({ - workspace_id: workspace.id, - github_team_id: workspace.github_team_id, - github_team_slug: workspace.github_team_slug ?? "beta", - repository_id: repositoryId, - full_name: policy.repositories.find(({ id }) => id === repositoryId).full_name, - role: workspace.id === "alpha-team" ? "write" : "none", - status: workspace.id === "alpha-team" ? "ok" : "refused", - detail: workspace.id === "alpha-team" ? "" : "Workspace beta-team GitHub Team has no live grant on repository example-org/beta", - })); + return rowsFor[workspace.id]; }, }; const result = await checkPolicyLive({ policy, github }); @@ -1072,51 +1297,99 @@ test("policy check --live reads back every Team grant and prints a secret-free t [ "WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL", "alpha-team 4001 alpha-team 3001 example-org/alpha write ok", - "beta-team 4002 beta 3002 example-org/beta none refused Workspace beta-team GitHub Team has no live grant on repository example-org/beta", - "2 grants checked, 1 ok, 1 refused", + "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", "", ].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" }, + ]; + 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/); + 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 uses one probe per Workspace and reports each repository", async () => { - const fixture = policyFixture(); - fixture.workspaces[0].repository_ids = [3001, 3002]; - const policy = parsePolicy(fixture); +test("readWorkspaceTeamGrants paginates the Team's write-capable repositories with one probe", async () => { + const policy = parsePolicy(policyFixture()); const calls = []; + const writable = { admin: false, maintain: false, pull: true, push: true, triage: true }; + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + id: 5000 + index, + full_name: `example-org/repo-${index}`, + owner: { id: 1001, login: "example-org" }, + permissions: index === 0 ? { admin: false, maintain: false, pull: true, push: false, triage: true } : writable, + })); + const secondPage = [ + { 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 } }, + ]; const fetchImpl = async (url, init) => { - const path = new URL(url).pathname; + const path = new URL(url).pathname + new URL(url).search; calls.push(`${init.method} ${path}`); - if (path === "/app/installations/2001/access_tokens") return jsonResponse({ token: "ghs_team_probe" }); + if (path === "/app/installations/2001/access_tokens") { + assert.deepEqual(JSON.parse(init.body), { permissions: PROBE_PERMISSIONS }); + return jsonResponse({ token: "ghs_team_probe" }); + } if (path === "/organizations/1001/team/4001") { return jsonResponse({ id: 4001, slug: "alpha-team", organization: { id: 1001, login: "example-org" } }); } - if (path === "/organizations/1001/team/4001/repos/example-org/alpha") { - return jsonResponse({ id: 3001, full_name: "example-org/alpha", permissions: { admin: false, maintain: true, pull: true, push: true, triage: true } }); - } - if (path === "/organizations/1001/team/4001/repos/example-org/beta") return jsonResponse({ message: "Not Found" }, 404); - if (path === "/organizations/1001/team/4002") return jsonResponse({ message: "Not Found" }, 404); + if (path === "/organizations/1001/team/4001/repos?per_page=100&page=1") return jsonResponse(firstPage); + if (path === "/organizations/1001/team/4001/repos?per_page=100&page=2") return jsonResponse(secondPage); + if (path === "/organizations/1001/team/4002") return jsonResponse({ id: 4002, slug: "beta", organization: { id: 1001, login: "example-org" } }); + if (path === "/organizations/1001/team/4002/repos?per_page=100&page=1") return jsonResponse([]); if (path === "/installation/token") return jsonResponse(null, 204); throw new Error(`unexpected request ${init.method} ${path}`); }; const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); const alpha = await github.readWorkspaceTeamGrants(policy, policy.workspaces[0]); - assert.deepEqual(alpha.map(({ repository_id, role, status }) => [repository_id, role, status]), [ - [3001, "maintain", "ok"], - [3002, "none", "refused"], - ]); - assert.match(alpha[1].detail, /no live grant on repository example-org\/beta/); + 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), { + workspace_id: "alpha-team", + github_team_id: 4001, + github_team_slug: "alpha-team", + repository_id: 3001, + full_name: "example-org/alpha", + role: "maintain", + status: "ok", + detail: "", + }); + assert.equal(alpha.some(({ repository_id }) => repository_id === 9001 || repository_id === 3002 || repository_id === 5000), 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, github_team_slug, status }) => [repository_id, github_team_slug, status]), [ - [3002, "?", "refused"], + assert.deepEqual(beta.map(({ repository_id, status, detail }) => [repository_id, status, detail]), [ + ["-", "ok", "Team holds no write-capable repository grant"], + ]); +}); + +test("readWorkspaceTeamGrants reports a missing Team as one refused row without listing repositories", async () => { + const policy = parsePolicy(policyFixture()); + const calls = []; + const fetchImpl = async (url, init) => { + const path = new URL(url).pathname + new URL(url).search; + calls.push(`${init.method} ${path}`); + if (path === "/app/installations/2001/access_tokens") return jsonResponse({ token: "ghs_team_probe" }); + if (path === "/organizations/1001/team/4002") return jsonResponse({ message: "Not Found" }, 404); + if (path === "/installation/token") return jsonResponse(null, 204); + throw new Error(`unexpected request ${init.method} ${path}`); + }; + const github = createGithubClient({ appId: "42", privateKey: testPrivateKey(), fetchImpl }); + const rows = await github.readWorkspaceTeamGrants(policy, policy.workspaces[1]); + assert.deepEqual(rows.map(({ github_team_slug, status }) => [github_team_slug, status]), [["?", "refused"]]); + assert.match(rows[0].detail, /Team 4002 no longer exists/); + assert.deepEqual(calls, [ + "POST /app/installations/2001/access_tokens", + "GET /organizations/1001/team/4002", + "DELETE /installation/token", ]); - assert.match(beta[0].detail, /Team 4002 no longer exists/); }); diff --git a/test/brokered-gh.test.mjs b/test/brokered-gh.test.mjs index c698a60..a47c83b 100644 --- a/test/brokered-gh.test.mjs +++ b/test/brokered-gh.test.mjs @@ -2,11 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import * as brokerClient from "../adapter/broker-client.mjs"; import { - firstPolicyRepository, normalizeRepository, parseBrokerClientConfig, - parseRepositoryPolicy, repositoryIdentityKey, requestGitHubAppToken, requireHttpsGitHubOrigin, @@ -25,10 +24,6 @@ import { const TOKEN = "ghs_secret_token_that_must_never_be_rendered"; const NOW = Date.parse("2026-08-18T12:00:00Z"); const environment = { - GITHUB_REPOSITORY_POLICY_JSON: JSON.stringify({ - "Example/Bravo": 202, - "Example/Alpha": 101, - }), GITHUB_TOKEN_BROKER_URL: "http://github-token-broker:8787", GITHUB_BROKER_WORKSPACE_ID: "example-management", GITHUB_BROKER_CLIENT_CREDENTIAL_FILE: "/run/secrets/github_broker_client_token", @@ -38,28 +33,22 @@ function response(body, { ok = true } = {}) { return { ok, json: async () => body }; } -function validToken(repositoryId = 101) { +function validToken(repositoryId = 101, repository = "Example/Alpha") { return { token: TOKEN, repository_id: repositoryId, + repository, expires_at: "2026-08-18T13:00:00Z", }; } -test("validates and deterministically sorts the Team repository policy", () => { - assert.deepEqual(parseRepositoryPolicy(environment), [ - { repository: "Example/Alpha", repositoryId: 101 }, - { repository: "Example/Bravo", repositoryId: 202 }, - ]); - assert.equal(firstPolicyRepository(environment), "Example/Alpha"); +test("normalizes repository coordinates and holds no client-side repository policy", () => { assert.equal(normalizeRepository("https://github.com/Example/Alpha.git"), "Example/Alpha"); assert.equal(repositoryIdentityKey("github.com/Example/Alpha"), "example/alpha"); assert.equal(requireHttpsGitHubOrigin("https://github.com/Example/Alpha.git"), "Example/Alpha"); assert.throws(() => requireHttpsGitHubOrigin("git@github.com:Example/Alpha.git"), /brokered HTTPS/); - assert.throws( - () => parseRepositoryPolicy({ GITHUB_REPOSITORY_POLICY_JSON: '{"Example/Alpha":101,"example/alpha":202}' }), - /policy is invalid/, - ); + assert.equal("parseRepositoryPolicy" in brokerClient, false); + assert.equal("firstPolicyRepository" in brokerClient, false); }); test("accepts only the exact T3 discovery and viewer envelopes", () => { @@ -109,11 +98,12 @@ test("accepts only the exact T3 discovery and viewer envelopes", () => { ); }); -test("requests one fresh repo-scoped token without exposing the Workspace credential", async () => { +test("requests one fresh repo-scoped token by name without exposing the Workspace credential", async () => { let captured; const result = await requestGitHubAppToken({ repository: "example/alpha", - environment, + // A leftover v0.9.0 client policy is ignored: it neither widens nor narrows the request. + environment: { ...environment, GITHUB_REPOSITORY_POLICY_JSON: JSON.stringify({ "Example/Bravo": 202 }) }, readFile: () => "workspace-credential-with-at-least-32-bytes", fetchImpl: async (url, options) => { captured = { url, options }; @@ -121,9 +111,14 @@ test("requests one fresh repo-scoped token without exposing the Workspace creden }, now: () => NOW, }); - assert.equal(result.repositoryId, 101); + assert.deepEqual({ ...result }, { + token: TOKEN, + repository: "Example/Alpha", + repositoryId: 101, + expiresAt: "2026-08-18T13:00:00Z", + }); assert.equal(captured.url, "http://github-token-broker:8787/v1/token"); - assert.deepEqual(JSON.parse(captured.options.body), { repository_id: 101 }); + assert.deepEqual(JSON.parse(captured.options.body), { repository: "example/alpha" }); assert.equal(captured.options.headers["X-Lazurio-Workspace-ID"], "example-management"); assert.equal(captured.options.headers.Authorization.startsWith("Bearer "), true); }); @@ -230,7 +225,34 @@ test("a mounted remote config cannot be bypassed by removing environment fields" ); }); -test("broker refusal, timeout, malformed policy and malformed response fail closed", async () => { +test("rejects a broker response for another repository or without a canonical identity", async () => { + const shared = { + repository: "Example/Alpha", + environment, + readFile: () => "workspace-credential-with-at-least-32-bytes", + now: () => NOW, + }; + for (const body of [ + validToken(202, "Example/Bravo"), + validToken(101, "Other/Alpha"), + validToken(101, "https://github.com/Example/Alpha"), + validToken(101, "Example/Alpha.git"), + { ...validToken(), repository: undefined }, + validToken(0), + validToken(-1), + validToken("101"), + { ...validToken(), repository_id: undefined }, + { ...validToken(), expires_at: "2026-08-18T14:30:00Z" }, + ]) { + await assert.rejects( + () => requestGitHubAppToken({ ...shared, fetchImpl: async () => response(body) }), + /invalid scoped response/, + JSON.stringify(body), + ); + } +}); + +test("broker refusal, timeout and malformed response fail closed", async () => { const shared = { repository: "Example/Alpha", environment, @@ -249,10 +271,6 @@ test("broker refusal, timeout, malformed policy and malformed response fail clos () => requestGitHubAppToken({ ...shared, fetchImpl: async () => response({ token: TOKEN }) }), /invalid scoped response/, ); - await assert.rejects( - () => requestGitHubAppToken({ ...shared, environment: { ...environment, GITHUB_REPOSITORY_POLICY_JSON: "{" } }), - /policy is invalid/, - ); }); test("auth status performs an uncached live proof and emits the official host schema", async () => { @@ -262,6 +280,7 @@ test("auth status performs an uncached live proof and emits the official host sc const input = { args: ["auth", "status", "--json", "hosts"], environment, + readOrigin: () => "https://github.com/Example/Alpha.git", requestToken: async ({ repository }) => { proofs += 1; assert.equal(repository, "Example/Alpha"); @@ -290,9 +309,11 @@ test("the exact viewer probe reports the machine actor only after a live proof", assert.equal( await runBrokeredGh({ args: ["api", "user", "--jq", ".login"], - environment, - requestToken: async () => { + environment: { ...environment, GH_REPO: "Example/Bravo" }, + readOrigin: () => null, + requestToken: async ({ repository }) => { calls += 1; + assert.equal(repository, "Example/Bravo"); return { token: TOKEN }; }, writeStdout: (value) => stdout.push(value), @@ -303,6 +324,30 @@ test("the exact viewer probe reports the machine actor only after a live proof", assert.deepEqual(stdout, [`${HOSTED_GITHUB_ACTOR}\n`]); }); +test("outside a Team repository the discovery proofs fail closed without a broker call", async () => { + const stdout = []; + const stderr = []; + let calls = 0; + const shared = { + environment, + readOrigin: () => null, + requestToken: async () => { + calls += 1; + return { token: TOKEN }; + }, + writeStdout: (value) => stdout.push(value), + writeStderr: (value) => stderr.push(value), + }; + assert.equal(await runBrokeredGh({ ...shared, args: ["auth", "status", "--json", "hosts"] }), 1); + assert.deepEqual(JSON.parse(stdout.at(-1)), { hosts: {} }); + assert.equal(stderr.at(-1), "Brokered gh authentication proof failed.\n"); + await assert.rejects( + () => runBrokeredGh({ ...shared, args: ["api", "user", "--jq", ".login"] }), + /identity proof failed/, + ); + assert.equal(calls, 0); +}); + test("repository operations accept T3 lowercase identity and pass only an env-local token", async () => { let child; const exitCode = await runBrokeredGh({ @@ -398,7 +443,7 @@ test("local help and version invoke real gh without any ambient credential", asy assert.equal(childEnvironment.GITHUB_TOKEN, undefined); }); -test("repo selection, policy and origin disagreements fail closed", () => { +test("repo selection and origin disagreements fail closed; no target means no default", () => { assert.equal( resolveGhRepository({ args: ["pr", "list", "--repo=Example/Alpha"], @@ -424,6 +469,14 @@ test("repo selection, policy and origin disagreements fail closed", () => { }), /does not match/, ); + assert.equal( + resolveGhRepository({ args: ["pr", "list", "-R", "Example/Bravo"], environment: {}, readOrigin: () => null }), + "Example/Bravo", + ); + assert.throws( + () => resolveGhRepository({ args: ["pr", "list"], environment: {}, readOrigin: () => null }), + /run gh inside a Team repository checkout or pass --repo OWNER\/REPO/, + ); }); test("the gh config boundary is read-only and environment sanitation is stable", () => { diff --git a/test/worker.test.mjs b/test/worker.test.mjs index aeef75b..9beafa8 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import crypto from "node:crypto"; import test from "node:test"; -import { TeamGrantError } from "../src/core.mjs"; +import { RepositoryDeniedError, TeamGrantError } from "../src/core.mjs"; import { createWorkerEntrypoint, createWorkerJwtSigner, @@ -15,7 +15,7 @@ const BETA_CREDENTIAL = "beta-secret-value-with-at-least-32-bytes"; function policyFixture() { return { - schema_version: "lazurio.github_app_broker.policy.v2", + schema_version: "lazurio.github_app_broker.policy.v3", github_app: { id: 42, slug: "example-app" }, github_owner: { id: 1001, login: "example-org" }, installation_id: 2001, @@ -28,22 +28,16 @@ function policyFixture() { metadata: "read", pull_requests: "write", }, - repositories: [ - { id: 3001, full_name: "example-org/alpha" }, - { id: 3002, full_name: "example-org/beta" }, - ], workspaces: [ { id: "alpha-team", github_team_id: 4001, credential_file: "/run/secrets/workspace-alpha", - repository_ids: [3001], }, { id: "beta-team", github_team_id: 4002, credential_file: "/run/secrets/workspace-beta", - repository_ids: [3002], }, ], }; @@ -60,19 +54,32 @@ function environment(overrides = {}) { }; } -function workerFixture({ teamGrant = async () => ({ role: "write" }) } = {}) { - const calls = { verify: 0, teamGrant: 0, mint: 0 }; +const grantedAlpha = async (_policy, _workspace, target) => { + if (target.repository_id !== undefined && target.repository_id !== 3001) { + throw new TeamGrantError("no grant"); + } + 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" }; +}; + +function workerFixture({ teamGrant = grantedAlpha } = {}) { + const calls = { verify: 0, teamGrant: 0, mint: 0, order: [] }; const worker = createWorkerEntrypoint({ createGithub: () => ({ async verifyPolicy() { calls.verify += 1; + calls.order.push("installation"); }, - async verifyTeamGrant(policy, workspace, repositoryId) { + async verifyTeamGrant(policy, workspace, target) { calls.teamGrant += 1; - return teamGrant(policy, workspace, repositoryId); + calls.order.push("team"); + return teamGrant(policy, workspace, target); }, async mintToken(_policy, repositoryId) { calls.mint += 1; + calls.order.push("mint"); return { token: `ghs_synthetic_${repositoryId}`, expires_at: "2030-01-01T00:00:00Z", @@ -88,7 +95,7 @@ function tokenRequest({ origin = "https://broker.example.test", workspace = "alpha-team", credential = ALPHA_CREDENTIAL, - repositoryId = 3001, + body = { repository: "example-org/alpha" }, } = {}) { return new Request(`${origin}/v1/token`, { method: "POST", @@ -97,10 +104,14 @@ function tokenRequest({ "Content-Type": "application/json", "X-Lazurio-Workspace-ID": workspace, }, - body: JSON.stringify({ repository_id: repositoryId }), + body: typeof body === "string" ? body : JSON.stringify(body), }); } +function countsOf(calls) { + return { verify: calls.verify, teamGrant: calls.teamGrant, mint: calls.mint }; +} + test("maps immutable Workspace ids to independent Worker secret bindings", () => { assert.equal(workspaceCredentialBindingName("blue-team"), "WORKSPACE_CREDENTIAL_BLUE_TEAM"); assert.throws(() => workspaceCredentialBindingName("Blue_Team"), /cannot be mapped/); @@ -111,7 +122,7 @@ test("Worker health validates deployment bindings without calling GitHub", async const response = await worker.fetch(new Request("https://broker.example.test/health"), environment()); assert.equal(response.status, 204); assert.equal(response.headers.get("cache-control"), "no-store"); - assert.deepEqual(calls, { verify: 0, teamGrant: 0, mint: 0 }); + assert.deepEqual(countsOf(calls), { verify: 0, teamGrant: 0, mint: 0 }); }); test("Worker fails closed when policy, key or unique Workspace secrets are unavailable", async () => { @@ -125,7 +136,7 @@ test("Worker fails closed when policy, key or unique Workspace secrets are unava assert.equal(response.status, 503); assert.deepEqual(await response.json(), { error: "configuration_unavailable" }); } - assert.deepEqual(calls, { verify: 0, teamGrant: 0, mint: 0 }); + assert.deepEqual(countsOf(calls), { verify: 0, teamGrant: 0, mint: 0 }); }); test("default Worker runtime rejects a non-PKCS#8 App key before reporting healthy", async () => { @@ -205,34 +216,67 @@ test("Worker rejects request metadata without consuming the body stream", async assert.deepEqual(await response.json(), { error: expectedError }); assert.equal(reads, 0); } - assert.deepEqual(calls, { verify: 0, teamGrant: 0, mint: 0 }); + assert.deepEqual(countsOf(calls), { verify: 0, teamGrant: 0, mint: 0 }); }); -test("Worker denies invalid credentials and repositories before live GitHub verification", async () => { +test("Worker denies invalid credentials, invalid bodies and foreign owners before live GitHub verification", async () => { const { worker, calls } = workerFixture(); const unauthorized = await worker.fetch(tokenRequest({ credential: "wrong-secret-with-at-least-thirty-two-bytes" }), environment()); assert.equal(unauthorized.status, 401); assert.deepEqual(await unauthorized.json(), { error: "workspace_unauthorized" }); - const denied = await worker.fetch(tokenRequest({ repositoryId: 3002 }), environment()); - assert.equal(denied.status, 403); - assert.deepEqual(await denied.json(), { error: "repository_denied" }); - assert.deepEqual(calls, { verify: 0, teamGrant: 0, mint: 0 }); + for (const repository of ["other-org/alpha", "OTHER-ORG/beta"]) { + const denied = await worker.fetch(tokenRequest({ body: { repository } }), environment()); + assert.equal(denied.status, 403); + assert.deepEqual(await denied.json(), { error: "repository_denied" }); + } + + for (const body of [ + "{", + {}, + { repository: "example-org/alpha", repository_id: 3001 }, + { repository_id: "3001" }, + { repository: "example-org/.." }, + ]) { + const invalid = await worker.fetch(tokenRequest({ body }), environment()); + assert.equal(invalid.status, 400, JSON.stringify(body)); + assert.deepEqual(await invalid.json(), { error: "invalid_request" }); + } + assert.deepEqual(countsOf(calls), { verify: 0, teamGrant: 0, mint: 0 }); }); -test("Worker verifies live policy before every fresh one-repository token", async () => { +test("Worker verifies the live installation, then the Team grant, before every fresh token in both forms", async () => { const { worker, calls } = workerFixture(); - for (let attempt = 0; attempt < 2; attempt += 1) { - const response = await worker.fetch(tokenRequest(), environment()); + for (const body of [{ repository: "example-org/alpha" }, { repository_id: 3001 }]) { + const response = await worker.fetch(tokenRequest({ body }), environment()); assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store"); assert.deepEqual(await response.json(), { token: "ghs_synthetic_3001", expires_at: "2030-01-01T00:00:00Z", repository_id: 3001, + repository: "example-org/alpha", }); } - assert.deepEqual(calls, { verify: 2, teamGrant: 2, mint: 2 }); + assert.deepEqual(countsOf(calls), { verify: 2, teamGrant: 2, mint: 2 }); + assert.deepEqual(calls.order, ["installation", "team", "mint", "installation", "team", "mint"]); +}); + +test("Worker refuses cross-repository and foreign-owner ids from the Team gate and mints nothing", async () => { + const { worker, calls } = workerFixture({ + teamGrant: async (policy, workspace, target) => { + if (target.repository_id === 9001) throw new RepositoryDeniedError("another owner"); + return grantedAlpha(policy, workspace, target); + }, + }); + const crossRepository = await worker.fetch(tokenRequest({ body: { repository: "example-org/beta" } }), environment()); + assert.equal(crossRepository.status, 403); + assert.deepEqual(await crossRepository.json(), { error: "team_grant_missing" }); + + const foreignId = await worker.fetch(tokenRequest({ body: { repository_id: 9001 } }), environment()); + assert.equal(foreignId.status, 403); + assert.deepEqual(await foreignId.json(), { error: "repository_denied" }); + assert.deepEqual(countsOf(calls), { verify: 2, teamGrant: 2, mint: 0 }); }); test("Worker rejects query substitution and oversized token bodies", async () => { @@ -244,7 +288,7 @@ test("Worker rejects query substitution and oversized token bodies", async () => "Content-Type": "application/json", "X-Lazurio-Workspace-ID": "alpha-team", }, - body: JSON.stringify({ repository_id: 3001 }), + body: JSON.stringify({ repository: "example-org/alpha" }), }), environment()); assert.equal(query.status, 404); @@ -255,12 +299,12 @@ test("Worker rejects query substitution and oversized token bodies", async () => "Content-Type": "application/json", "X-Lazurio-Workspace-ID": "alpha-team", }, - body: JSON.stringify({ repository_id: 3001, padding: "x".repeat(1024) }), + body: JSON.stringify({ repository: "example-org/alpha", padding: "x".repeat(1024) }), }); const response = await worker.fetch(oversized, environment()); assert.equal(response.status, 502); assert.deepEqual(await response.json(), { error: "token_unavailable" }); - assert.deepEqual(calls, { verify: 0, teamGrant: 0, mint: 0 }); + assert.deepEqual(countsOf(calls), { verify: 0, teamGrant: 0, mint: 0 }); }); test("Worker PKCS#8 signer produces a valid RS256 signature", async () => { @@ -278,10 +322,10 @@ test("Worker PKCS#8 signer produces a valid RS256 signature", async () => { test("Worker refuses with team_grant_missing after live verification and mints nothing", async () => { let attempts = 0; const { worker, calls } = workerFixture({ - teamGrant: async () => { + teamGrant: async (policy, workspace, target) => { attempts += 1; if (attempts > 1) throw new TeamGrantError("grant revoked between mints"); - return { role: "write" }; + return grantedAlpha(policy, workspace, target); }, }); const granted = await worker.fetch(tokenRequest(), environment()); @@ -291,7 +335,7 @@ test("Worker refuses with team_grant_missing after live verification and mints n assert.equal(refused.status, 403); assert.equal(refused.headers.get("cache-control"), "no-store"); assert.deepEqual(await refused.json(), { error: "team_grant_missing" }); - assert.deepEqual(calls, { verify: 2, teamGrant: 2, mint: 1 }); + assert.deepEqual(countsOf(calls), { verify: 2, teamGrant: 2, mint: 1 }); }); test("Worker treats a GitHub client without Team verification as unavailable configuration", async () => { From 75424d5e2b8a5d6c4e34ce60c7eb43d5f967d5ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 3 Oct 2026 13:01:17 +0200 Subject: [PATCH 2/3] docs: policy check rows do not prove installation membership in selected mode Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a1f02aa..863648b 100644 --- a/README.md +++ b/README.md @@ -318,8 +318,10 @@ beta-team 4002 ? - - - refuse 2 Workspaces checked, 1 Teams ok, 1 refused; 2 write-capable repository grants ``` -The repository rows are information: they show what each Team's Environment -can reach right now. A Team with no write-capable grant prints one `ok` row +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 From fdf45e293b10219ca30eccc0e0994cb0528636a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 3 Oct 2026 13:05:57 +0200 Subject: [PATCH 3/3] Workspace connection proof: POST /v1/workspace for discovery outside a checkout Launchpad and T3 run `gh auth status` and `gh api user --jq .login` from the Folder root, which is not a repository. Without a policy default those proofs had no repository and would report GitHub as disconnected. - Broker: `POST /v1/workspace` runs the credential gate, the installation gate and the Team identity read through the revoked probe, mints no repository token and returns the Workspace, Organization and Team. Team missing or drifted is 403 team_grant_missing; GitHub failure is 502. It shares the core handler in the Node and Worker adapters. - Adapter: discovery mints its proof for the checkout or GH_REPO when there is one and otherwise calls `requestWorkspaceProof`; any failure stays fail closed and a failed repository proof never falls back. Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 14 +-- README.md | 31 ++++++- adapter/broker-client.mjs | 76 ++++++++++++---- adapter/brokered-gh.mjs | 40 ++++++--- src/core.mjs | 142 +++++++++++++++++++----------- src/worker.mjs | 8 +- test/broker.test.mjs | 113 +++++++++++++++++++++++- test/brokered-gh.test.mjs | 181 +++++++++++++++++++++++++++++++++++--- test/worker.test.mjs | 83 ++++++++++++++++- 9 files changed, 583 insertions(+), 105 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e775cbd..77e344d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -210,14 +210,18 @@ The official CLI remains responsible for pull requests, REST and GraphQL. T3 Code performs two host-level discovery calls that are not repository API operations. The adapter recognizes only their exact argument envelopes: -- `gh auth status --json hosts` performs a live token proof for the - repository of the current checkout (or `GH_REPO`), discards the token, and - emits the official JSON host shape for `lazurio-for-github[bot]`; +- `gh auth status --json hosts` performs a live proof and emits the official + JSON host shape for `lazurio-for-github[bot]`. Inside a repository checkout + (or with `GH_REPO`) the proof mints and discards a token for that + repository; anywhere else, such as the Folder root, it calls + `POST /v1/workspace`, which runs the credential gate, the installation gate + and the Team identity read through the revoked probe and mints no + repository token; - `gh api user --jq .login` performs the same proof and emits that machine actor without calling GitHub's user endpoint. -A failed proof, including one run outside any Team repository, returns the -official unauthenticated host shape and a non-zero exit. The result is deliberately not cached, so repository or installation +A failed proof returns the official unauthenticated host shape and a non-zero +exit; a failed repository proof never falls back to the Workspace proof. The result is deliberately not cached, so repository or installation revocation affects the next discovery and operation without restarting the Workspace. This host signal never substitutes for per-repository authorization. diff --git a/README.md b/README.md index 863648b..95e4548 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,17 @@ returned, with `Cache-Control: no-store`: {"token": "", "expires_at": "2030-01-01T00:00:00Z", "repository_id": 2345, "repository": "example-organization/example-repository"} ``` +`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 +the installation gate and a read of the Workspace's Team through the same +revoked `members: read` plus `metadata: read` probe. It mints no repository +token and returns, with `Cache-Control: no-store`: + +```json +{"workspace_id": "customer-team", "organization": "example-organization", "github_team_id": 4001, "github_team_slug": "customer-team"} +``` + `GET /health` returns `204` only after startup policy verification has succeeded. @@ -195,6 +206,12 @@ and revocation; it only guarantees that no final repository token exists: | `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. | +`POST /v1/workspace` uses the same codes: `401 workspace_unauthorized` with no +GitHub traffic, `403 team_grant_missing` when the Workspace's Team is gone or +its identity differs from the policy, `502 token_unavailable` on any GitHub +failure and `415` for a non-JSON media type. It never returns `400` or +`repository_denied` because it names no repository. + The Team gate runs live on every mint with a short-lived `members: read` plus `metadata: read` probe token that is revoked immediately afterwards. Its read requests are: @@ -246,6 +263,8 @@ Including the final scoped mint, one accepted token request costs: | Node adapter, accepted id form | 6 (Team gate 5 + scoped mint 1) | | Worker adapter, accepted name form | 6 (installation gate 1 + Team gate 4 + scoped mint 1) | | Worker adapter, accepted id form | 7 (installation gate 1 + Team gate 5 + scoped mint 1) | +| Node adapter, accepted `POST /v1/workspace` | 3 (probe mint, Team read, probe revocation) | +| Worker adapter, accepted `POST /v1/workspace` | 4 (installation gate 1 + the same 3) | | `policy check --live` | 1 + per Workspace: probe mint, Team read, one Team repository list page per 100 repositories, probe revocation (a missing Team skips the list) | Denied credentials, invalid bodies and foreign owners in the name form cost @@ -429,10 +448,14 @@ denied, and the argument is forwarded unchanged rather than interpreted as a second authority. The host-level authenticated indicator means only that the broker accepted a -fresh proof for the repository of the current checkout (or `GH_REPO`). It is -not an Organization-wide capability claim. Outside a Team repository the proof -fails closed and reports the unauthenticated host shape. Every actual command -resolves and authorizes its own repository again. +fresh proof. Inside a repository checkout (or with `GH_REPO`) the proof mints +and discards a token for that repository. Anywhere else, such as the Folder +root from which Launchpad and T3 run discovery, it calls `POST /v1/workspace`, +which proves the live installation and the Workspace's Team without minting a +repository token. A failure of whichever proof applies reports the +unauthenticated host shape; a failed repository proof does not fall back to the +Workspace proof. The indicator is not an Organization-wide capability claim. +Every actual command resolves and authorizes its own repository again. The adapter holds no repository list. It sends the repository name, and it accepts only a broker response whose `repository` names the same repository diff --git a/adapter/broker-client.mjs b/adapter/broker-client.mjs index d7de1d2..9d8627e 100644 --- a/adapter/broker-client.mjs +++ b/adapter/broker-client.mjs @@ -102,17 +102,8 @@ function requireBrokerRuntime(environment, readFile, exists) { return Object.freeze({ workspaceId, brokerOrigin }); } -export async function requestGitHubAppToken({ - repository, - environment = process.env, - readFile = fs.readFileSync, - exists = fs.existsSync, - fetchImpl = fetch, - now = () => Date.now(), - timeoutMs = 5_000, -}) { - // The broker decides the scope from the live GitHub Team grant; the client holds no allowlist. - const coordinate = normalizeRepository(repository); +/** Authenticated POST to one broker route with the pinned origin, Workspace id and credential. */ +async function postToBroker(path, body, { environment, readFile, exists, fetchImpl, timeoutMs }) { const { workspaceId, brokerOrigin } = requireBrokerRuntime(environment, readFile, exists); const clientCredential = readFile(CREDENTIAL_FILE, "utf8").trim(); @@ -122,14 +113,14 @@ export async function requestGitHubAppToken({ let response; try { - response = await fetchImpl(`${brokerOrigin}/v1/token`, { + response = await fetchImpl(`${brokerOrigin}${path}`, { method: "POST", headers: { Authorization: `Bearer ${clientCredential}`, "Content-Type": "application/json", "X-Lazurio-Workspace-ID": workspaceId, }, - body: JSON.stringify({ repository: coordinate }), + body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); } catch { @@ -137,12 +128,29 @@ export async function requestGitHubAppToken({ } if (!response.ok) fail("GitHub token broker refused the request"); - let result; try { - result = await response.json(); + return { workspaceId, result: await response.json() }; } catch { - fail("GitHub token broker returned an invalid scoped response"); + return { workspaceId, result: undefined }; } +} + +export async function requestGitHubAppToken({ + repository, + environment = process.env, + readFile = fs.readFileSync, + exists = fs.existsSync, + fetchImpl = fetch, + now = () => Date.now(), + timeoutMs = 5_000, +}) { + // The broker decides the scope from the live GitHub Team grant; the client holds no allowlist. + const coordinate = normalizeRepository(repository); + const { result } = await postToBroker( + "/v1/token", + { repository: coordinate }, + { environment, readFile, exists, fetchImpl, timeoutMs }, + ); const expiresAt = Date.parse(result?.expires_at ?? ""); let returnedRepository; try { @@ -171,3 +179,39 @@ export async function requestGitHubAppToken({ expiresAt: result.expires_at, }); } + +/** + * Repository-independent connection proof: the broker verifies the live installation and the + * Workspace's GitHub Team and mints no repository token. + */ +export async function requestWorkspaceProof({ + environment = process.env, + readFile = fs.readFileSync, + exists = fs.existsSync, + fetchImpl = fetch, + timeoutMs = 5_000, +} = {}) { + const { workspaceId, result } = await postToBroker( + "/v1/workspace", + {}, + { environment, readFile, exists, fetchImpl, timeoutMs }, + ); + if ( + result?.workspace_id !== workspaceId || + typeof result?.organization !== "string" || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(result.organization) || + !Number.isSafeInteger(result?.github_team_id) || + result.github_team_id <= 0 || + typeof result?.github_team_slug !== "string" || + result.github_team_slug.length === 0 || + "token" in result + ) { + fail("GitHub token broker returned an invalid Workspace proof"); + } + return Object.freeze({ + workspaceId, + organization: result.organization, + githubTeamId: result.github_team_id, + githubTeamSlug: result.github_team_slug, + }); +} diff --git a/adapter/brokered-gh.mjs b/adapter/brokered-gh.mjs index 2920d10..1e6b6f8 100644 --- a/adapter/brokered-gh.mjs +++ b/adapter/brokered-gh.mjs @@ -7,6 +7,7 @@ import { normalizeRepository, repositoryIdentityKey, requestGitHubAppToken, + requestWorkspaceProof, requireHttpsGitHubOrigin, } from "./broker-client.mjs"; @@ -112,16 +113,18 @@ function explicitRepository(args, environment) { return result; } +function readCheckoutOrigin() { + const result = spawnSync("git", ["remote", "get-url", "origin"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 ? result.stdout.trim() : null; +} + export function resolveGhRepository({ args, environment = process.env, - readOrigin = () => { - const result = spawnSync("git", ["remote", "get-url", "origin"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - return result.status === 0 ? result.stdout.trim() : null; - }, + readOrigin = readCheckoutOrigin, }) { const selected = explicitRepository(args, environment); const origin = readOrigin(); @@ -196,11 +199,27 @@ function authenticatedStatus() { }; } +/** + * Live connection proof for the exact T3 discovery envelopes. Inside a repository checkout (or with + * GH_REPO) it mints and discards a token for that repository; anywhere else, such as the Folder + * root, it asks the broker for the repository-independent Workspace proof. Throws on any failure. + */ +async function proveConnection({ environment, readOrigin, requestToken, requestWorkspace }) { + const origin = readOrigin(); + if (origin || environment.GH_REPO) { + const repository = resolveGhRepository({ args: [], environment, readOrigin: () => origin }); + await requestToken({ repository, environment }); + return; + } + await requestWorkspace({ environment }); +} + export async function runBrokeredGh({ args, environment = process.env, - readOrigin, + readOrigin = readCheckoutOrigin, requestToken = requestGitHubAppToken, + requestWorkspace = requestWorkspaceProof, runRealGh = defaultRunRealGh, assertConfigDirectory = assertReadOnlyGhConfigDirectory, writeStdout = (value) => process.stdout.write(value), @@ -216,10 +235,7 @@ export async function runBrokeredGh({ } if (classification === "auth-status" || classification === "viewer-login") { try { - // The proof targets the current checkout (or GH_REPO); there is no policy default to fall - // back to, so outside a Team repository the proof fails closed. - const repository = resolveGhRepository({ args: [], environment, readOrigin }); - await requestToken({ repository, environment }); + await proveConnection({ environment, readOrigin, requestToken, requestWorkspace }); } catch { if (classification === "auth-status") { writeStdout(`${JSON.stringify({ hosts: {} })}\n`); diff --git a/src/core.mjs b/src/core.mjs index 445dce0..93548a1 100644 --- a/src/core.mjs +++ b/src/core.mjs @@ -433,6 +433,14 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () }); } + /** + * Live Workspace identity proof: the Workspace's immutable Team exists in the Organization with + * the asserted identity. Reads through the revoked probe and mints no repository token. + */ + async function verifyWorkspaceTeam(policy, workspace) { + return withProbeToken(policy, TEAM_PROBE_PERMISSIONS, (authorization) => readTeam(policy, workspace, authorization)); + } + /** * 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 @@ -541,7 +549,7 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = () return { token: result.token, expires_at: result.expires_at, repository_id: repositoryId }; } - return Object.freeze({ verifyPolicy, verifyTeamGrant, readWorkspaceTeamGrants, mintToken }); + return Object.freeze({ verifyPolicy, verifyTeamGrant, verifyWorkspaceTeam, readWorkspaceTeamGrants, mintToken }); } /** @@ -599,12 +607,89 @@ function result(status, body) { export function createBrokerHandler({ policy, github, credentials, secretMatches }) { if (!(credentials instanceof Map)) fail("Workspace credentials must be a Map"); if (typeof secretMatches !== "function") fail("secret matcher is missing"); - if (typeof github?.verifyTeamGrant !== "function" || typeof github?.mintToken !== "function") { - fail("GitHub client must verify the Team grant and mint tokens"); + if ( + typeof github?.verifyTeamGrant !== "function" || + typeof github?.verifyWorkspaceTeam !== "function" || + typeof github?.mintToken !== "function" + ) { + fail("GitHub client must verify the Team grant and Workspace Team and mint tokens"); } const workspaces = new Map(policy.workspaces.map((workspace) => [workspace.id, workspace])); const ownerKey = policy.github_owner.login.toLowerCase(); + async function authenticate(workspaceId, authorization) { + const presentedCredential = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; + const workspace = typeof workspaceId === "string" ? workspaces.get(workspaceId) : undefined; + const credentialMatches = await secretMatches( + presentedCredential, + (typeof workspaceId === "string" ? credentials.get(workspaceId) : undefined) ?? + DUMMY_WORKSPACE_CREDENTIAL, + ); + return workspace && presentedCredential && credentialMatches ? workspace : undefined; + } + + /** Maps a live-gate refusal to its 403; any other failure stays a 502. */ + function refusal(error) { + if (error?.code === TEAM_GRANT_MISSING || error?.code === REPOSITORY_DENIED) { + return result(403, { error: error.code }); + } + throw error; + } + + async function token(workspace, readBody) { + if (typeof readBody !== "function") fail("request body reader is missing"); + const bodyText = await readBody(); + if (typeof bodyText !== "string") fail("request body reader returned an invalid value"); + if (new TextEncoder().encode(bodyText).byteLength > MAX_BODY_BYTES) { + fail("request body is too large"); + } + const target = parseTokenRequest(bodyText); + if (!target) return result(400, { error: INVALID_REQUEST }); + // A name outside the policy's Organization is refused before any GitHub traffic. + if (target.repository !== undefined && target.repository.split("/", 1)[0].toLowerCase() !== ownerKey) { + return result(403, { error: REPOSITORY_DENIED }); + } + + let grant; + try { + grant = await github.verifyTeamGrant(policy, workspace, target); + } catch (error) { + return refusal(error); + } + const repositoryId = grant?.repository_id; + const repository = parseRepositoryCoordinate(grant?.full_name); + if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0 || !repository) { + fail("Team gate returned an invalid repository"); + } + const minted = await github.mintToken(policy, repositoryId); + if (minted?.repository_id !== repositoryId) fail("minted token is outside the verified repository"); + return result(200, { + token: minted.token, + expires_at: minted.expires_at, + repository_id: repositoryId, + repository: repository.full_name, + }); + } + + /** Repository-independent connection proof; the body is never read and no token is minted. */ + async function workspaceProof(workspace) { + let team; + try { + team = await github.verifyWorkspaceTeam(policy, workspace); + } catch (error) { + return refusal(error); + } + if (team?.id !== workspace.github_team_id || typeof team?.slug !== "string") { + fail("Workspace Team proof returned an invalid Team"); + } + return result(200, { + workspace_id: workspace.id, + organization: policy.github_owner.login, + github_team_id: team.id, + github_team_slug: team.slug, + }); + } + return async ({ method, path, @@ -616,60 +701,17 @@ export function createBrokerHandler({ policy, github, credentials, secretMatches if (method === "GET" && path === "/health") { return result(204, null); } - if (method !== "POST" || path !== "/v1/token") { + if (method !== "POST" || (path !== "/v1/token" && path !== "/v1/workspace")) { return result(404, { error: "not_found" }); } if (!contentType.toLowerCase().startsWith("application/json")) { return result(415, { error: "unsupported_media_type" }); } - const presentedCredential = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; try { - const workspace = typeof workspaceId === "string" ? workspaces.get(workspaceId) : undefined; - const credentialMatches = await secretMatches( - presentedCredential, - (typeof workspaceId === "string" ? credentials.get(workspaceId) : undefined) ?? - DUMMY_WORKSPACE_CREDENTIAL, - ); - if (!workspace || !presentedCredential || !credentialMatches) { - return result(401, { error: "workspace_unauthorized" }); - } - - if (typeof readBody !== "function") fail("request body reader is missing"); - const bodyText = await readBody(); - if (typeof bodyText !== "string") fail("request body reader returned an invalid value"); - if (new TextEncoder().encode(bodyText).byteLength > MAX_BODY_BYTES) { - fail("request body is too large"); - } - const target = parseTokenRequest(bodyText); - if (!target) return result(400, { error: INVALID_REQUEST }); - // A name outside the policy's Organization is refused before any GitHub traffic. - if (target.repository !== undefined && target.repository.split("/", 1)[0].toLowerCase() !== ownerKey) { - return result(403, { error: REPOSITORY_DENIED }); - } - - let grant; - try { - grant = await github.verifyTeamGrant(policy, workspace, target); - } catch (error) { - if (error?.code === TEAM_GRANT_MISSING || error?.code === REPOSITORY_DENIED) { - return result(403, { error: error.code }); - } - throw error; - } - const repositoryId = grant?.repository_id; - const repository = parseRepositoryCoordinate(grant?.full_name); - if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0 || !repository) { - fail("Team gate returned an invalid repository"); - } - const minted = await github.mintToken(policy, repositoryId); - if (minted?.repository_id !== repositoryId) fail("minted token is outside the verified repository"); - return result(200, { - token: minted.token, - expires_at: minted.expires_at, - repository_id: repositoryId, - repository: repository.full_name, - }); + const workspace = await authenticate(workspaceId, authorization); + if (!workspace) return result(401, { error: "workspace_unauthorized" }); + return path === "/v1/token" ? await token(workspace, readBody) : await workspaceProof(workspace); } catch { return result(502, { error: "token_unavailable" }); } diff --git a/src/worker.mjs b/src/worker.mjs index c9476d1..c43bf98 100644 --- a/src/worker.mjs +++ b/src/worker.mjs @@ -159,6 +159,7 @@ export function createWorkerEntrypoint({ createGithub = defaultGithub, fetchImpl !github || typeof github.verifyPolicy !== "function" || typeof github.verifyTeamGrant !== "function" || + typeof github.verifyWorkspaceTeam !== "function" || typeof github.mintToken !== "function" ) { fail("GitHub client is invalid"); @@ -168,12 +169,17 @@ export function createWorkerEntrypoint({ createGithub = defaultGithub, fetchImpl } // No trustworthy startup phase: the exact live installation is verified before the - // Team gate on every authorized request, and both precede the one-repository mint. + // Team gate (or the Workspace Team proof) on every authorized request, and both precede + // the one-repository mint. const verifiedGithub = Object.freeze({ async verifyTeamGrant(activePolicy, workspace, target) { await github.verifyPolicy(activePolicy); return github.verifyTeamGrant(activePolicy, workspace, target); }, + async verifyWorkspaceTeam(activePolicy, workspace) { + await github.verifyPolicy(activePolicy); + return github.verifyWorkspaceTeam(activePolicy, workspace); + }, mintToken(activePolicy, repositoryId) { return github.mintToken(activePolicy, repositoryId); }, diff --git a/test/broker.test.mjs b/test/broker.test.mjs index 439c3f5..3f03e04 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -57,6 +57,12 @@ const SYNTHETIC_REPOSITORIES = new Map([ ["example-org/beta", 3002], ]); +/** Synthetic Workspace Team proof: the bound Team exists with its asserted identity. */ +const provenTeam = async (_policy, workspace) => ({ + id: workspace.github_team_id, + slug: workspace.github_team_slug ?? workspace.id, +}); + /** Synthetic Team gate: resolves both body forms to the canonical repository id and name. */ const grantedTeam = async (_policy, workspace, target) => { const entry = target.repository_id === undefined @@ -77,10 +83,10 @@ async function withServer(run, mintToken = async (_policy, repositoryId) => ({ token: `ghs_synthetic_${repositoryId}`, expires_at: "2030-01-01T00:00:00Z", repository_id: repositoryId, -}), verifyTeamGrant = grantedTeam) { +}), verifyTeamGrant = grantedTeam, verifyWorkspaceTeam = provenTeam) { const server = createBrokerServer({ policy: parsePolicy(policyFixture()), - github: { verifyTeamGrant, mintToken }, + github: { verifyTeamGrant, verifyWorkspaceTeam, mintToken }, readFile: (file) => secretByPath.get(file) ?? "", realpath: (file) => file, }); @@ -628,6 +634,7 @@ test("keeps one verified credential snapshot for the complete server lifetime", policy: parsePolicy(policyFixture()), github: { verifyTeamGrant: grantedTeam, + verifyWorkspaceTeam: provenTeam, mintToken: async (_policy, repositoryId) => ({ token: `ghs_synthetic_${repositoryId}`, expires_at: "2030-01-01T00:00:00Z", @@ -665,6 +672,7 @@ test("verifies live policy exactly once before serving runtime requests", async verifies += 1; }, verifyTeamGrant: grantedTeam, + verifyWorkspaceTeam: provenTeam, mintToken: async (_policy, repositoryId) => ({ token: `ghs_synthetic_${repositoryId}`, expires_at: "2030-01-01T00:00:00Z", @@ -1085,6 +1093,7 @@ test("mints only after a live Team grant and refuses with team_grant_missing oth const server = createBrokerServer({ policy: parsePolicy(policyFixture()), github: { + verifyWorkspaceTeam: provenTeam, verifyTeamGrant: async (policy, workspace, target) => { checks += 1; assert.equal(workspace.github_team_id, 4001); @@ -1393,3 +1402,103 @@ test("readWorkspaceTeamGrants reports a missing Team as one refused row without "DELETE /installation/token", ]); }); + +function workspaceRequest(origin, { + workspace = "alpha-team", + secret = secretByPath.get("/run/secrets/workspace-alpha"), +} = {}) { + return fetch(`${origin}/v1/workspace`, { + method: "POST", + headers: { + Authorization: `Bearer ${secret}`, + "Content-Type": "application/json", + "X-Lazurio-Workspace-ID": workspace, + }, + body: "{}", + }); +} + +test("POST /v1/workspace proves the live Workspace Team through a revoked probe and mints no token", async () => { + const calls = []; + await withLiveCoreServer(mintingFetch(calls), async (origin) => { + const response = await workspaceRequest(origin); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { + workspace_id: "alpha-team", + organization: "example-org", + github_team_id: 4001, + github_team_slug: "alpha-team", + }); + assert.deepEqual(calls, [ + "POST /app/installations/2001/access_tokens", + "GET /organizations/1001/team/4001", + "DELETE /installation/token", + ]); + }); +}); + +test("POST /v1/workspace refuses bad credentials without GitHub traffic and a drifted Team with 403", async () => { + const calls = []; + const drifted = teamGrantFetch({ team: { id: 4001, slug: "renamed-team", organization: { id: 1001, login: "example-org" } } }); + await withLiveCoreServer(mintingFetch(calls, drifted), async (origin) => { + for (const options of [ + { secret: "wrong-secret-value-with-at-least-32-bytes" }, + { workspace: "unknown-team", secret: "unknown-secret-value-with-at-least-32-bytes" }, + { workspace: "beta-team", secret: secretByPath.get("/run/secrets/workspace-alpha") }, + ]) { + const response = await workspaceRequest(origin, options); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "workspace_unauthorized" }); + } + assert.equal(calls.length, 0); + + const refused = await workspaceRequest(origin); + assert.equal(refused.status, 403); + assert.deepEqual(await refused.json(), { error: "team_grant_missing" }); + assert.deepEqual(calls, [ + "POST /app/installations/2001/access_tokens", + "GET /organizations/1001/team/4001", + "DELETE /installation/token", + ]); + }); + + const missing = []; + await withLiveCoreServer(mintingFetch(missing, teamGrantFetch({ team: null })), async (origin) => { + const response = await workspaceRequest(origin); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "team_grant_missing" }); + assert.equal(missing.at(-1), "DELETE /installation/token"); + }); +}); + +test("POST /v1/workspace fails closed as token_unavailable on a GitHub outage and never reads repositories", async () => { + const calls = []; + const outage = async (url, init) => { + if (new URL(url).pathname === "/organizations/1001/team/4001") return jsonResponse({ message: "boom" }, 503); + return teamGrantFetch()(url, init); + }; + await withLiveCoreServer(mintingFetch(calls, outage), async (origin) => { + const response = await workspaceRequest(origin); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "token_unavailable" }); + assert.equal(calls.some((call) => call.includes("/repos") || call.startsWith("GET /repositories")), false); + assert.equal(calls.filter((call) => call === "POST /app/installations/2001/access_tokens").length, 1); + assert.equal(calls.at(-1), "DELETE /installation/token"); + }); +}); + +test("POST /v1/workspace is not a token route: it rejects other methods, media types and paths", async () => { + await withServer(async (origin) => { + assert.equal((await fetch(`${origin}/v1/workspace`)).status, 404); + assert.equal((await fetch(`${origin}/v1/workspace/extra`, { method: "POST" })).status, 404); + const unsupported = await fetch(`${origin}/v1/workspace`, { + method: "POST", + headers: { "Content-Type": "text/plain", "X-Lazurio-Workspace-ID": "alpha-team" }, + body: "{}", + }); + assert.equal(unsupported.status, 415); + const proof = await (await workspaceRequest(origin)).json(); + assert.equal("token" in proof, false); + }); +}); diff --git a/test/brokered-gh.test.mjs b/test/brokered-gh.test.mjs index a47c83b..585ba69 100644 --- a/test/brokered-gh.test.mjs +++ b/test/brokered-gh.test.mjs @@ -8,6 +8,7 @@ import { parseBrokerClientConfig, repositoryIdentityKey, requestGitHubAppToken, + requestWorkspaceProof, requireHttpsGitHubOrigin, } from "../adapter/broker-client.mjs"; import { @@ -324,28 +325,184 @@ test("the exact viewer probe reports the machine actor only after a live proof", assert.deepEqual(stdout, [`${HOSTED_GITHUB_ACTOR}\n`]); }); -test("outside a Team repository the discovery proofs fail closed without a broker call", async () => { +const WORKSPACE_PROOF = { + workspace_id: "example-management", + organization: "Example", + github_team_id: 4001, + github_team_slug: "management", +}; + +function brokerStub(status, body) { + const requests = []; + return { + requests, + fetchImpl: async (url, options) => { + requests.push({ url, options }); + return { ok: status >= 200 && status < 300, status, json: async () => body }; + }, + }; +} + +test("requestWorkspaceProof posts to /v1/workspace with the pinned identity and returns the Team", async () => { + const stub = brokerStub(200, WORKSPACE_PROOF); + const proof = await requestWorkspaceProof({ + environment, + readFile: () => "workspace-credential-with-at-least-32-bytes", + fetchImpl: stub.fetchImpl, + }); + assert.deepEqual({ ...proof }, { + workspaceId: "example-management", + organization: "Example", + githubTeamId: 4001, + githubTeamSlug: "management", + }); + assert.equal(stub.requests.length, 1); + assert.equal(stub.requests[0].url, "http://github-token-broker:8787/v1/workspace"); + assert.equal(stub.requests[0].options.method, "POST"); + assert.deepEqual(JSON.parse(stub.requests[0].options.body), {}); + assert.equal(stub.requests[0].options.headers["X-Lazurio-Workspace-ID"], "example-management"); + assert.ok(stub.requests[0].options.signal); + + // The same pinned remote-origin and credential-path validation as the token request. + await assert.rejects( + () => requestWorkspaceProof({ + environment: { ...environment, GITHUB_TOKEN_BROKER_URL: "https://attacker.invalid" }, + readFile: () => "workspace-credential-with-at-least-32-bytes", + fetchImpl: stub.fetchImpl, + }), + /endpoint is invalid/, + ); + await assert.rejects( + () => requestWorkspaceProof({ + environment: { ...environment, GITHUB_BROKER_CLIENT_CREDENTIAL_FILE: "/tmp/credential" }, + readFile: () => "workspace-credential-with-at-least-32-bytes", + fetchImpl: stub.fetchImpl, + }), + /credential path is invalid/, + ); + assert.equal(stub.requests.length, 1); +}); + +test("requestWorkspaceProof fails closed on refusals, outages and foreign or token-bearing answers", async () => { + const shared = { environment, readFile: () => "workspace-credential-with-at-least-32-bytes" }; + for (const [status, body] of [ + [401, { error: "workspace_unauthorized" }], + [403, { error: "team_grant_missing" }], + [502, { error: "token_unavailable" }], + ]) { + await assert.rejects( + () => requestWorkspaceProof({ ...shared, fetchImpl: brokerStub(status, body).fetchImpl }), + /refused/, + String(status), + ); + } + await assert.rejects( + () => requestWorkspaceProof({ ...shared, fetchImpl: async () => { throw new Error(TOKEN); } }), + /unavailable/, + ); + for (const body of [ + { ...WORKSPACE_PROOF, workspace_id: "other-workspace" }, + { ...WORKSPACE_PROOF, github_team_id: 0 }, + { ...WORKSPACE_PROOF, github_team_slug: "" }, + { ...WORKSPACE_PROOF, organization: "not an org" }, + { ...WORKSPACE_PROOF, token: TOKEN }, + null, + ]) { + await assert.rejects( + () => requestWorkspaceProof({ ...shared, fetchImpl: brokerStub(200, body).fetchImpl }), + /invalid Workspace proof/, + JSON.stringify(body), + ); + } +}); + +test("from the Folder root the discovery proofs use /v1/workspace and mint no repository token", async () => { const stdout = []; - const stderr = []; - let calls = 0; + let tokenRequests = 0; + let workspaceProofs = 0; const shared = { environment, readOrigin: () => null, requestToken: async () => { - calls += 1; + tokenRequests += 1; return { token: TOKEN }; }, + requestWorkspace: async ({ environment: proofEnvironment }) => { + workspaceProofs += 1; + assert.equal(proofEnvironment, environment); + return WORKSPACE_PROOF; + }, writeStdout: (value) => stdout.push(value), - writeStderr: (value) => stderr.push(value), + writeStderr: () => {}, }; - assert.equal(await runBrokeredGh({ ...shared, args: ["auth", "status", "--json", "hosts"] }), 1); + assert.equal(await runBrokeredGh({ ...shared, args: ["auth", "status", "--json", "hosts"] }), 0); + const status = JSON.parse(stdout.at(-1)); + assert.equal(status.hosts["github.com"][0].state, "success"); + assert.equal(status.hosts["github.com"][0].login, HOSTED_GITHUB_ACTOR); + assert.equal(await runBrokeredGh({ ...shared, args: ["api", "user", "--jq", ".login"] }), 0); + assert.equal(stdout.at(-1), `${HOSTED_GITHUB_ACTOR}\n`); + assert.equal(workspaceProofs, 2); + assert.equal(tokenRequests, 0); +}); + +test("from the Folder root a refused or unavailable Workspace proof fails closed", async () => { + for (const [status, body] of [ + [401, { error: "workspace_unauthorized" }], + [403, { error: "team_grant_missing" }], + [502, { error: "token_unavailable" }], + ]) { + const stub = brokerStub(status, body); + const stdout = []; + const stderr = []; + const shared = { + environment, + readOrigin: () => null, + requestToken: async () => { + throw new Error("must not mint a repository token from the Folder root"); + }, + requestWorkspace: (options) => requestWorkspaceProof({ + ...options, + readFile: () => "workspace-credential-with-at-least-32-bytes", + exists: () => false, + fetchImpl: stub.fetchImpl, + }), + writeStdout: (value) => stdout.push(value), + writeStderr: (value) => stderr.push(value), + }; + assert.equal(await runBrokeredGh({ ...shared, args: ["auth", "status", "--json", "hosts"] }), 1, String(status)); + assert.deepEqual(JSON.parse(stdout.at(-1)), { hosts: {} }); + assert.equal(stderr.at(-1), "Brokered gh authentication proof failed.\n"); + await assert.rejects( + () => runBrokeredGh({ ...shared, args: ["api", "user", "--jq", ".login"] }), + /identity proof failed/, + ); + assert.deepEqual(stub.requests.map(({ url }) => url), [ + "http://github-token-broker:8787/v1/workspace", + "http://github-token-broker:8787/v1/workspace", + ]); + } +}); + +test("inside a checkout the discovery proof stays the repository mint and never falls back", async () => { + let workspaceProofs = 0; + const stdout = []; + const exitCode = await runBrokeredGh({ + args: ["auth", "status", "--json", "hosts"], + environment, + readOrigin: () => "https://github.com/Example/Alpha.git", + requestToken: async () => { + throw new Error("team_grant_missing"); + }, + requestWorkspace: async () => { + workspaceProofs += 1; + return WORKSPACE_PROOF; + }, + writeStdout: (value) => stdout.push(value), + writeStderr: () => {}, + }); + assert.equal(exitCode, 1); assert.deepEqual(JSON.parse(stdout.at(-1)), { hosts: {} }); - assert.equal(stderr.at(-1), "Brokered gh authentication proof failed.\n"); - await assert.rejects( - () => runBrokeredGh({ ...shared, args: ["api", "user", "--jq", ".login"] }), - /identity proof failed/, - ); - assert.equal(calls, 0); + assert.equal(workspaceProofs, 0); }); test("repository operations accept T3 lowercase identity and pass only an env-local token", async () => { diff --git a/test/worker.test.mjs b/test/worker.test.mjs index 9beafa8..f706955 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -64,8 +64,10 @@ const grantedAlpha = async (_policy, _workspace, target) => { return { repository_id: 3001, full_name: "example-org/alpha", role: "write" }; }; -function workerFixture({ teamGrant = grantedAlpha } = {}) { - const calls = { verify: 0, teamGrant: 0, mint: 0, order: [] }; +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 worker = createWorkerEntrypoint({ createGithub: () => ({ async verifyPolicy() { @@ -77,6 +79,11 @@ function workerFixture({ teamGrant = grantedAlpha } = {}) { calls.order.push("team"); return teamGrant(policy, workspace, target); }, + async verifyWorkspaceTeam(policy, workspace) { + calls.workspaceTeam += 1; + calls.order.push("workspace-team"); + return workspaceTeam(policy, workspace); + }, async mintToken(_policy, repositoryId) { calls.mint += 1; calls.order.push("mint"); @@ -338,11 +345,81 @@ test("Worker refuses with team_grant_missing after live verification and mints n assert.deepEqual(countsOf(calls), { verify: 2, teamGrant: 2, mint: 1 }); }); -test("Worker treats a GitHub client without Team verification as unavailable configuration", async () => { +test("Worker treats a GitHub client without Team or Workspace verification as unavailable configuration", async () => { const worker = createWorkerEntrypoint({ createGithub: () => ({ verifyPolicy: async () => {}, mintToken: async () => ({}) }), }); const response = await worker.fetch(tokenRequest(), environment()); assert.equal(response.status, 503); assert.deepEqual(await response.json(), { error: "configuration_unavailable" }); + + const withoutWorkspaceProof = createWorkerEntrypoint({ + createGithub: () => ({ verifyPolicy: async () => {}, verifyTeamGrant: async () => ({}), mintToken: async () => ({}) }), + }); + const missingProof = await withoutWorkspaceProof.fetch(workspaceRequest(), environment()); + assert.equal(missingProof.status, 503); + assert.deepEqual(await missingProof.json(), { error: "configuration_unavailable" }); +}); + +function workspaceRequest({ workspace = "alpha-team", credential = ALPHA_CREDENTIAL } = {}) { + return new Request("https://broker.example.test/v1/workspace", { + method: "POST", + headers: { + Authorization: `Bearer ${credential}`, + "Content-Type": "application/json", + "X-Lazurio-Workspace-ID": workspace, + }, + body: "{}", + }); +} + +test("Worker /v1/workspace verifies the installation, then the Team, and mints nothing", async () => { + const { worker, calls } = workerFixture(); + const response = await worker.fetch(workspaceRequest(), environment()); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { + workspace_id: "alpha-team", + organization: "example-org", + github_team_id: 4001, + github_team_slug: "alpha-team", + }); + assert.deepEqual(calls.order, ["installation", "workspace-team"]); + assert.equal(calls.mint, 0); + assert.equal(calls.teamGrant, 0); +}); + +test("Worker /v1/workspace refuses bad credentials without GitHub, drift with 403 and outages with 502", async () => { + const unauthorized = workerFixture(); + const denied = await unauthorized.worker.fetch( + workspaceRequest({ credential: "wrong-secret-with-at-least-thirty-two-bytes" }), + environment(), + ); + assert.equal(denied.status, 401); + assert.deepEqual(await denied.json(), { error: "workspace_unauthorized" }); + assert.deepEqual(unauthorized.calls.order, []); + + const drifted = workerFixture({ + workspaceTeam: async () => { + throw new TeamGrantError("Team gone"); + }, + }); + const refused = await drifted.worker.fetch(workspaceRequest(), environment()); + assert.equal(refused.status, 403); + assert.deepEqual(await refused.json(), { error: "team_grant_missing" }); + assert.equal(drifted.calls.mint, 0); + + const outage = workerFixture({ + workspaceTeam: async () => { + throw new Error("GitHub request failed with HTTP 503"); + }, + }); + const unavailable = await outage.worker.fetch(workspaceRequest(), environment()); + assert.equal(unavailable.status, 502); + assert.deepEqual(await unavailable.json(), { error: "token_unavailable" }); + assert.equal(outage.calls.mint, 0); + + const wrongTeam = workerFixture({ workspaceTeam: async () => ({ id: 4999, slug: "other" }) }); + const mismatch = await wrongTeam.worker.fetch(workspaceRequest(), environment()); + assert.equal(mismatch.status, 502); });