From fa28601613a5f520627c9b2f3f8e656e222ffa51 Mon Sep 17 00:00:00 2001 From: lex00 Date: Fri, 19 Jun 2026 09:59:47 -0600 Subject: [PATCH] feat(cycle): token approval cycle (#16) Auto-decides pending fine-grained PAT requests against policy: approves when every requested permission is allowed, else auto-deny or leave for manual review. New TokenApprovalPolicy + LiveTokenRequest + pure evaluateTokenRequest + token-request diff type (decisions as UPDATE entries). App-only platform wall documented. Mock-tested; verify against a real App/test-org later. Registered, exported, action bundle rebuilt. Co-Authored-By: Claude Opus 4.8 --- action/index.mjs | 93 ++++++++++- src/cli/registry.ts | 2 + src/config/types.ts | 28 ++++ src/cycles/token-approval.test.ts | 248 ++++++++++++++++++++++++++++++ src/cycles/token-approval.ts | 145 +++++++++++++++++ src/index.ts | 6 +- src/reconcile/diff.ts | 59 +++++++ 7 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 src/cycles/token-approval.test.ts create mode 100644 src/cycles/token-approval.ts diff --git a/action/index.mjs b/action/index.mjs index d312dc2..34e4de7 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -279,6 +279,7 @@ function diff(org, desired, live, opts = {}) { diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries); diffRepoBaselines(desired.repoBaselines, live.repos ?? {}, entries); diffTokenGrants(desired.tokenPolicy, live.tokenGrants ?? [], opts, entries); + diffTokenRequests(desired.tokenApproval, live.tokenRequests ?? [], entries); diffTeams(desired.teams, live.teams ?? {}, opts, entries); diffMembers(desired.members, live.members ?? [], opts, entries); diffRepos(desired.repos, live.repos ?? {}, opts, entries); @@ -714,6 +715,27 @@ function diffTokenGrants(policy, grants, opts, out) { }); } } +function evaluateTokenRequest(request, policy) { + const allowed = new Set(policy.allowedPermissions ?? []); + const approvable = request.permissions.every((p) => allowed.has(p)); + if (approvable) return "approve"; + return policy.default === "deny" ? "deny" : null; +} +function diffTokenRequests(policy, requests, out) { + if (policy === void 0) return; + for (const request of requests) { + const decision = evaluateTokenRequest(request, policy); + if (!decision) continue; + out.push({ + kind: "update", + resourceType: "token-request", + key: String(request.id), + before: request, + after: { decision, ownerLogin: request.ownerLogin }, + fields: [{ field: "decision", before: "pending", after: decision }] + }); + } +} function diffRepoBaselines(desired, liveRepos, out) { if (desired === void 0) return; for (const baseline of desired) { @@ -812,6 +834,7 @@ var init_diff = __esm({ "org-secret", "org-variable", "token-grant", + "token-request", "repo-baseline", "team", "team-member", @@ -230731,6 +230754,73 @@ var tokenGovernanceCycle = { } }; +// src/cycles/token-approval.ts +var PER_PAGE7 = 100; +function flattenRequestPermissions(permissions) { + const out = []; + if (!permissions) return out; + for (const [group, scopes] of Object.entries(permissions)) { + if (!scopes || typeof scopes !== "object") continue; + for (const scope of Object.keys(scopes)) out.push(`${group}:${scope}`); + } + return out; +} +function mapTokenRequest(raw) { + const req = { id: raw.id, permissions: flattenRequestPermissions(raw.permissions) }; + if (raw.owner?.login) req.ownerLogin = raw.owner.login; + return req; +} +var tokenApprovalCycle = { + name: "token-approval", + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + async fetchLive(client, orgLogin, _scope, budget) { + if (budget.exhausted) { + const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports)); + throw new BudgetExhaustedError2(); + } + const requests = []; + let page = 1; + for (; ; ) { + if (budget.exhausted) break; + budget.use(1); + let batch; + try { + batch = await client.request( + "GET", + `/orgs/${orgLogin}/personal-access-token-requests?per_page=${PER_PAGE7}&page=${page}` + ); + } catch (err) { + if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) { + return { tokenRequests: [] }; + } + throw err; + } + if (!Array.isArray(batch) || batch.length === 0) break; + for (const r of batch) if (r && typeof r.id === "number") requests.push(mapTokenRequest(r)); + if (batch.length < PER_PAGE7) break; + page++; + } + return { tokenRequests: requests }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.tokenApproval) return {}; + return { tokenApproval: orgConfig.tokenApproval }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "token-request") return; + if (entry.kind !== "update") return; + const requestId = entry.key; + const decision = entry.after.decision; + if (decision !== "approve" && decision !== "deny") return; + budget.use(1); + await client.request("POST", `/orgs/${orgLogin}/personal-access-token-requests/${requestId}`, { + action: decision + }); + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, @@ -230744,7 +230834,8 @@ var CYCLE_REGISTRY = { [secretsVariablesCycle.name]: secretsVariablesCycle, [dependencyHygieneCycle.name]: dependencyHygieneCycle, [repoBaselineCycle.name]: repoBaselineCycle, - [tokenGovernanceCycle.name]: tokenGovernanceCycle + [tokenGovernanceCycle.name]: tokenGovernanceCycle, + [tokenApprovalCycle.name]: tokenApprovalCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 0351f28..5a5f1c4 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -21,6 +21,7 @@ import { secretsVariablesCycle } from "../cycles/secrets-variables.js"; import { dependencyHygieneCycle } from "../cycles/dependency-hygiene.js"; import { repoBaselineCycle } from "../cycles/repo-baseline.js"; import { tokenGovernanceCycle } from "../cycles/token-governance.js"; +import { tokenApprovalCycle } from "../cycles/token-approval.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -42,4 +43,5 @@ export const CYCLE_REGISTRY: Record = { [dependencyHygieneCycle.name]: dependencyHygieneCycle, [repoBaselineCycle.name]: repoBaselineCycle, [tokenGovernanceCycle.name]: tokenGovernanceCycle, + [tokenApprovalCycle.name]: tokenApprovalCycle, }; diff --git a/src/config/types.ts b/src/config/types.ts index 2d5a297..1190a77 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -122,6 +122,29 @@ export interface TokenPolicyConfig { maxIdleDays?: number; } +/** + * Policy for auto-deciding pending fine-grained PAT requests (the token-approval + * cycle). A request is auto-APPROVED when every permission it asks for is in + * `allowedPermissions`; otherwise the `default` decision applies. These request + * endpoints are callable ONLY by a GitHub App. + * + * Platform note: admins can only approve or deny a request — they cannot change + * the repo scope a creator chose. + */ +export interface TokenApprovalPolicy { + /** + * Permission scope names that may be auto-approved. A request is approved only + * when ALL of its requested permissions are in this list. Absent → no request + * is auto-approved. + */ + allowedPermissions?: string[]; + /** + * Decision for a request that is not auto-approved: "deny" (auto-deny) or + * "manual" (leave pending for a human). Default "manual". + */ + default?: "deny" | "manual"; +} + // --------------------------------------------------------------------------- // Dependency hygiene (Dependabot config file) // --------------------------------------------------------------------------- @@ -465,6 +488,11 @@ export interface OrgConfig { * grants are not governed by chant. */ tokenPolicy?: TokenPolicyConfig; + /** + * Policy for auto-deciding pending fine-grained PAT requests. Absent means + * PAT requests are not auto-decided by chant. + */ + tokenApproval?: TokenApprovalPolicy; } /** diff --git a/src/cycles/token-approval.test.ts b/src/cycles/token-approval.test.ts new file mode 100644 index 0000000..408dc55 --- /dev/null +++ b/src/cycles/token-approval.test.ts @@ -0,0 +1,248 @@ +/** + * Tests for the token-approval cycle. + * + * All tests use a mock AppClient — no network. + * Coverage: + * - evaluateTokenRequest: approve (subset) / deny (default) / manual (null) + * - flattenRequestPermissions + mapTokenRequest + * - buildDesired / fetchLive (paginated; 403/404 tolerated) + * - diff: decided requests → token-request UPDATE; manual → none + * - apply: POST approve/deny; foreign skip + * - runner integration: subset request auto-approved + */ + +import { describe, it, expect } from "vitest"; +import { tokenApprovalCycle, mapTokenRequest, flattenRequestPermissions } from "./token-approval.js"; +import type { TokenApprovalScope } from "./token-approval.js"; +import type { AppClient } from "../auth/app-client.js"; +import type { RateBudget } from "../reconcile/runner.js"; +import { runReconcile, BudgetExhaustedError } from "../reconcile/runner.js"; +import { diff, evaluateTokenRequest } from "../reconcile/diff.js"; +import type { LiveOrgState } from "../reconcile/diff.js"; +import type { GovernanceConfig, OrgConfig } from "../config/types.js"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +interface MockCall { + method: string; + path: string; + body?: unknown; +} + +interface MockClient extends AppClient { + calls: MockCall[]; + responses: Map; +} + +function makeMockClient(responses: Record = {}): MockClient { + const calls: MockCall[] = []; + const responseMap = new Map(Object.entries(responses)); + return { + calls, + responses: responseMap, + async request(method: string, path: string, body?: unknown): Promise { + calls.push({ method, path, body }); + const key = `${method} ${path}`; + if (responseMap.has(key)) return responseMap.get(key) as T; + if (method === "GET" && path.includes("/personal-access-token-requests")) return [] as T; + return {} as T; + }, + }; +} + +function makeBudget(initial = 100): RateBudget { + let remaining = initial; + return { + get remaining() { + return remaining; + }, + get exhausted() { + return remaining <= 0; + }, + use(n = 1) { + if (remaining <= 0) throw new BudgetExhaustedError(); + remaining = Math.max(0, remaining - n); + }, + }; +} + +const scope: TokenApprovalScope = {}; + +// --------------------------------------------------------------------------- +// 1. evaluateTokenRequest +// --------------------------------------------------------------------------- + +describe("evaluateTokenRequest", () => { + const policy = { allowedPermissions: ["repository:contents", "repository:metadata"], default: "deny" as const }; + + it("approves when all requested permissions are allowed", () => { + expect(evaluateTokenRequest({ id: 1, permissions: ["repository:contents"] }, policy)).toBe("approve"); + expect(evaluateTokenRequest({ id: 1, permissions: [] }, policy)).toBe("approve"); + }); + + it("denies a request with a disallowed permission when default=deny", () => { + expect(evaluateTokenRequest({ id: 1, permissions: ["repository:administration"] }, policy)).toBe("deny"); + }); + + it("leaves for manual review when default is manual (the default)", () => { + expect( + evaluateTokenRequest({ id: 1, permissions: ["repository:administration"] }, { allowedPermissions: [] }), + ).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. flattenRequestPermissions / mapTokenRequest +// --------------------------------------------------------------------------- + +describe("flattenRequestPermissions", () => { + it("flattens nested groups to group:scope names", () => { + expect( + flattenRequestPermissions({ repository: { contents: "write", metadata: "read" }, organization: { members: "read" } }), + ).toEqual(["repository:contents", "repository:metadata", "organization:members"]); + }); + + it("handles null/empty", () => { + expect(flattenRequestPermissions(null)).toEqual([]); + expect(flattenRequestPermissions({ repository: null })).toEqual([]); + }); + + it("mapTokenRequest captures owner + permissions", () => { + const req = mapTokenRequest({ id: 3, owner: { login: "alice" }, permissions: { repository: { contents: "write" } } }); + expect(req).toEqual({ id: 3, ownerLogin: "alice", permissions: ["repository:contents"] }); + }); +}); + +// --------------------------------------------------------------------------- +// 3. buildDesired / fetchLive +// --------------------------------------------------------------------------- + +describe("tokenApprovalCycle.buildDesired / fetchLive", () => { + it("keeps only tokenApproval", () => { + const orgConfig: OrgConfig = { tokenApproval: { default: "deny" }, members: [] }; + expect(tokenApprovalCycle.buildDesired(orgConfig, "test-org", scope)).toEqual({ + tokenApproval: { default: "deny" }, + }); + }); + + it("lists pending requests", async () => { + const client = makeMockClient({ + "GET /orgs/test-org/personal-access-token-requests?per_page=100&page=1": [ + { id: 1, owner: { login: "a" }, permissions: { repository: { contents: "read" } } }, + ], + }); + const live = await tokenApprovalCycle.fetchLive(client, "test-org", scope, makeBudget()); + expect(live.tokenRequests).toHaveLength(1); + expect(live.tokenRequests![0]!.permissions).toEqual(["repository:contents"]); + }); + + it("tolerates 404 as no requests", async () => { + const client: MockClient = makeMockClient(); + client.request = async (method: string, path: string): Promise => { + client.calls.push({ method, path }); + throw new Error("GET ... returned 404: Not Found"); + }; + expect((await tokenApprovalCycle.fetchLive(client, "test-org", scope, makeBudget())).tokenRequests).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff +// --------------------------------------------------------------------------- + +describe("diff integration with token-approval cycle", () => { + it("emits token-request UPDATEs for decided requests, none for manual", () => { + const live: LiveOrgState = { + tokenRequests: [ + { id: 1, ownerLogin: "a", permissions: ["repository:contents"] }, // approve + { id: 2, ownerLogin: "b", permissions: ["repository:administration"] }, // deny + { id: 3, ownerLogin: "c", permissions: ["organization:members"] }, // manual? allowed has it? no → deny + ], + }; + const desired = tokenApprovalCycle.buildDesired( + { tokenApproval: { allowedPermissions: ["repository:contents"], default: "deny" } }, + "test-org", + scope, + ); + const cs = diff("test-org", desired, live); + const decisions = Object.fromEntries( + cs.entries.map((e) => [e.key, (e.after as { decision?: string }).decision]), + ); + expect(cs.entries.every((e) => e.resourceType === "token-request" && e.kind === "update")).toBe(true); + expect(decisions["1"]).toBe("approve"); + expect(decisions["2"]).toBe("deny"); + }); + + it("leaves requests pending when default is manual", () => { + const live: LiveOrgState = { tokenRequests: [{ id: 9, permissions: ["repository:administration"] }] }; + const desired = tokenApprovalCycle.buildDesired( + { tokenApproval: { allowedPermissions: ["repository:contents"] } }, + "test-org", + scope, + ); + expect(diff("test-org", desired, live).entries).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply +// --------------------------------------------------------------------------- + +describe("tokenApprovalCycle.apply", () => { + it("POSTs the decision action", async () => { + const client = makeMockClient(); + await tokenApprovalCycle.apply( + client, + { kind: "update", resourceType: "token-request", key: "8", before: { id: 8 }, after: { decision: "approve" }, fields: [] }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("POST"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/personal-access-token-requests/8"); + expect(client.calls[0]!.body).toEqual({ action: "approve" }); + }); + + it("ignores foreign resource types", async () => { + const client = makeMockClient(); + await tokenApprovalCycle.apply( + client, + { kind: "update", resourceType: "token-grant", key: "1", after: { revoke: true } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("tokenApprovalCycle via runReconcile", () => { + it("apply: auto-approves a subset request", async () => { + const config: GovernanceConfig = { + orgs: { "test-org": { tokenApproval: { allowedPermissions: ["repository:contents"], default: "manual" } } }, + }; + const client = makeMockClient({ + "GET /orgs/test-org/personal-access-token-requests?per_page=100&page=1": [ + { id: 4, owner: { login: "dev" }, permissions: { repository: { contents: "read" } } }, + ], + }); + const result = await runReconcile({ + config, + client, + cycles: [tokenApprovalCycle], + mode: "apply", + allowGuardrailOverride: true, + }); + expect(result.completed).toBe(true); + expect(result.cycles[0]!.applied).toHaveLength(1); + const post = client.calls.find((c) => c.method === "POST")!; + expect(post.path).toBe("/orgs/test-org/personal-access-token-requests/4"); + expect(post.body).toEqual({ action: "approve" }); + }); +}); diff --git a/src/cycles/token-approval.ts b/src/cycles/token-approval.ts new file mode 100644 index 0000000..4f01de7 --- /dev/null +++ b/src/cycles/token-approval.ts @@ -0,0 +1,145 @@ +/** + * Token approval cycle. + * + * Auto-decides pending fine-grained PAT requests against policy: a request whose + * every permission is in `allowedPermissions` is approved; otherwise the policy + * `default` applies (auto-deny, or leave pending for a human). Poll/event-driven + * on pending requests. + * + * GET /orgs/{org}/personal-access-token-requests — list pending + * POST /orgs/{org}/personal-access-token-requests/{id} — approve / deny + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## Platform wall + * + * These request endpoints are callable ONLY by a GitHub App. Admins can approve + * or deny a request but cannot change the repo scope the creator chose — so the + * policy decides approve/deny only. + * + * Decisions are modeled as "token-request" UPDATE entries (the pure decision + * logic is `evaluateTokenRequest` in diff.ts). Mock-tested; verify against a + * real App/test-org before relying on it. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveTokenRequest } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** Scope for the token-approval cycle. The org is identified by `orgLogin`. */ +export type TokenApprovalScope = Record; + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +interface GhTokenRequest { + id: number; + owner?: { login?: string }; + /** Nested permission groups, e.g. { repository: { contents: "write" } }. */ + permissions?: Record | null | undefined> | null; +} + +const PER_PAGE = 100; + +/** Flatten the nested request permissions to `group:scope` names. */ +export function flattenRequestPermissions( + permissions: GhTokenRequest["permissions"], +): string[] { + const out: string[] = []; + if (!permissions) return out; + for (const [group, scopes] of Object.entries(permissions)) { + if (!scopes || typeof scopes !== "object") continue; + for (const scope of Object.keys(scopes)) out.push(`${group}:${scope}`); + } + return out; +} + +/** Map a GitHub PAT-request list item to the `LiveTokenRequest` diff shape. */ +export function mapTokenRequest(raw: GhTokenRequest): LiveTokenRequest { + const req: LiveTokenRequest = { id: raw.id, permissions: flattenRequestPermissions(raw.permissions) }; + if (raw.owner?.login) req.ownerLogin = raw.owner.login; + return req; +} + +// --------------------------------------------------------------------------- +// tokenApprovalCycle — implements Cycle +// --------------------------------------------------------------------------- + +export const tokenApprovalCycle: Cycle = { + name: "token-approval", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + _scope: TokenApprovalScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const requests: LiveTokenRequest[] = []; + let page = 1; + for (;;) { + if (budget.exhausted) break; + budget.use(1); + let batch: GhTokenRequest[]; + try { + batch = await client.request( + "GET", + `/orgs/${orgLogin}/personal-access-token-requests?per_page=${PER_PAGE}&page=${page}`, + ); + } catch (err) { + if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) { + return { tokenRequests: [] }; + } + throw err; + } + if (!Array.isArray(batch) || batch.length === 0) break; + for (const r of batch) if (r && typeof r.id === "number") requests.push(mapTokenRequest(r)); + if (batch.length < PER_PAGE) break; + page++; + } + + return { tokenRequests: requests }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: TokenApprovalScope): OrgConfig { + if (!orgConfig.tokenApproval) return {}; + return { tokenApproval: orgConfig.tokenApproval }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: TokenApprovalScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "token-request") return; + if (entry.kind !== "update") return; + + const requestId = entry.key; + const decision = (entry.after as { decision?: string }).decision; + if (decision !== "approve" && decision !== "deny") return; + + budget.use(1); + await client.request("POST", `/orgs/${orgLogin}/personal-access-token-requests/${requestId}`, { + action: decision, + }); + }, +}; diff --git a/src/index.ts b/src/index.ts index ea35faa..49dfc17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export type { DependabotConfig, RepoBaselineConfig, TokenPolicyConfig, + TokenApprovalPolicy, } from "./config/types.js"; // Config loader @@ -54,9 +55,10 @@ export type { LiveVariable, LiveDependabot, LiveTokenGrant, + LiveTokenRequest, LiveOrgState, } from "./reconcile/diff.js"; -export { diff, summarizeChangeSet, renderChangeSet, evaluateTokenViolation } from "./reconcile/diff.js"; +export { diff, summarizeChangeSet, renderChangeSet, evaluateTokenViolation, evaluateTokenRequest } from "./reconcile/diff.js"; // Reconcile: guardrails export type { @@ -112,6 +114,8 @@ export { repoBaselineCycle, listOrgRepoNames } from "./cycles/repo-baseline.js"; export type { RepoBaselineScope } from "./cycles/repo-baseline.js"; export { tokenGovernanceCycle, mapTokenGrant } from "./cycles/token-governance.js"; export type { TokenGovernanceScope } from "./cycles/token-governance.js"; +export { tokenApprovalCycle, mapTokenRequest, flattenRequestPermissions } from "./cycles/token-approval.js"; +export type { TokenApprovalScope } from "./cycles/token-approval.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js"; diff --git a/src/reconcile/diff.ts b/src/reconcile/diff.ts index 7789a1b..422fecb 100644 --- a/src/reconcile/diff.ts +++ b/src/reconcile/diff.ts @@ -29,6 +29,7 @@ import type { DependabotConfig, RepoBaselineConfig, TokenPolicyConfig, + TokenApprovalPolicy, } from "../config/types.js"; // --------------------------------------------------------------------------- @@ -255,6 +256,17 @@ export interface LiveOrgState { secrets?: LiveSecret[]; variables?: LiveVariable[]; tokenGrants?: LiveTokenGrant[]; + tokenRequests?: LiveTokenRequest[]; +} + +/** Live snapshot of a pending fine-grained PAT request. */ +export interface LiveTokenRequest { + /** Request id (used to approve/deny). */ + id: number; + /** Login of the requester. */ + ownerLogin?: string; + /** Flattened permission scope names the request asks for. */ + permissions: string[]; } /** Live snapshot of a fine-grained PAT grant on the org (timestamps in epoch ms). */ @@ -283,6 +295,7 @@ const RESOURCE_TYPE_ORDER = [ "org-secret", "org-variable", "token-grant", + "token-request", "repo-baseline", "team", "team-member", @@ -324,6 +337,7 @@ export function diff( diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries); diffRepoBaselines(desired.repoBaselines, live.repos ?? {}, entries); diffTokenGrants(desired.tokenPolicy, live.tokenGrants ?? [], opts, entries); + diffTokenRequests(desired.tokenApproval, live.tokenRequests ?? [], entries); diffTeams(desired.teams, live.teams ?? {}, opts, entries); diffMembers(desired.members, live.members ?? [], opts, entries); diffRepos(desired.repos, live.repos ?? {}, opts, entries); @@ -1012,6 +1026,51 @@ function diffTokenGrants( } } +// --------------------------------------------------------------------------- +// Token approval +// --------------------------------------------------------------------------- + +/** + * Decide a single pending PAT request against the approval policy. Returns + * "approve" (all requested permissions are allowed), "deny" (not approvable and + * the policy auto-denies), or null (leave pending for a human). Pure. + */ +export function evaluateTokenRequest( + request: LiveTokenRequest, + policy: TokenApprovalPolicy, +): "approve" | "deny" | null { + const allowed = new Set(policy.allowedPermissions ?? []); + const approvable = request.permissions.every((p) => allowed.has(p)); + if (approvable) return "approve"; + return policy.default === "deny" ? "deny" : null; +} + +/** + * Diff pending PAT requests against the approval policy. Each auto-decided + * request is emitted as a "token-request" UPDATE carrying the decision; requests + * left for manual review produce no entry. + */ +function diffTokenRequests( + policy: TokenApprovalPolicy | undefined, + requests: LiveTokenRequest[], + out: ChangeSetEntry[], +): void { + if (policy === undefined) return; + + for (const request of requests) { + const decision = evaluateTokenRequest(request, policy); + if (!decision) continue; + out.push({ + kind: "update", + resourceType: "token-request", + key: String(request.id), + before: request, + after: { decision, ownerLogin: request.ownerLogin }, + fields: [{ field: "decision", before: "pending", after: decision }], + }); + } +} + // --------------------------------------------------------------------------- // Repository baseline / provisioning // ---------------------------------------------------------------------------