Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ dependency-light and customer-neutral.
3. The App private key never leaves the broker workload.
4. A token is limited to one repository and exactly `actions: write`,
`checks: read`, `contents: write` and `pull_requests: write` (plus GitHub's
automatic `metadata: read`), and expires on GitHub's installation-token
schedule.
automatic `metadata: read`), plus `workflows: write` only when the reviewed
policy declares that the installation accepted it, and expires on GitHub's
installation-token schedule.
5. Workspace credentials and installation tokens are never logged, committed,
stored in Git configuration or persisted by this service.
6. The broker authorizes a Workspace and repository, never a browser user.
Expand Down
19 changes: 17 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ repository set. The Node adapter performs this gate before listening; the
Worker performs it after local authorization on every valid token request.
Every token request then asks GitHub to mint a new one-repository token with
exactly `actions: write`,
`checks: read`, `contents: write` and `pull_requests: write`; removal of a live
`checks: read`, `contents: write` and `pull_requests: write`, plus
`workflows: write` when the policy declares that the installation accepted it,
and refuses a response with any other scope; removal of a live
repository grant therefore fails the next issuance without waiting for a local
cache.
Between the installation gate and the mint, the runtime performs the Team gate
Expand All @@ -38,6 +40,18 @@ Actions mutations in the same repository. The remaining boundaries therefore
stay material: one immutable repository per short-lived token, an exact
Workspace repository allowlist and no Checks write access.

`workflows: write` lets a Workspace push commits that change
`.github/workflows/`. Editing a workflow does not by itself run it or hand it
secrets, but a workflow it writes can, in an eligible run, reference and
exfiltrate the repository and Organization Actions secrets available to that
run, subject to environment protections (fork-pull-request and Dependabot
events withhold ordinary secrets, and protected environment secrets need
approval). That is the same capability a Team member with a write grant
already has from GitHub, and an Organization opts into it by accepting the
App permission and declaring it in its reviewed policy. A policy without it
mints the base set, so the release can roll out before any Organization
accepts.

## Team binding and live grant verification

A Hosted Team Workspace has no human GitHub identity; its GitHub identity is
Expand All @@ -55,7 +69,8 @@ is nevertheless a separate, deny-only admission gate that decides which live
grants the broker will use at all: a repository outside the allowlist is
refused before the Team grant is read, even when the Team holds a write grant
on it. On every token request, after the credential and allowlist
checks, the runtime mints a `members: read` probe token, reads the Team by
checks, the runtime mints a `members: read` plus `metadata: read` probe token,
reads the Team by
`/organizations/{org_id}/team/{team_id}`, checks the Team's grant on the exact
repository with `/organizations/{org_id}/team/{team_id}/repos/{owner}/{repo}`
and the repository-permissions media type, revokes the probe, and only then
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Open-source, publicly auditable source for the small credential boundary used
by Lazurio Team Workspaces. The broker exchanges an authenticated Workspace
request for a short-lived GitHub App installation token restricted to one
immutable repository id and exactly `actions: write`, `checks: read`,
`contents: write` plus `pull_requests: write`.
`contents: write` plus `pull_requests: write`, and `workflows: write` where the
Organization's policy declares it.

Lazurio for GitHub is an independent project. It is not affiliated with,
sponsored by, or endorsed by GitHub, Inc. GitHub and GitHub CLI are trademarks
Expand Down Expand Up @@ -107,6 +108,13 @@ Schema v2 makes the Team binding part of the contract:
the policy is reviewed, and the slug never selects a different Team.
- `installation_permissions.members` must be `read`: the App reads Teams and
Team repository grants through this Organization permission.
- `installation_permissions.workflows: write` is optional. When the
Organization accepted it and the policy declares it, every token also asks
for `workflows: write`, so a Workspace can push commits that add or change
`.github/workflows/` files in its repository, exactly like a Team member
with a write grant. Without it the token keeps the base set. Because the
installation gate compares permissions exactly, accept the permission and
deploy the policy that declares it in one short window.
- `id`, `credential_file` and `repository_ids` keep their v1 meaning.

