diff --git a/package-lock.json b/package-lock.json index 091bcb1..add54cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.5", + "version": "0.20.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.5", + "version": "0.20.6", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.5", + "version": "0.20.6", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.5", + "version": "0.20.6", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 794cab5..a1747a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.5", + "version": "0.20.6", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 6287d54..d5f85a8 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.20.5", + "version": "0.20.6", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index a0dc9ab..14a0ff3 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.20.5", + version: "0.20.6", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.5", + version: "0.20.6", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index fb0a3ca..e214ebf 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.20.5", + "version": "0.20.6", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 79ec7d0..cdb303f 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.5", + "version": "0.20.6", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 75895d0..e55b694 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -916,7 +916,7 @@ export async function scaffoldObligationOccurrence(input, options = {}) { && completionFallsInWindow(record, item, loaded.workspace.timezone, existing?.record.status === "reconciled" ? undefined : loaded.model) && completionMatchesMember(record, completionMemberField, resourceId, loaded.model, byId, obligation, program.id) )); - const accepted = matching.filter((record) => completionPassesActivity(record, activity, loaded.model, byId, asOf, loaded.workspace.timezone, obligation, program.id)); + const accepted = matching.filter((record) => completionPassesActivity(record, activity, loaded.model, byId, asOf, loaded.workspace.timezone, obligation, program.id, loaded)); return { ...prior, resourceId, @@ -1278,10 +1278,10 @@ function completionMatchesOccurrenceMember(record, field, memberId, model, resou function completionPassesOccurrence(record, obligation, activity, model, resources, occurrence, asOf, timezone, root, currentById, programId) { const context = occurrenceCompletionContext(root, occurrence, record, model, resources, timezone); const byId = context.resources === resources ? currentById : resourceIndex(context.resources).byId; - return completionPassesActivity(record, activity, context.model, byId, occurrenceProofDate(occurrence, asOf, timezone), timezone, byId.get(obligation.id) || obligation, programId); + return completionPassesActivity(record, activity, context.model, byId, occurrenceProofDate(occurrence, asOf, timezone), timezone, byId.get(obligation.id) || obligation, programId, { ...context, root }); } -function completionPassesActivity(record, activity, model, byId, asOf, timezone = "UTC", obligation, programId) { +function completionPassesActivity(record, activity, model, byId, asOf, timezone = "UTC", obligation, programId, sourceContext = {}) { const assessmentDate = asOf || currentCalendarDate(timezone); if (record.type === "attestation") { if (record.status !== "completed" || !record.completedOn || record.completedOn > assessmentDate @@ -1302,8 +1302,8 @@ function completionPassesActivity(record, activity, model, byId, asOf, timezone } if (record.type === "access-review" && obligation && !obligation.scopeResourceIds?.length && !obligation.selector && !byId.get(obligation.activeRuleId)?.selector && modelSupports(model, "operating-preparation")) { - const resources = [...byId.values()]; - const loaded = { resources, model, workspace: resources.find(({ type }) => type === "workspace") }; + const resources = sourceContext.resources || [...byId.values()]; + const loaded = { ...sourceContext, resources, model, workspace: resources.find(({ type }) => type === "workspace") }; const programs = resources.filter((candidate) => candidate.type === "program" && candidate.status !== "retired" && (!programId || candidate.id === programId) && recordBelongsToProgram(obligation, candidate, byId, model)); if (programs.length !== 1) return false; @@ -2124,7 +2124,7 @@ export function assessCompletionForObligation(record, obligation, loaded, option const date = executionDate(record, loaded.model, loaded.workspace?.timezone || "UTC"); const issues = []; if (!completionMatchesObligation(record, obligation, loaded.model)) issues.push("Use the completion record type, activity profile, subtype, and Obligation linkage required by the adopted activity."); - if (!completionPassesActivity(record, activity, loaded.model, byId, options.asOf, loaded.workspace?.timezone, obligation, options.programId)) issues.push("Finish the model-required actors, actual result, completion time, review and proof fields; a planned record or applicability decision is not test execution."); + if (!completionPassesActivity(record, activity, loaded.model, byId, options.asOf, loaded.workspace?.timezone, obligation, options.programId, loaded)) issues.push("Finish the model-required actors, actual result, completion time, review and proof fields; a planned record or applicability decision is not test execution."); if (!date || options.asOf && date > options.asOf || options.notBefore && date < options.notBefore) issues.push("The actual completion date must cover the current implementation and cannot be in the future."); if (options.window && !completionFallsInWindow(record, options.window, loaded.workspace?.timezone || "UTC", loaded.model)) issues.push("This result falls outside the adopted occurrence's allowed completion dates."); const memberIds = options.memberIds || []; diff --git a/packages/filegrc/src/provider-access-completion.js b/packages/filegrc/src/provider-access-completion.js index 3fcb4d8..5ebcd7f 100644 --- a/packages/filegrc/src/provider-access-completion.js +++ b/packages/filegrc/src/provider-access-completion.js @@ -1,3 +1,4 @@ +import { reviewHistoryContext, reviewHistoricalWorkspace, reviewHistoryCommits } from "./historical-workspace.js"; import { assessProviderAccessReconciliation } from "./provider-access-reconciliation.js"; import { preparationEvidenceIssues } from "./preparation-evidence.js"; import { personWasActiveOn } from "./soc2.js"; @@ -30,8 +31,87 @@ export function providerAccessPopulation(loaded, program, controls) { // Keep provider scope, grant decisions and source proof identical for setup and // program-wide recurring work. The caller owns its allowed completion window. export function assessProgramAccessReview(candidate, loaded, program, controls, asOf, options = {}) { + // A completed review proves a dated population, while readiness also asks + // whether today's providers and active access are covered. Do not rewrite + // old retain decisions or account totals after an account is later closed. + if (!options.historicalAssessment && loaded.root && !loaded.historicalCommit && candidate.status === "complete") { + const context = reviewHistoryContext(loaded.root, loaded); + const commits = reviewHistoryCommits(context); + // A review and its supporting grants or Evidence can land in separate + // commits. Start at its exact committed revision, then consider later + // source snapshots as well, while requiring that revision to stay intact. + const firstMatch = context.index?.available + ? [...context.index.historiesById.get(candidate.id) || []].reverse().find(({ commit }) => + JSON.stringify(context.index.recordsByCommit.get(commit)?.get(candidate.id)?.record) === JSON.stringify(candidate))?.commit + : null; + const start = context.index?.available ? commits.indexOf(firstMatch) : 0; + for (const commit of start < 0 ? [] : commits.slice(start)) { + const snapshot = reviewHistoricalWorkspace(context, commit); + if (JSON.stringify(snapshot?.byId.get(candidate.id)) !== JSON.stringify(candidate)) continue; + const priorProgram = snapshot.byId.get(program.id); + if (!priorProgram) continue; + const historical = assessProgramAccessReview(candidate, snapshot, priorProgram, + providerAccessControls(snapshot, priorProgram), candidate.approvedOn, { ...options, historicalAssessment: true }); + if (!historical.complete) continue; + const liveOptions = { ...options, historicalAssessment: true, historicalComponentIds: historical.reviewedComponentIds }; + const current = assessProgramAccessReview(candidate, loaded, program, controls, asOf, liveOptions); + // Only these dated-population differences are superseded by the exact + // historical assessment. Every other current or future failure survives. + const historicalDifferences = new Set(["supported-grant-decisions", "accountable-provider-identities", + "account-grant-mappings", "reconciled-account-counts", "unique-scoped-provider-reconciliations"]); + const missingFacts = current.missingFacts.filter((fact) => !historicalDifferences.has(fact)); + const currentById = new Map(loaded.resources.map((record) => [record.id, record])); + const invalidClosures = (candidate.grantDecisions || []).map(({ accessGrantId }) => accessGrantId).filter((id) => { + if (snapshot.byId.get(id)?.status !== "active") return false; + const grant = currentById.get(id); + if (grant?.status === "active") return false; + try { assertHistoricalRecordValid(grant, loaded.model, resourceDataPath(loaded.model, grant)); } catch { return true; } + return !["revoked", "expired"].includes(grant.status) || !grant.deprovisionedOn + || grant.deprovisionedOn < candidate.completedOn || grant.deprovisionedOn > asOf; + }); + if (invalidClosures.length) missingFacts.push("current-authorized-access"); + // Current active grants may not acquire different authority, subject or provider + // under the identity of an already reviewed grant. + const liveComponentIds = new Set([...providerAccessPopulation(loaded, program, controls).componentIds, ...historical.reviewedComponentIds]); + const reviewedGrantIds = new Set((candidate.grantDecisions || []).map(({ accessGrantId }) => accessGrantId)); + const grants = loaded.resources.filter((record) => record.type === "access-grant" && record.status === "active" + && (liveComponentIds.has(record.componentId) || reviewedGrantIds.has(record.id))); + const changed = grants.filter((grant) => { + const prior = snapshot.byId.get(grant.id); + return prior && (["subjectId", "componentId", "accessLevel", "role", "privileged"].some((field) => prior[field] !== grant[field])); + }).map(({ id }) => id); + if (changed.length) missingFacts.push("current-authorized-access"); + // Closed accounts remain historical. Still-live identities without a + // separate grant (for example an inventoried service identity) retain + // their current ownership, expiry and review requirements. + if (candidate.sourceReconciliations) { + const activeIds = new Set(grants.map(({ id }) => id)); + const liveCandidate = { ...candidate, + grantDecisions: (candidate.grantDecisions || []).filter(({ accessGrantId, decision }) => activeIds.has(accessGrantId) || decision === "remove"), + sourceReconciliations: candidate.sourceReconciliations.map((row) => row.accounts === undefined ? row : ({ ...row, + accounts: row.accounts?.filter((account) => account.disposition !== "authorized" + || (account.accessGrantIds?.length ? account.accessGrantIds.some((id) => activeIds.has(id)) + : loaded.resources.find(({ id }) => id === account.subjectId)?.status === "active")) + .map((account) => account.disposition === "authorized" && account.accessGrantIds?.length + ? { ...account, accessGrantIds: account.accessGrantIds.filter((id) => activeIds.has(id)) } : account) + })) }; + const live = assessProgramAccessReview(liveCandidate, loaded, program, controls, asOf, liveOptions); + // Projected account totals differ from the retained original exports; + // their completeness/proof checks were enforced above on the full rows. + const projectionDifferences = new Set(["reconciled-account-counts", "actual-provider-population-and-results"]); + missingFacts.push(...live.missingFacts.filter((fact) => !projectionDifferences.has(fact))); + } + return { ...historical, ...current, complete: missingFacts.length === 0, missingFacts: unique(missingFacts), + historicalReviewCommit: commit, issues: unique(missingFacts), + reviewedComponentIds: historical.reviewedComponentIds, invalidGrantIds: unique([...current.invalidGrantIds, ...changed, ...invalidClosures]), + missingSourceIds: [], missingMappedGrantIds: [], unresolvedAccountReferences: [] }; + } + } const byId = new Map(loaded.resources.map((record) => [record.id, record])); - const { componentIds, grants } = providerAccessPopulation(loaded, program, controls); + const population = providerAccessPopulation(loaded, program, controls); + const componentIds = unique([...population.componentIds, ...options.historicalComponentIds || []]); + const grants = options.historicalComponentIds ? loaded.resources.filter((record) => record.type === "access-grant" + && record.status === "active" && componentIds.includes(record.componentId)) : population.grants; const valid = (record) => { try { assertHistoricalRecordValid(record, loaded.model, resourceDataPath(loaded.model, record)); return true; } catch { return false; } @@ -52,7 +132,7 @@ export function assessProgramAccessReview(candidate, loaded, program, controls, const decisions = candidate.grantDecisions || []; const invalidGrantIds = grants.filter((grant) => { const subject = byId.get(grant.subjectId); - return !valid(grant) || grant.expiresOn && grant.expiresOn < asOf || !grant.provisionedOn || grant.provisionedOn > candidate.completedOn + return byId.get(grant.componentId)?.status !== "active" || !valid(grant) || grant.expiresOn && grant.expiresOn < asOf || !grant.provisionedOn || grant.provisionedOn > candidate.completedOn || !subject || subject.type === "person" && !personWasActiveOn(subject, asOf) || subject.type === "service-account" && (subject.status !== "active" || currentPartyPeople(subject.ownerIds || [], byId).size === 0); }).map(({ id }) => id); @@ -107,5 +187,5 @@ export function assessProgramAccessReview(candidate, loaded, program, controls, const missingSourceIds = reconciliation?.missingSourceIds || componentIds.filter((id) => !exports.some((proof) => proof.sourceComponentId === id && proof.populationCount === reviewedGrants.filter((grant) => grant?.componentId === id).length)); if (missingSourceIds.length) issues.push("actual-provider-population-and-results"); - return { resourceId: candidate.id, complete: !issues.length, missingFacts: issues, missingScopeIds, missingGrantIds, missingSourceIds, invalidGrantIds, unresolvedDecisionIds, permittedExceptionIds, remediationIds, ...(reconciliation || {}) }; + return { resourceId: candidate.id, reviewedComponentIds: componentIds, complete: !issues.length, missingFacts: issues, missingScopeIds, missingGrantIds, missingSourceIds, invalidGrantIds, unresolvedDecisionIds, permittedExceptionIds, remediationIds, ...(reconciliation || {}) }; } diff --git a/packages/filegrc/test/operating-baseline.test.js b/packages/filegrc/test/operating-baseline.test.js index 23622e3..864fabd 100644 --- a/packages/filegrc/test/operating-baseline.test.js +++ b/packages/filegrc/test/operating-baseline.test.js @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { reviewHistoryContext } from "../src/historical-workspace.js"; import { loadWorkspace } from "../src/workspace.js"; import { assessOperatingPreparation } from "../src/operating-preparation.js"; import { contentRevision } from "../src/files.js"; @@ -12,7 +14,7 @@ import { assessProgramReadiness } from "../src/program-readiness.js"; import { runCli } from "../src/cli.js"; import { captureCli } from "./helpers.js"; import { planModelMigration } from "../src/model-migration.js"; -import { planObligations } from "../src/obligations.js"; +import { assessCompletionForObligation, planObligations } from "../src/obligations.js"; async function fixture(context, code) { const root = await mkdtemp(join(tmpdir(), "filegrc-full-baseline-")); @@ -446,3 +448,192 @@ test("setup eligibility retains the planner's rule, activation anchor and offset }); } }); + + +test("committed access populations survive later closure while current scope and identities stay required", async (context) => { + const { root, loaded, get, baseline } = await fixture(context, "IAM-01"); + const review = get("access-review"), grant = get("access-grant"), proof = get("evidence"), service = get("service-account"); + const component = get("component"); + const serviceGrant = { ...grant, id: "access-grant-historical-service", subjectId: service.id }; + loaded.resources.push(serviceGrant); + Object.assign(service, { componentIds: [component.id], ownerIds: [get("person").id] }); + Object.assign(review, { completedOn: "2026-07-01", approvedOn: "2026-07-02", populationCount: 2, exceptionCount: 0, + grantDecisions: [grant, serviceGrant].map(({ id }) => ({ accessGrantId: id, decision: "retain" })), + sourceReconciliations: [{ componentId: component.id, boundary: "direct", populationEvidenceId: proof.id, + accounts: [grant, serviceGrant].map((item, index) => ({ accountReference: `account-${index}`, disposition: "authorized", + subjectId: item.subjectId, authorizationKind: "access-grant", accessGrantIds: [item.id] })) }] }); + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", timezone: "UTC", + externalReference: { url: "https://evidence.example.com/accounts" }, queryDescription: "All provider accounts", + populationCount: 2, completenessValidation: "Checked all account classes", accuracyValidation: "Matched subjects and permissions", + coverage: { kind: "as-of", on: "2026-07-01" } }); + delete proof.filePaths; + const noStore = { ...component, id: "component-no-account-store" }; + loaded.resources.push(noStore); review.componentIds.push(noStore.id); proof.componentIds = [component.id, noStore.id]; + review.sourceReconciliations.push({ componentId: noStore.id, boundary: "no-account-store", rationale: "This capability has no separate identity store", boundaryEvidenceIds: [proof.id] }); + for (const record of [review, grant, serviceGrant, proof, service, noStore]) await writeFile(join(root, "data", resourceDataPath(loaded.model, record)), JSON.stringify(record)); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }); + git("init", "--initial-branch=main"); git("config", "user.name", "Example Owner"); git("config", "user.email", "owner@example.com"); + git("add", "data/access-reviews"); git("commit", "-m", "Record completed provider review"); + git("add", "data"); git("commit", "-m", "Retain provider review supporting records"); + assert.equal((await baseline("provider-access")).complete, true); + const revoke = (item) => Object.assign(item, { status: "revoked", deprovisionedOn: "2026-08-01", + deprovisionedByIds: [get("person").id], deprovisionReason: "Closed after the reviewed population" }); + const serviceGrantBeforeClosure = structuredClone(serviceGrant); + serviceGrant.status = "requested"; + assert.equal((await baseline("provider-access")).complete, false, "Requested or approved access is not proved closed"); + Object.assign(serviceGrant, serviceGrantBeforeClosure); + revoke(serviceGrant); serviceGrant.deprovisionedOn = "2026-12-01"; + assert.equal((await baseline("provider-access")).complete, false, "A future revocation does not close today's access"); + serviceGrant.deprovisionedOn = "2026-06-01"; + assert.equal((await baseline("provider-access")).complete, false, "Backdating closure before the active reviewed grant is not a later lifecycle change"); + revoke(serviceGrant); service.status = "disabled"; + let result = await baseline("provider-access"); + assert.equal(result.complete, true, JSON.stringify(result.candidateResults)); + assert.equal(review.grantDecisions[1].decision, "retain"); + assert.equal(review.populationCount, 2); + Object.assign(component, { status: "retired", statusTransition: { changedOn: "2026-08-01", changedByIds: [get("person").id], reason: "Service retired" } }); + assert.equal((await baseline("provider-access")).complete, false, "Active access to a retired service still needs resolution"); + const control = get("control"), originalComponentIds = control.componentIds, originalSourceIds = control.evidenceSourceComponentIds; + control.componentIds = []; control.evidenceSourceComponentIds = []; + const orphan = (await baseline("provider-access")).candidateResults[0]; + assert.ok(orphan.missingFacts.includes("current-authorized-access"), "Removing retired provider links must not conceal still-active access"); + control.componentIds = originalComponentIds; control.evidenceSourceComponentIds = originalSourceIds; + revoke(grant); + result = await baseline("provider-access"); + assert.equal(result.complete, true, JSON.stringify(result.candidateResults)); + assert.deepEqual(result.candidateResults[0].issues, []); + const historyContext = reviewHistoryContext(root, loaded), originalIndex = historyContext.index; + historyContext.index = null; + assert.equal((await baseline("provider-access")).complete, true, "Bounded-index recovery falls back to committed Git history"); + historyContext.index = originalIndex; + assert.equal(review.sourceReconciliations.length, 2); + const actors = loaded.resources.filter(({ type }) => type === "person"); + const actorCopies = actors.map((actor) => structuredClone(actor)); + for (const actor of actors) Object.assign(actor, { status: "inactive", endDate: "2026-08-01" }); + assert.equal((await baseline("provider-access")).complete, true, "Dated reviewer, approver and collector work survives later departure"); + actors.forEach((actor, index) => { delete actor.endDate; Object.assign(actor, actorCopies[index]); }); + const obligation = get("obligation"), originalObligation = structuredClone(get("obligation")); + Object.assign(obligation, { activityType: "access-review", scopeResourceIds: [] }); + delete obligation.selector; + const rule = loaded.resources.find(({ id }) => id === obligation.activeRuleId); + const originalRule = rule && structuredClone(rule); + if (rule) delete rule.selector; + assert.equal(assessCompletionForObligation(review, obligation, loaded, { asOf: "2026-10-13", programId: get("program").id }).complete, true, + "Recurring completion must receive the same history context as setup readiness"); + Object.assign(obligation, originalObligation); + if (rule) Object.assign(rule, originalRule); + const nextComponent = { ...component, id: "component-new-provider", status: "active" }; + delete nextComponent.statusTransition; + loaded.resources.push(nextComponent); + assert.equal((await baseline("provider-access")).complete, false, "A newly active provider is not covered by the old snapshot"); + loaded.resources.pop(); component.status = "active"; delete component.statusTransition; + serviceGrant.status = "active"; delete serviceGrant.deprovisionedOn; delete serviceGrant.deprovisionedByIds; delete serviceGrant.deprovisionReason; + assert.equal((await baseline("provider-access")).complete, false, "A live grant to a disabled identity cannot inherit old authorization"); + service.status = "active"; + service.reviewDueOn = "2026-09-01"; + assert.equal((await baseline("provider-access")).complete, false, "A live identity still needs its current review"); + delete service.reviewDueOn; + assert.equal((await baseline("provider-access")).complete, true); + serviceGrant.accessLevel = "New elevated authority"; + assert.equal((await baseline("provider-access")).complete, false, "Material grant changes need current review"); + serviceGrant.accessLevel = grant.accessLevel; + const outside = { ...component, id: "component-outside-program", systemUses: [] }; + loaded.resources.push(outside); serviceGrant.componentId = outside.id; + assert.equal((await baseline("provider-access")).complete, false, "Moving reviewed access out of scope cannot conceal its changed authority"); + loaded.resources.pop(); serviceGrant.componentId = component.id; + const extraGrant = { ...serviceGrant, id: "access-grant-added-after-review" }; + loaded.resources.push(extraGrant); + assert.equal((await baseline("provider-access")).complete, false, "A new active grant requires its own decision and account mapping"); + loaded.resources.pop(); revoke(serviceGrant); service.status = "disabled"; + review.populationCount = 1; + assert.equal((await baseline("provider-access")).complete, false, "An edited completed review cannot reuse the unchanged committed approval"); +}); + +test("historical access reuse still checks retained removal proof", async (context) => { + const { root, loaded, get, baseline } = await fixture(context, "IAM-01"); + const review = get("access-review"), grant = get("access-grant"), proof = get("evidence"), component = get("component"); + Object.assign(grant, { status: "revoked", deprovisionedOn: "2026-07-01", deprovisionedByIds: [get("person").id], deprovisionReason: "Removed during review" }); + const resolution = { ...proof, id: "evidence-account-removal", artifactKind: "business-record", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T12:00:00Z", externalReference: { url: "https://evidence.example.com/removal" } }; + delete resolution.filePaths; + loaded.resources.push(resolution); + Object.assign(review, { completedOn: "2026-07-01", approvedOn: "2026-07-02", populationCount: 1, exceptionCount: 0, + evidenceIds: [proof.id, resolution.id], grantDecisions: [{ accessGrantId: grant.id, decision: "remove" }], + sourceReconciliations: [{ componentId: component.id, boundary: "direct", populationEvidenceId: proof.id, accounts: [{ + accountReference: "removed-account", disposition: "removed", subjectId: grant.subjectId, accessGrantIds: [grant.id], + rationale: "Removed excess access", resolutionEvidenceIds: [resolution.id] + }] }] }); + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", timezone: "UTC", + externalReference: { url: "https://evidence.example.com/accounts" }, queryDescription: "All provider accounts", populationCount: 1, + completenessValidation: "All account classes checked", accuracyValidation: "Matched actual account and permission", + coverage: { kind: "as-of", on: "2026-07-01" } }); + delete proof.filePaths; + for (const record of [review, grant, proof, resolution]) { + const path = join(root, "data", resourceDataPath(loaded.model, record)); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, JSON.stringify(record)); + } + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }); + git("init", "--initial-branch=main"); git("config", "user.name", "Example Owner"); git("config", "user.email", "owner@example.com"); + git("add", "data"); git("commit", "-m", "Complete access removal review"); + assert.equal((await baseline("provider-access")).complete, true); + resolution.status = "draft"; + const invalid = (await baseline("provider-access")).candidateResults[0]; + assert.equal(invalid.complete, false); + assert.ok(invalid.missingFacts.includes("resolved-provider-account-removals")); + resolution.status = "verified"; resolution.expiresOn = "2026-09-01"; + assert.equal((await baseline("provider-access")).complete, false); +}); + +test("revoking one permission preserves a historically reviewed multi-grant account", async (context) => { + const { root, loaded, get, baseline } = await fixture(context, "IAM-01"); + const review = get("access-review"), grant = get("access-grant"), proof = get("evidence"), component = get("component"); + const second = { ...grant, id: "access-grant-second-permission" }; + loaded.resources.push(second); + Object.assign(review, { completedOn: "2026-07-01", approvedOn: "2026-07-02", populationCount: 1, exceptionCount: 0, + grantDecisions: [grant, second].map(({ id }) => ({ accessGrantId: id, decision: "retain" })), + sourceReconciliations: [{ componentId: component.id, boundary: "direct", populationEvidenceId: proof.id, accounts: [{ + accountReference: "one-account", disposition: "authorized", subjectId: grant.subjectId, authorizationKind: "access-grant", accessGrantIds: [grant.id, second.id] + }] }] }); + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", timezone: "UTC", + externalReference: { url: "https://evidence.example.com/accounts" }, queryDescription: "All provider accounts", populationCount: 1, + completenessValidation: "All accounts checked", accuracyValidation: "Matched both permissions", + coverage: { kind: "as-of", on: "2026-07-01" } }); + delete proof.filePaths; + for (const record of [review, grant, second, proof]) await writeFile(join(root, "data", resourceDataPath(loaded.model, record)), JSON.stringify(record)); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }); + git("init", "--initial-branch=main"); git("config", "user.name", "Example Owner"); git("config", "user.email", "owner@example.com"); + git("add", "data"); git("commit", "-m", "Review multi-permission account"); + assert.equal((await baseline("provider-access")).complete, true); + Object.assign(second, { status: "revoked", deprovisionedOn: "2026-08-01", deprovisionedByIds: [get("person").id], deprovisionReason: "Removed one permission" }); + const result = await baseline("provider-access"); + assert.equal(result.complete, true, JSON.stringify(result.candidateResults)); + assert.deepEqual(review.sourceReconciliations[0].accounts[0].accessGrantIds, [grant.id, second.id]); + assert.equal(review.populationCount, 1); +}); + +test("a committed real zero-account review can omit optional grant decisions", async (context) => { + const { root, loaded, get, baseline } = await fixture(context, "IAM-01"); + const review = get("access-review"), grant = get("access-grant"), proof = get("evidence"), component = get("component"); + Object.assign(grant, { status: "revoked", deprovisionedOn: "2026-06-30", deprovisionedByIds: [get("person").id], deprovisionReason: "Access ended before the checked zero population" }); + Object.assign(review, { completedOn: "2026-07-01", approvedOn: "2026-07-02", populationCount: 0, exceptionCount: 0, + sourceReconciliations: [{ componentId: component.id, boundary: "direct", populationEvidenceId: proof.id, accounts: [] }] }); + delete review.grantDecisions; + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", timezone: "UTC", + externalReference: { url: "https://evidence.example.com/zero-accounts" }, queryDescription: "All account classes, including disabled and service identities", + populationCount: 0, completenessValidation: "Confirmed real zero source count", accuracyValidation: "Checked source scope and filters", + coverage: { kind: "as-of", on: "2026-07-01" } }); + delete proof.filePaths; + for (const record of [review, grant, proof]) await writeFile(join(root, "data", resourceDataPath(loaded.model, record)), JSON.stringify(record)); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }); + git("init", "--initial-branch=main"); git("config", "user.name", "Example Owner"); git("config", "user.email", "owner@example.com"); + git("add", "data"); git("commit", "-m", "Review actual zero-account population"); + const result = await baseline("provider-access"); + assert.equal(result.complete, true, JSON.stringify(result.candidateResults)); + assert.ok(result.candidateResults[0].historicalReviewCommit); + assert.equal(review.grantDecisions, undefined); +});