From 3098d07eb4ecc02397e114134350419320934a0d Mon Sep 17 00:00:00 2001 From: lex00 Date: Fri, 19 Jun 2026 09:55:56 -0600 Subject: [PATCH] feat(cycle): token governance scheduled sweep (#15) Inventories org fine-grained PAT grants and revokes org access for policy violators (expired / over max-lifetime / idle). New TokenPolicyConfig + LiveTokenGrant + pure evaluateTokenViolation + token-grant diff type. Violations modeled as UPDATE (revoke), not delete, so a routine sweep doesn't trip removalDeltaCap. Runner injects nowMs for time-based checks. Platform walls (App-only; user PATs not API-rotatable) 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 | 106 +++++++++++- src/cli/registry.ts | 2 + src/config/types.ts | 27 +++ src/cycles/token-governance.test.ts | 259 ++++++++++++++++++++++++++++ src/cycles/token-governance.ts | 151 ++++++++++++++++ src/index.ts | 6 +- src/reconcile/diff.ts | 95 ++++++++++ src/reconcile/runner.ts | 8 +- 8 files changed, 648 insertions(+), 6 deletions(-) create mode 100644 src/cycles/token-governance.test.ts create mode 100644 src/cycles/token-governance.ts diff --git a/action/index.mjs b/action/index.mjs index ac0b007..d312dc2 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -278,6 +278,7 @@ function diff(org, desired, live, opts = {}) { diffSecrets("", "org-secret", desired.secrets, live.secrets ?? [], opts, entries); diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries); diffRepoBaselines(desired.repoBaselines, live.repos ?? {}, entries); + diffTokenGrants(desired.tokenPolicy, live.tokenGrants ?? [], opts, entries); diffTeams(desired.teams, live.teams ?? {}, opts, entries); diffMembers(desired.members, live.members ?? [], opts, entries); diffRepos(desired.repos, live.repos ?? {}, opts, entries); @@ -688,6 +689,31 @@ function diffVariables(keyPrefix, resourceType, desired, live, opts, out) { } } } +function evaluateTokenViolation(grant, policy, nowMs) { + if (policy.revokeExpired !== false && grant.expired === true) return "expired"; + if (policy.maxLifetimeDays != null && nowMs != null && grant.grantedAtMs != null && (nowMs - grant.grantedAtMs) / MS_PER_DAY > policy.maxLifetimeDays) { + return "exceeds-max-lifetime"; + } + if (policy.maxIdleDays != null && nowMs != null && grant.lastUsedAtMs != null && (nowMs - grant.lastUsedAtMs) / MS_PER_DAY > policy.maxIdleDays) { + return "idle"; + } + return null; +} +function diffTokenGrants(policy, grants, opts, out) { + if (policy === void 0) return; + for (const grant of grants) { + const reason = evaluateTokenViolation(grant, policy, opts.nowMs); + if (!reason) continue; + out.push({ + kind: "update", + resourceType: "token-grant", + key: String(grant.id), + before: grant, + after: { revoke: true, reason, ownerLogin: grant.ownerLogin }, + fields: [{ field: "access", before: "granted", after: `revoked (${reason})` }] + }); + } +} function diffRepoBaselines(desired, liveRepos, out) { if (desired === void 0) return; for (const baseline of desired) { @@ -776,7 +802,7 @@ function fmt(v) { const json2 = JSON.stringify(v); return json2.length > 60 ? `${json2.slice(0, 57)}...` : json2; } -var RESOURCE_TYPE_ORDER, RULESET_FIELDS, ENVIRONMENT_FIELDS; +var RESOURCE_TYPE_ORDER, RULESET_FIELDS, ENVIRONMENT_FIELDS, MS_PER_DAY; var init_diff = __esm({ "src/reconcile/diff.ts"() { "use strict"; @@ -785,6 +811,7 @@ var init_diff = __esm({ "org-ruleset", "org-secret", "org-variable", + "token-grant", "repo-baseline", "team", "team-member", @@ -806,6 +833,7 @@ var init_diff = __esm({ "reviewers", "deploymentBranchPolicy" ]; + MS_PER_DAY = 864e5; } }); @@ -1021,7 +1049,10 @@ async function runReconcile(opts) { }); continue; } - const changeSet = diff(orgLogin, desired, live, diffOptions); + const changeSet = diff(orgLogin, desired, live, { + ...diffOptions, + nowMs: diffOptions.nowMs ?? Date.now() + }); const guardrailResult = runGuardrails(changeSet, live, guardrailConfig); const counts = { create: 0, update: 0, delete: 0 }; for (const e of changeSet.entries) counts[e.kind]++; @@ -230632,6 +230663,74 @@ var repoBaselineCycle = { } }; +// src/cycles/token-governance.ts +var PER_PAGE6 = 100; +function toMs(iso) { + if (!iso) return void 0; + const ms = Date.parse(iso); + return Number.isNaN(ms) ? void 0 : ms; +} +function mapTokenGrant(raw) { + const grant = { id: raw.id }; + if (raw.owner?.login) grant.ownerLogin = raw.owner.login; + if (typeof raw.token_expired === "boolean") grant.expired = raw.token_expired; + const exp = toMs(raw.token_expires_at); + if (exp !== void 0) grant.expiresAtMs = exp; + const last = toMs(raw.token_last_used_at); + if (last !== void 0) grant.lastUsedAtMs = last; + const granted = toMs(raw.access_granted_at); + if (granted !== void 0) grant.grantedAtMs = granted; + return grant; +} +var tokenGovernanceCycle = { + name: "token-governance", + // ── 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 grants = []; + let page = 1; + for (; ; ) { + if (budget.exhausted) break; + budget.use(1); + let batch; + try { + batch = await client.request( + "GET", + `/orgs/${orgLogin}/personal-access-tokens?per_page=${PER_PAGE6}&page=${page}` + ); + } catch (err) { + if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) { + return { tokenGrants: [] }; + } + throw err; + } + if (!Array.isArray(batch) || batch.length === 0) break; + for (const g of batch) if (g && typeof g.id === "number") grants.push(mapTokenGrant(g)); + if (batch.length < PER_PAGE6) break; + page++; + } + return { tokenGrants: grants }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.tokenPolicy) return {}; + return { tokenPolicy: orgConfig.tokenPolicy }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "token-grant") return; + if (entry.kind !== "update") return; + const patId = entry.key; + budget.use(1); + await client.request("POST", `/orgs/${orgLogin}/personal-access-tokens/${patId}`, { + action: "revoke" + }); + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, @@ -230644,7 +230743,8 @@ var CYCLE_REGISTRY = { [environmentsCycle.name]: environmentsCycle, [secretsVariablesCycle.name]: secretsVariablesCycle, [dependencyHygieneCycle.name]: dependencyHygieneCycle, - [repoBaselineCycle.name]: repoBaselineCycle + [repoBaselineCycle.name]: repoBaselineCycle, + [tokenGovernanceCycle.name]: tokenGovernanceCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index b82803a..0351f28 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -20,6 +20,7 @@ import { environmentsCycle } from "../cycles/environments.js"; 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"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -40,4 +41,5 @@ export const CYCLE_REGISTRY: Record = { [secretsVariablesCycle.name]: secretsVariablesCycle, [dependencyHygieneCycle.name]: dependencyHygieneCycle, [repoBaselineCycle.name]: repoBaselineCycle, + [tokenGovernanceCycle.name]: tokenGovernanceCycle, }; diff --git a/src/config/types.ts b/src/config/types.ts index ac4d5a4..2d5a297 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -100,6 +100,28 @@ export interface MemberConfig { role?: OrgMemberRole; } +// --------------------------------------------------------------------------- +// Token governance +// --------------------------------------------------------------------------- + +/** + * Org fine-grained PAT governance policy. Drives the token-governance cycle's + * scheduled sweep: it revokes a token grant's ORG ACCESS when the grant + * violates the policy. + * + * PLATFORM WALL: user PATs cannot be created or rotated on a user's behalf via + * the API. warden can only inventory grants, gate approval (#16), and revoke + * org access — these token APIs are callable ONLY by a GitHub App. + */ +export interface TokenPolicyConfig { + /** Revoke org access for an expired grant still listed. Default true. */ + revokeExpired?: boolean; + /** Maximum grant lifetime in days (1–366). Older grants are revoked. */ + maxLifetimeDays?: number; + /** Maximum idle days since last use. Staler grants are revoked. */ + maxIdleDays?: number; +} + // --------------------------------------------------------------------------- // Dependency hygiene (Dependabot config file) // --------------------------------------------------------------------------- @@ -438,6 +460,11 @@ export interface OrgConfig { * machine user from a person, so this list is operator-declared. */ machineUsers?: string[]; + /** + * Fine-grained PAT governance policy (scheduled sweep). Absent means token + * grants are not governed by chant. + */ + tokenPolicy?: TokenPolicyConfig; } /** diff --git a/src/cycles/token-governance.test.ts b/src/cycles/token-governance.test.ts new file mode 100644 index 0000000..a86cfb8 --- /dev/null +++ b/src/cycles/token-governance.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for the token-governance cycle. + * + * All tests use a mock AppClient + explicit nowMs — no network, no real clock. + * Coverage: + * - evaluateTokenViolation: expired / lifetime / idle / compliant + * - mapTokenGrant: ISO → epoch ms + * - buildDesired: keeps tokenPolicy + * - fetchLive: lists grants (paginated); 403/404 tolerated + * - diff: violators emitted as token-grant UPDATE (revoke), not delete + * - apply: POST revoke; foreign skip + * - runner integration: expired grant revoked (clock-free) + */ + +import { describe, it, expect } from "vitest"; +import { tokenGovernanceCycle, mapTokenGrant } from "./token-governance.js"; +import type { TokenGovernanceScope } from "./token-governance.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, evaluateTokenViolation } from "../reconcile/diff.js"; +import type { LiveOrgState, LiveTokenGrant } 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-tokens")) 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: TokenGovernanceScope = {}; +const DAY = 86_400_000; +const NOW = 1_700_000_000_000; + +// --------------------------------------------------------------------------- +// 1. evaluateTokenViolation +// --------------------------------------------------------------------------- + +describe("evaluateTokenViolation", () => { + it("flags an expired grant (clock-free)", () => { + expect(evaluateTokenViolation({ id: 1, expired: true }, {})).toBe("expired"); + }); + + it("respects revokeExpired:false", () => { + expect(evaluateTokenViolation({ id: 1, expired: true }, { revokeExpired: false })).toBeNull(); + }); + + it("flags a grant over the max lifetime", () => { + const grant: LiveTokenGrant = { id: 1, grantedAtMs: NOW - 40 * DAY }; + expect(evaluateTokenViolation(grant, { maxLifetimeDays: 30 }, NOW)).toBe("exceeds-max-lifetime"); + expect(evaluateTokenViolation(grant, { maxLifetimeDays: 60 }, NOW)).toBeNull(); + }); + + it("flags an idle grant", () => { + const grant: LiveTokenGrant = { id: 1, lastUsedAtMs: NOW - 100 * DAY }; + expect(evaluateTokenViolation(grant, { maxIdleDays: 90 }, NOW)).toBe("idle"); + }); + + it("skips lifetime/idle checks without nowMs", () => { + const grant: LiveTokenGrant = { id: 1, grantedAtMs: 0, lastUsedAtMs: 0 }; + expect(evaluateTokenViolation(grant, { maxLifetimeDays: 1, maxIdleDays: 1 })).toBeNull(); + }); + + it("returns null for a compliant grant", () => { + expect(evaluateTokenViolation({ id: 1, expired: false }, { maxLifetimeDays: 30 }, NOW)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. mapTokenGrant +// --------------------------------------------------------------------------- + +describe("mapTokenGrant", () => { + it("maps owner + ISO timestamps to epoch ms", () => { + const grant = mapTokenGrant({ + id: 7, + owner: { login: "alice" }, + token_expired: false, + token_expires_at: "2024-01-01T00:00:00Z", + token_last_used_at: "2023-06-01T00:00:00Z", + access_granted_at: "2023-01-01T00:00:00Z", + }); + expect(grant.id).toBe(7); + expect(grant.ownerLogin).toBe("alice"); + expect(grant.expiresAtMs).toBe(Date.parse("2024-01-01T00:00:00Z")); + expect(grant.grantedAtMs).toBe(Date.parse("2023-01-01T00:00:00Z")); + }); + + it("omits unparseable / null timestamps", () => { + const grant = mapTokenGrant({ id: 1, token_last_used_at: null }); + expect(grant.lastUsedAtMs).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. buildDesired / fetchLive +// --------------------------------------------------------------------------- + +describe("tokenGovernanceCycle.buildDesired / fetchLive", () => { + it("keeps only tokenPolicy", () => { + const orgConfig: OrgConfig = { tokenPolicy: { maxLifetimeDays: 90 }, members: [] }; + expect(tokenGovernanceCycle.buildDesired(orgConfig, "test-org", scope)).toEqual({ + tokenPolicy: { maxLifetimeDays: 90 }, + }); + }); + + it("lists grants and maps them", async () => { + const client = makeMockClient({ + "GET /orgs/test-org/personal-access-tokens?per_page=100&page=1": [ + { id: 1, owner: { login: "a" }, token_expired: true }, + { id: 2, owner: { login: "b" }, token_expired: false }, + ], + }); + const live = await tokenGovernanceCycle.fetchLive(client, "test-org", scope, makeBudget()); + expect(live.tokenGrants).toHaveLength(2); + expect(live.tokenGrants![0]!.ownerLogin).toBe("a"); + }); + + it("tolerates 403 as no grants", async () => { + const client: MockClient = makeMockClient(); + client.request = async (method: string, path: string): Promise => { + client.calls.push({ method, path }); + throw new Error("GET ... returned 403: Resource not accessible by integration"); + }; + const live = await tokenGovernanceCycle.fetchLive(client, "test-org", scope, makeBudget()); + expect(live.tokenGrants).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff +// --------------------------------------------------------------------------- + +describe("diff integration with token-governance cycle", () => { + it("emits a token-grant UPDATE (revoke) for a violator, not a delete", () => { + const live: LiveOrgState = { + tokenGrants: [ + { id: 10, ownerLogin: "a", expired: true }, + { id: 11, ownerLogin: "b", expired: false }, + ], + }; + const desired = tokenGovernanceCycle.buildDesired({ tokenPolicy: {} }, "test-org", scope); + const cs = diff("test-org", desired, live, { nowMs: NOW }); + expect(cs.entries).toHaveLength(1); + const e = cs.entries[0]!; + expect(e.resourceType).toBe("token-grant"); + expect(e.kind).toBe("update"); // not delete → won't trip removalDeltaCap + expect(e.key).toBe("10"); + expect((e.after as { reason?: string }).reason).toBe("expired"); + }); + + it("emits nothing when no policy is declared", () => { + const live: LiveOrgState = { tokenGrants: [{ id: 1, expired: true }] }; + const cs = diff("test-org", {}, live, { nowMs: NOW }); + expect(cs.entries).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply +// --------------------------------------------------------------------------- + +describe("tokenGovernanceCycle.apply", () => { + it("POSTs a revoke for a token-grant update", async () => { + const client = makeMockClient(); + await tokenGovernanceCycle.apply( + client, + { kind: "update", resourceType: "token-grant", key: "42", before: { id: 42 }, after: { revoke: true }, fields: [] }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("POST"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/personal-access-tokens/42"); + expect(client.calls[0]!.body).toEqual({ action: "revoke" }); + }); + + it("ignores foreign resource types", async () => { + const client = makeMockClient(); + await tokenGovernanceCycle.apply( + client, + { kind: "update", resourceType: "member", key: "alice", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("tokenGovernanceCycle via runReconcile", () => { + it("apply: revokes an expired grant (clock-free)", async () => { + const config: GovernanceConfig = { + orgs: { "test-org": { tokenPolicy: { revokeExpired: true } } }, + }; + const client = makeMockClient({ + "GET /orgs/test-org/personal-access-tokens?per_page=100&page=1": [ + { id: 5, owner: { login: "stale" }, token_expired: true }, + ], + }); + const result = await runReconcile({ + config, + client, + cycles: [tokenGovernanceCycle], + mode: "apply", + allowGuardrailOverride: true, // no members → adminFloor would block + }); + 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-tokens/5"); + expect(post.body).toEqual({ action: "revoke" }); + }); +}); diff --git a/src/cycles/token-governance.ts b/src/cycles/token-governance.ts new file mode 100644 index 0000000..bcaa2b6 --- /dev/null +++ b/src/cycles/token-governance.ts @@ -0,0 +1,151 @@ +/** + * Token governance cycle (scheduled sweep). + * + * Inventories the org's fine-grained PAT grants and revokes the ORG ACCESS of + * any grant that violates the token policy (expired, over max lifetime, or idle + * too long). Time-based — nothing fires an event for an aging token — so this + * is a scheduled sweep. + * + * GET /orgs/{org}/personal-access-tokens — list active grants + * POST /orgs/{org}/personal-access-tokens/{pat_id} — revoke a grant's access + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## PLATFORM WALL (documented in code) + * + * User PATs cannot be created or rotated on a user's behalf via the API. warden + * can only: enforce lifetime/idle policy by REVOKING org access, inventory + * grants, and gate approval (the separate #16 cycle). These token APIs are + * callable ONLY by a GitHub App (warden's auth). + * + * ## Modeling + * + * A violation is emitted by the diff as an UPDATE on a "token-grant" resource + * (meaning "revoke org access"), not a delete — so a routine revocation sweep + * does not trip the removalDeltaCap guardrail. The violation logic itself lives + * in `evaluateTokenViolation` (pure, in diff.ts) and is exercised against the + * `nowMs` the runner injects. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveTokenGrant } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** Scope for the token-governance cycle. The org is identified by `orgLogin`. */ +export type TokenGovernanceScope = Record; + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +interface GhTokenGrant { + id: number; + owner?: { login?: string }; + token_expired?: boolean; + token_expires_at?: string | null; + token_last_used_at?: string | null; + access_granted_at?: string | null; +} + +const PER_PAGE = 100; + +function toMs(iso: string | null | undefined): number | undefined { + if (!iso) return undefined; + const ms = Date.parse(iso); + return Number.isNaN(ms) ? undefined : ms; +} + +/** Map a GitHub PAT-grant list item to the `LiveTokenGrant` diff shape. */ +export function mapTokenGrant(raw: GhTokenGrant): LiveTokenGrant { + const grant: LiveTokenGrant = { id: raw.id }; + if (raw.owner?.login) grant.ownerLogin = raw.owner.login; + if (typeof raw.token_expired === "boolean") grant.expired = raw.token_expired; + const exp = toMs(raw.token_expires_at); + if (exp !== undefined) grant.expiresAtMs = exp; + const last = toMs(raw.token_last_used_at); + if (last !== undefined) grant.lastUsedAtMs = last; + const granted = toMs(raw.access_granted_at); + if (granted !== undefined) grant.grantedAtMs = granted; + return grant; +} + +// --------------------------------------------------------------------------- +// tokenGovernanceCycle — implements Cycle +// --------------------------------------------------------------------------- + +export const tokenGovernanceCycle: Cycle = { + name: "token-governance", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + _scope: TokenGovernanceScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const grants: LiveTokenGrant[] = []; + let page = 1; + for (;;) { + if (budget.exhausted) break; + budget.use(1); + let batch: GhTokenGrant[]; + try { + batch = await client.request( + "GET", + `/orgs/${orgLogin}/personal-access-tokens?per_page=${PER_PAGE}&page=${page}`, + ); + } catch (err) { + // No fine-grained PAT grants / no access → nothing to govern. + if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) { + return { tokenGrants: [] }; + } + throw err; + } + if (!Array.isArray(batch) || batch.length === 0) break; + for (const g of batch) if (g && typeof g.id === "number") grants.push(mapTokenGrant(g)); + if (batch.length < PER_PAGE) break; + page++; + } + + return { tokenGrants: grants }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: TokenGovernanceScope): OrgConfig { + if (!orgConfig.tokenPolicy) return {}; + return { tokenPolicy: orgConfig.tokenPolicy }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: TokenGovernanceScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "token-grant") return; + // Only the revoke (update) action is produced by the diff. + if (entry.kind !== "update") return; + + const patId = entry.key; + budget.use(1); + await client.request("POST", `/orgs/${orgLogin}/personal-access-tokens/${patId}`, { + action: "revoke", + }); + }, +}; diff --git a/src/index.ts b/src/index.ts index 7614f87..ea35faa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export type { VariableConfig, DependabotConfig, RepoBaselineConfig, + TokenPolicyConfig, } from "./config/types.js"; // Config loader @@ -52,9 +53,10 @@ export type { LiveSecret, LiveVariable, LiveDependabot, + LiveTokenGrant, LiveOrgState, } from "./reconcile/diff.js"; -export { diff, summarizeChangeSet, renderChangeSet } from "./reconcile/diff.js"; +export { diff, summarizeChangeSet, renderChangeSet, evaluateTokenViolation } from "./reconcile/diff.js"; // Reconcile: guardrails export type { @@ -108,6 +110,8 @@ export { dependencyHygieneCycle, fetchDependabot } from "./cycles/dependency-hyg export type { DependencyHygieneScope } from "./cycles/dependency-hygiene.js"; 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"; // 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 9a14c09..7789a1b 100644 --- a/src/reconcile/diff.ts +++ b/src/reconcile/diff.ts @@ -28,6 +28,7 @@ import type { VariableConfig, DependabotConfig, RepoBaselineConfig, + TokenPolicyConfig, } from "../config/types.js"; // --------------------------------------------------------------------------- @@ -97,6 +98,13 @@ export interface DiffOptions { * @returns `true` if chant owns this entry and may delete it. */ isOwned?: (resourceType: string, key: string) => boolean; + + /** + * Reference "now" in epoch milliseconds, used by time-based diffs (e.g. + * token-grant lifetime/idle evaluation). The runner injects `Date.now()` when + * unset; tests pass an explicit value for determinism. + */ + nowMs?: number; } // --------------------------------------------------------------------------- @@ -246,6 +254,23 @@ export interface LiveOrgState { rulesets?: LiveRuleset[]; secrets?: LiveSecret[]; variables?: LiveVariable[]; + tokenGrants?: LiveTokenGrant[]; +} + +/** Live snapshot of a fine-grained PAT grant on the org (timestamps in epoch ms). */ +export interface LiveTokenGrant { + /** Grant id (used to revoke). */ + id: number; + /** Login of the token owner. */ + ownerLogin?: string; + /** Whether GitHub reports the token as expired. */ + expired?: boolean; + /** Expiry time (epoch ms), if any. */ + expiresAtMs?: number; + /** Last-used time (epoch ms), if known. */ + lastUsedAtMs?: number; + /** When org access was granted (epoch ms). */ + grantedAtMs?: number; } // --------------------------------------------------------------------------- @@ -257,6 +282,7 @@ const RESOURCE_TYPE_ORDER = [ "org-ruleset", "org-secret", "org-variable", + "token-grant", "repo-baseline", "team", "team-member", @@ -297,6 +323,7 @@ export function diff( diffSecrets("", "org-secret", desired.secrets, live.secrets ?? [], opts, entries); diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries); diffRepoBaselines(desired.repoBaselines, live.repos ?? {}, entries); + diffTokenGrants(desired.tokenPolicy, live.tokenGrants ?? [], opts, entries); diffTeams(desired.teams, live.teams ?? {}, opts, entries); diffMembers(desired.members, live.members ?? [], opts, entries); diffRepos(desired.repos, live.repos ?? {}, opts, entries); @@ -917,6 +944,74 @@ function diffVariables( } } +// --------------------------------------------------------------------------- +// Token governance +// --------------------------------------------------------------------------- + +const MS_PER_DAY = 86_400_000; + +/** + * Evaluate a single token grant against the policy. Returns a short violation + * reason (e.g. "expired", "exceeds-max-lifetime", "idle") or null when the + * grant is compliant. Pure — age/idle checks require `nowMs` (skipped when + * undefined); the expiry check is clock-free (uses GitHub's `expired` flag). + */ +export function evaluateTokenViolation( + grant: LiveTokenGrant, + policy: TokenPolicyConfig, + nowMs?: number, +): string | null { + if (policy.revokeExpired !== false && grant.expired === true) return "expired"; + + if ( + policy.maxLifetimeDays != null && + nowMs != null && + grant.grantedAtMs != null && + (nowMs - grant.grantedAtMs) / MS_PER_DAY > policy.maxLifetimeDays + ) { + return "exceeds-max-lifetime"; + } + + if ( + policy.maxIdleDays != null && + nowMs != null && + grant.lastUsedAtMs != null && + (nowMs - grant.lastUsedAtMs) / MS_PER_DAY > policy.maxIdleDays + ) { + return "idle"; + } + + return null; +} + +/** + * Diff org token grants against the governance policy. A violating grant is + * emitted as an UPDATE (resource type "token-grant", key = grant id) meaning + * "revoke org access" — modelled as an update, not a delete, so a routine + * revocation sweep does not trip the removalDeltaCap guardrail. + */ +function diffTokenGrants( + policy: TokenPolicyConfig | undefined, + grants: LiveTokenGrant[], + opts: DiffOptions, + out: ChangeSetEntry[], +): void { + if (policy === undefined) return; + + for (const grant of grants) { + const reason = evaluateTokenViolation(grant, policy, opts.nowMs); + if (!reason) continue; + out.push({ + kind: "update", + resourceType: "token-grant", + key: String(grant.id), + before: grant, + after: { revoke: true, reason, ownerLogin: grant.ownerLogin }, + fields: [{ field: "access", before: "granted", after: `revoked (${reason})` }], + }); + } +} + // --------------------------------------------------------------------------- // Repository baseline / provisioning // --------------------------------------------------------------------------- diff --git a/src/reconcile/runner.ts b/src/reconcile/runner.ts index d91eb9e..7554f1e 100644 --- a/src/reconcile/runner.ts +++ b/src/reconcile/runner.ts @@ -357,8 +357,12 @@ export async function runReconcile( continue; } - // Step 3: diff. - const changeSet: ChangeSet = diff(orgLogin, desired, live, diffOptions); + // Step 3: diff. Inject a default "now" for time-based diffs (token grants) + // unless the caller supplied one (tests pass an explicit value). + const changeSet: ChangeSet = diff(orgLogin, desired, live, { + ...diffOptions, + nowMs: diffOptions.nowMs ?? Date.now(), + }); // Step 4: guardrails. const guardrailResult = runGuardrails(changeSet, live, guardrailConfig);