Schema v1 is retired and rejected with a migration message. Existing v1
Expand All @@ -132,7 +140,7 @@ policy verification has succeeded.

Every `POST /v1/token` passes these gates in order; a failed gate never issues
a scoped repository token. The Team gate itself first mints a short-lived
`members: read` probe token, performs the Team read and revokes the probe, so
`members: read` plus `metadata: read` probe token, performs the Team read and revokes the probe, so
a Team or grant refusal has already cost that probe mint and revocation — it
only guarantees that no final repository token exists:

Expand All @@ -144,8 +152,8 @@ only guarantees that no final repository token exists:
| `502` | `token_unavailable` | GitHub was unreachable, rate-limited or over quota, returned an unexpected shape, or the mint left the requested scope. |
| `415` | `unsupported_media_type` | Body is not JSON. |

The Team gate runs live on every mint with a short-lived `members: read` probe
token that is revoked immediately afterwards. Its two read requests are
The Team gate runs live on every mint with a short-lived `members: read` plus
`metadata: read` probe token that is revoked immediately afterwards. Its two read requests are
addressed by immutable ids:

1. `GET /organizations/{org_id}/team/{team_id}` proves the Team still exists in
Expand All @@ -157,6 +165,9 @@ addressed by immutable ids:
repository id to match and `push`, `maintain` or `admin` to be `true`. A
`404` means no grant (Organization `members: read`; GitHub also lists this
endpoint under repository `administration: read`, which is not required).
The probe also needs repository `metadata: read`: without it GitHub
answers `404` for every private repository the probe cannot see, which
would look like a missing grant.

The `{owner}/{repo}` path segment is the policy's asserted `full_name`, whose
immutable id was already proven against the live installation. A repository
Expand Down Expand Up @@ -245,7 +256,8 @@ Without `--live` the command only parses the policy and prints the Workspace
to Team binding; it needs no key and makes no GitHub call. With `--live` it
first performs the same installation verification as `--verify-only`, then
reads back every `repository_ids` entry of every Workspace with one
`members: read` probe token per Workspace, and prints one row per grant:
`members: read` plus `metadata: read` probe token per Workspace, and prints one
row per grant:

