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
93 changes: 92 additions & 1 deletion action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -812,6 +834,7 @@ var init_diff = __esm({
"org-secret",
"org-variable",
"token-grant",
"token-request",
"repo-baseline",
"team",
"team-member",
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,4 +43,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
[dependencyHygieneCycle.name]: dependencyHygieneCycle,
[repoBaselineCycle.name]: repoBaselineCycle,
[tokenGovernanceCycle.name]: tokenGovernanceCycle,
[tokenApprovalCycle.name]: tokenApprovalCycle,
};
28 changes: 28 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading