Skip to content

Commit 4daa2ff

Browse files
lex00claude
andauthored
feat(report): identity & service-account hygiene pass (#17) (#33)
Detect-and-report (report-style, per roadmap decision): inventories the org's installed Apps and flags operator-declared machine users (OrgConfig.machineUsers) that are seat-consuming org members, recommending migration to Apps. Pure buildIdentityReport + render, folded into the compliance report (optional identity field, flips clean) and surfaced via `report --identity`. Machine users are operator-declared because the API can't reliably mark them. Action bundle rebuilt. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d41b546 commit 4daa2ff

9 files changed

Lines changed: 368 additions & 5 deletions

File tree

action/index.mjs

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233710,8 +233710,49 @@ function shouldFail(report, failOn) {
233710233710
}
233711233711
}
233712233712

233713+
// src/report/identity.ts
233714+
function buildIdentityReport(installations, memberLogins, machineUserLogins) {
233715+
const apps = installations.map((i) => ({
233716+
slug: i.app_slug ?? "unknown",
233717+
appId: i.app_id,
233718+
permissionCount: i.permissions ? Object.keys(i.permissions).length : 0
233719+
}));
233720+
const memberSet = new Set(memberLogins);
233721+
const declared = [...new Set(machineUserLogins)];
233722+
const flagged = declared.filter((l) => memberSet.has(l));
233723+
const notMembers = declared.filter((l) => !memberSet.has(l));
233724+
const recommendations = [];
233725+
for (const login of flagged) {
233726+
recommendations.push(
233727+
`Machine user "${login}" consumes an org seat \u2014 replace it with a GitHub App (Apps consume no seat).`
233728+
);
233729+
}
233730+
return {
233731+
installations: { count: apps.length, apps },
233732+
machineUsers: { flagged, notMembers },
233733+
summary: { installationCount: apps.length, flaggedMachineUsers: flagged.length },
233734+
recommendations
233735+
};
233736+
}
233737+
function renderIdentityReport(report) {
233738+
const lines = [];
233739+
lines.push("--- identity & service-account hygiene ---");
233740+
lines.push(` installed apps: ${report.installations.count} (seat-free)`);
233741+
for (const a of report.installations.apps) {
233742+
lines.push(` ${a.slug} permissions=${a.permissionCount}`);
233743+
}
233744+
lines.push(
233745+
` machine users: ${report.machineUsers.flagged.length} flagged` + (report.machineUsers.notMembers.length ? `, ${report.machineUsers.notMembers.length} declared-not-member` : "")
233746+
);
233747+
for (const r of report.recommendations) {
233748+
lines.push(` \u26A0 ${r}`);
233749+
}
233750+
lines.push("");
233751+
return lines.join("\n");
233752+
}
233753+
233713233754
// src/report/compliance.ts
233714-
function buildComplianceReport(results, audit) {
233755+
function buildComplianceReport(results, audit, identity) {
233715233756
const cycles = [];
233716233757
const errored = [];
233717233758
const deferred = [];
@@ -233757,11 +233798,12 @@ function buildComplianceReport(results, audit) {
233757233798
mergeWorthy: auditMergeWorthy
233758233799
};
233759233800
}
233760-
const clean = drift === 0 && guardrailTrips === 0 && failed === 0 && errored.length === 0 && deferred.length === 0 && auditMergeWorthy === 0;
233801+
const clean = drift === 0 && guardrailTrips === 0 && failed === 0 && errored.length === 0 && deferred.length === 0 && auditMergeWorthy === 0 && (identity?.summary.flaggedMachineUsers ?? 0) === 0;
233761233802
return {
233762233803
modes: [...modeSet],
233763233804
cycles,
233764233805
audit: auditCompliance,
233806+
identity,
233765233807
totals: {
233766233808
drift,
233767233809
guardrailTrips,
@@ -233812,6 +233854,10 @@ function renderComplianceReport(report) {
233812233854
` total=${report.audit.total} merge-worthy=${report.audit.mergeWorthy} (quick-win=${report.audit.quickWin}, needs-review=${report.audit.needsReview}, report-only=${report.audit.reportOnly})`
233813233855
);
233814233856
}
233857+
if (report.identity) {
233858+
lines.push("");
233859+
lines.push(renderIdentityReport(report.identity).trimEnd());
233860+
}
233815233861
lines.push("");
233816233862
lines.push("--- totals ---");
233817233863
const t = report.totals;
@@ -234020,6 +234066,7 @@ function parseReportArgs(argv) {
234020234066
cycles: [],
234021234067
out: void 0,
234022234068
audit: false,
234069+
identity: false,
234023234070
failOn: "none"
234024234071
};
234025234072
const knownFlags = /* @__PURE__ */ new Set([
@@ -234030,6 +234077,7 @@ function parseReportArgs(argv) {
234030234077
"--cycles",
234031234078
"--out",
234032234079
"--audit",
234080+
"--identity",
234033234081
"--fail-on"
234034234082
]);
234035234083
let i = 0;
@@ -234079,6 +234127,10 @@ function parseReportArgs(argv) {
234079234127
args.audit = true;
234080234128
break;
234081234129
}
234130+
case "--identity": {
234131+
args.identity = true;
234132+
break;
234133+
}
234082234134
case "--fail-on": {
234083234135
const val = argv[++i];
234084234136
if (val !== "none" && val !== "attention") {
@@ -234544,7 +234596,39 @@ async function runReport(argv) {
234544234596
}
234545234597
}
234546234598
}
234547-
const report = buildComplianceReport([result], auditReport);
234599+
let identityReport;
234600+
if (reportArgs.identity) {
234601+
try {
234602+
const installations = [];
234603+
const memberLogins = [];
234604+
const machineUsers = [];
234605+
for (const [orgName, orgCfg] of Object.entries(config2.orgs)) {
234606+
try {
234607+
const data = await client.request(
234608+
"GET",
234609+
`/orgs/${orgName}/installations?per_page=100`
234610+
);
234611+
installations.push(...data.installations ?? []);
234612+
} catch (err) {
234613+
if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err;
234614+
}
234615+
try {
234616+
const members = await client.request(
234617+
"GET",
234618+
`/orgs/${orgName}/members?per_page=100`
234619+
);
234620+
for (const m of members ?? []) if (typeof m.login === "string") memberLogins.push(m.login);
234621+
} catch (err) {
234622+
if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err;
234623+
}
234624+
machineUsers.push(...orgCfg.machineUsers ?? []);
234625+
}
234626+
identityReport = buildIdentityReport(installations, memberLogins, machineUsers);
234627+
} catch (err) {
234628+
die(3, `identity pass failed: ${errMsg(err)}`);
234629+
}
234630+
}
234631+
const report = buildComplianceReport([result], auditReport, identityReport);
234548234632
report.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
234549234633
process.stdout.write(renderComplianceReport(report));
234550234634
if (reportArgs.out) {
@@ -234595,6 +234679,7 @@ function printUsage() {
234595234679
" --cycles <name[,name...]> Cycles to include (default: all).",
234596234680
" --out <path> Write the JSON compliance artifact to this path.",
234597234681
" --audit Include an audit pass in the report.",
234682+
" --identity Include an identity & service-account hygiene pass.",
234598234683
" --fail-on none|attention Exit 4 when the report needs attention (default: none).",
234599234684
"",
234600234685
"Exit codes:",

src/cli.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { auditRepos } from "./audit/engine.js";
4545
import { renderPostureSummary, shouldFail, type FailOn } from "./audit/summary.js";
4646
import type { Cycle, ReconcileResult } from "./reconcile/runner.js";
4747
import { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js";
48+
import { buildIdentityReport, type RawInstallation } from "./report/identity.js";
4849

4950
// ---------------------------------------------------------------------------
5051
// Arg parser
@@ -320,6 +321,8 @@ export interface ReportArgs {
320321
out: string | undefined;
321322
/** Include an audit pass in the report. */
322323
audit: boolean;
324+
/** Include an identity & service-account hygiene pass in the report. */
325+
identity: boolean;
323326
/** Exit non-zero when the report needs attention. */
324327
failOn: "none" | "attention";
325328
}
@@ -345,6 +348,7 @@ export function parseReportArgs(argv: string[]): ReportArgs {
345348
cycles: [],
346349
out: undefined,
347350
audit: false,
351+
identity: false,
348352
failOn: "none",
349353
};
350354

@@ -356,6 +360,7 @@ export function parseReportArgs(argv: string[]): ReportArgs {
356360
"--cycles",
357361
"--out",
358362
"--audit",
363+
"--identity",
359364
"--fail-on",
360365
]);
361366

@@ -407,6 +412,10 @@ export function parseReportArgs(argv: string[]): ReportArgs {
407412
args.audit = true;
408413
break;
409414
}
415+
case "--identity": {
416+
args.identity = true;
417+
break;
418+
}
410419
case "--fail-on": {
411420
const val = argv[++i];
412421
if (val !== "none" && val !== "attention") {
@@ -1082,8 +1091,44 @@ async function runReport(argv: string[]): Promise<void> {
10821091
}
10831092
}
10841093

1094+
// ── Optional identity & service-account hygiene pass ───────────────────────
1095+
let identityReport;
1096+
if (reportArgs.identity) {
1097+
try {
1098+
const installations: RawInstallation[] = [];
1099+
const memberLogins: string[] = [];
1100+
const machineUsers: string[] = [];
1101+
for (const [orgName, orgCfg] of Object.entries(config.orgs)) {
1102+
// App installations on the org (tolerate 403/404 — no access / none).
1103+
try {
1104+
const data = await client.request<{ installations?: RawInstallation[] }>(
1105+
"GET",
1106+
`/orgs/${orgName}/installations?per_page=100`,
1107+
);
1108+
installations.push(...(data.installations ?? []));
1109+
} catch (err) {
1110+
if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err;
1111+
}
1112+
// Org members (logins).
1113+
try {
1114+
const members = await client.request<Array<{ login?: string }>>(
1115+
"GET",
1116+
`/orgs/${orgName}/members?per_page=100`,
1117+
);
1118+
for (const m of members ?? []) if (typeof m.login === "string") memberLogins.push(m.login);
1119+
} catch (err) {
1120+
if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err;
1121+
}
1122+
machineUsers.push(...(orgCfg.machineUsers ?? []));
1123+
}
1124+
identityReport = buildIdentityReport(installations, memberLogins, machineUsers);
1125+
} catch (err) {
1126+
die(3, `identity pass failed: ${errMsg(err)}`);
1127+
}
1128+
}
1129+
10851130
// ── Aggregate + output ─────────────────────────────────────────────────────
1086-
const report = buildComplianceReport([result], auditReport);
1131+
const report = buildComplianceReport([result], auditReport, identityReport);
10871132
report.generatedAt = new Date().toISOString();
10881133
process.stdout.write(renderComplianceReport(report));
10891134

@@ -1136,6 +1181,7 @@ function printUsage() {
11361181
" --cycles <name[,name...]> Cycles to include (default: all).",
11371182
" --out <path> Write the JSON compliance artifact to this path.",
11381183
" --audit Include an audit pass in the report.",
1184+
" --identity Include an identity & service-account hygiene pass.",
11391185
" --fail-on none|attention Exit 4 when the report needs attention (default: none).",
11401186
"",
11411187
"Exit codes:",

src/cli/cli.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ describe("parseReportArgs", () => {
151151
expect(args.failOn).toBe("attention");
152152
});
153153

154+
it("parses --identity", () => {
155+
const args = parseReportArgs(["--config", "g.yml", "--token-env", "GH_TOKEN", "--identity"]);
156+
expect(args.identity).toBe(true);
157+
});
158+
154159
it("throws code 2 when auth is missing", () => {
155160
expect(() => parseReportArgs(["--config", "g.yml"])).toThrow(
156161
expect.objectContaining({ code: 2 }),

src/config/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,13 @@ export interface OrgConfig {
431431
* Absent means repo provisioning is not managed by chant.
432432
*/
433433
repoBaselines?: RepoBaselineConfig[];
434+
/**
435+
* Known machine / service-account logins. The identity report flags any of
436+
* these that are seat-consuming org members and recommends migrating them to
437+
* GitHub Apps (Apps consume no seat). The API cannot reliably distinguish a
438+
* machine user from a person, so this list is operator-declared.
439+
*/
440+
machineUsers?: string[];
434441
}
435442

436443
/**

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,5 @@ export type {
125125
ComplianceError,
126126
} from "./report/compliance.js";
127127
export { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js";
128+
export type { IdentityReport, InstalledApp, RawInstallation } from "./report/identity.js";
129+
export { buildIdentityReport, renderIdentityReport } from "./report/identity.js";

src/report/compliance.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "./compliance.js";
1313
import type { ReconcileResult, CycleResult } from "../reconcile/runner.js";
1414
import type { PostureReport } from "../audit/engine.js";
15+
import { buildIdentityReport } from "./identity.js";
1516

1617
// ---------------------------------------------------------------------------
1718
// Builders for mock run results
@@ -152,6 +153,19 @@ describe("buildComplianceReport", () => {
152153
expect(report.clean).toBe(true);
153154
});
154155

156+
it("folds in an identity report and flips clean when machine users are flagged", () => {
157+
const identity = buildIdentityReport([], ["ci-bot"], ["ci-bot"]);
158+
const report = buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })], undefined, identity);
159+
expect(report.identity).toBe(identity);
160+
expect(report.clean).toBe(false); // flagged machine user
161+
});
162+
163+
it("stays clean with an identity report that flags nobody", () => {
164+
const identity = buildIdentityReport([{ app_slug: "warden" }], ["alice"], []);
165+
const report = buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })], undefined, identity);
166+
expect(report.clean).toBe(true);
167+
});
168+
155169
it("merges multiple reconcile results and dedupes modes", () => {
156170
const report = buildComplianceReport([
157171
reconcileResult({ mode: "dry-run", cycles: [cycleResult()] }),

src/report/compliance.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import type { ReconcileResult } from "../reconcile/runner.js";
1818
import type { PostureReport } from "../audit/engine.js";
19+
import type { IdentityReport } from "./identity.js";
20+
import { renderIdentityReport } from "./identity.js";
1921

2022
// ---------------------------------------------------------------------------
2123
// Public types
@@ -63,6 +65,8 @@ export interface ComplianceReport {
6365
cycles: CycleComplianceEntry[];
6466
/** Audit totals, when an audit report was supplied. */
6567
audit?: AuditCompliance;
68+
/** Identity & service-account hygiene, when an identity pass was run. */
69+
identity?: IdentityReport;
6670
/** Cross-cutting roll-ups. */
6771
totals: {
6872
/** Total change-set entries across all cycles (total drift). */
@@ -103,6 +107,7 @@ export interface ComplianceReport {
103107
export function buildComplianceReport(
104108
results: ReconcileResult[],
105109
audit?: PostureReport,
110+
identity?: IdentityReport,
106111
): ComplianceReport {
107112
const cycles: CycleComplianceEntry[] = [];
108113
const errored: ComplianceError[] = [];
@@ -163,12 +168,14 @@ export function buildComplianceReport(
163168
failed === 0 &&
164169
errored.length === 0 &&
165170
deferred.length === 0 &&
166-
auditMergeWorthy === 0;
171+
auditMergeWorthy === 0 &&
172+
(identity?.summary.flaggedMachineUsers ?? 0) === 0;
167173

168174
return {
169175
modes: [...modeSet],
170176
cycles,
171177
audit: auditCompliance,
178+
identity,
172179
totals: {
173180
drift,
174181
guardrailTrips,
@@ -238,6 +245,11 @@ export function renderComplianceReport(report: ComplianceReport): string {
238245
);
239246
}
240247

248+
if (report.identity) {
249+
lines.push("");
250+
lines.push(renderIdentityReport(report.identity).trimEnd());
251+
}
252+
241253
lines.push("");
242254
lines.push("--- totals ---");
243255
const t = report.totals;

0 commit comments

Comments
 (0)