```text
WORKSPACE TEAM_ID TEAM_SLUG REPOSITORY_ID REPOSITORY ROLE STATUS DETAIL
Expand Down
34 changes: 20 additions & 14 deletions src/core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,22 @@ const TOKEN_PERMISSIONS = Object.freeze({
contents: "write",
pull_requests: "write",
});
/** Organization `members: read` is the GitHub App permission that reads Teams and Team repository grants. */
const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read" });
/**
* Pushing a commit that touches `.github/workflows/` needs `workflows: write`. The token asks for it
* only when the Organization accepted it and the reviewed policy declares it, so a deployment whose
* installation has not accepted the permission keeps minting the base set.
*/
const WORKFLOW_TOKEN_PERMISSIONS = Object.freeze({ ...TOKEN_PERMISSIONS, workflows: "write" });

function tokenPermissions(policy) {
return policy.installation_permissions.workflows === "write" ? WORKFLOW_TOKEN_PERMISSIONS : TOKEN_PERMISSIONS;
}
/**
* Organization `members: read` reads Teams and Team repository grants. GitHub answers the Team
* repository check with 404 unless the token can also see the repository, which takes
* `metadata: read`; a `members: read`-only probe therefore refuses every private repository.
*/
const TEAM_PROBE_PERMISSIONS = Object.freeze({ members: "read", metadata: "read" });
/** Custom media type that makes the Team repository check return the repository with its `permissions`. */
const REPOSITORY_PERMISSIONS_MEDIA_TYPE = "application/vnd.github.v3.repository+json";

Expand Down Expand Up @@ -415,11 +429,12 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = ()
}

async function mintToken(policy, repositoryId) {
const requested = tokenPermissions(policy);
const result = await request(`/app/installations/${policy.installation_id}/access_tokens`, {
method: "POST",
body: {
repository_ids: [repositoryId],
permissions: TOKEN_PERMISSIONS,
permissions: requested,
},
});
const expiresAt = Date.parse(result?.expires_at ?? "");
Expand All @@ -433,19 +448,10 @@ export function createGithubClient({ appId, signJwt, fetchImpl = fetch, now = ()
expiresAt > now() + 65 * 60 * 1000 ||
returnedRepositories.length !== 1 ||
returnedRepositories[0]?.id !== repositoryId ||
returnedPermissions.actions !== "write" ||
returnedPermissions.checks !== "read" ||
returnedPermissions.contents !== "write" ||
returnedPermissions.pull_requests !== "write" ||
Object.entries(requested).some(([permission, level]) => returnedPermissions[permission] !== level) ||
Object.entries(returnedPermissions).some(
([permission, level]) =>
!(
(permission === "actions" && level === "write") ||
(permission === "checks" && level === "read") ||
(permission === "contents" && level === "write") ||
(permission === "pull_requests" && level === "write") ||
(permission === "metadata" && level === "read")
),
requested[permission] !== level && !(permission === "metadata" && level === "read"),
)
) {
fail("GitHub returned a token outside the requested repository or permission scope");
Expand Down
49 changes: 48 additions & 1 deletion test/broker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,53 @@ test("mints through repository_ids and rejects under- or over-scoped responses",
);
});

test("asks for workflows: write only when the policy declares the accepted permission", async () => {
const now = 1_700_000_000_000;
const base = { actions: "write", checks: "read", contents: "write", pull_requests: "write" };
let requested;
let responsePermissions;
const github = createGithubClient({
appId: "42",
privateKey: testPrivateKey(),
fetchImpl: async (_url, init) => {
requested = JSON.parse(init.body).permissions;
return jsonResponse({
token: "ghs_scoped_synthetic_token",
expires_at: new Date(now + 60 * 60 * 1000).toISOString(),
repositories: [{ id: 3001 }],
permissions: responsePermissions,
});
},
now: () => now,
});

const withoutWorkflows = parsePolicy(policyFixture());
responsePermissions = { ...base, metadata: "read", workflows: "write" };
await assert.rejects(
() => github.mintToken(withoutWorkflows, 3001),
/outside the requested repository or permission scope/,
);
assert.deepEqual(requested, base);

const fixture = policyFixture();
fixture.installation_permissions = { ...fixture.installation_permissions, emails: "read", workflows: "write" };
const withWorkflows = parsePolicy(fixture);
assert.equal((await github.mintToken(withWorkflows, 3001)).repository_id, 3001);
assert.deepEqual(requested, { ...base, workflows: "write" });

responsePermissions = { ...base, metadata: "read" };
await assert.rejects(
() => github.mintToken(withWorkflows, 3001),
/outside the requested repository or permission scope/,
);

responsePermissions = { ...base, metadata: "read", workflows: "write", emails: "read" };
await assert.rejects(
() => github.mintToken(withWorkflows, 3001),
/outside the requested repository or permission scope/,
);
});

test("rejects duplicated Workspace credential values and aliased paths before listening", async () => {
const policy = parsePolicy(policyFixture());
const github = { verifyPolicy: async () => {} };
Expand Down Expand Up @@ -810,7 +857,7 @@ test("verifies the live Team binding and write-capable grant with a revoked memb
"GET /organizations/1001/team/4001/repos/example-org/alpha",
"DELETE /installation/token",
]);
assert.deepEqual(calls[0].body, { permissions: { members: "read" } });
assert.deepEqual(calls[0].body, { permissions: { members: "read", metadata: "read" } });
assert.equal(calls[1].authorization, "Bearer ghs_team_probe");
assert.equal(calls[2].accept, "application/vnd.github.v3.repository+json");
assert.equal(calls[3].authorization, "Bearer ghs_team_probe");
Expand Down
Loading