diff --git a/package-lock.json b/package-lock.json index bfb3ee8..8be06f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.1", + "version": "0.20.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.1", + "version": "0.20.2", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.1", + "version": "0.20.2", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.1", + "version": "0.20.2", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 6cad774..57cbe54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.1", + "version": "0.20.2", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 00008e6..faf7e3b 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.20.1", + "version": "0.20.2", "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 54a7b68..954d557 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.1", + version: "0.20.2", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.1", + version: "0.20.2", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 9b0019b..8150ee1 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.1", + "version": "0.20.2", "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 84704aa..8565c9d 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.1", + "version": "0.20.2", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/audit-preparation.js b/packages/filegrc/src/audit-preparation.js index 5e9f735..24a9549 100644 --- a/packages/filegrc/src/audit-preparation.js +++ b/packages/filegrc/src/audit-preparation.js @@ -98,7 +98,7 @@ export async function assessAuditPreparation(input, options = {}) { await documentsStage(loaded, audit, byId, calendarAsOf), evidenceStage(audit, records, byId, loaded.model), populationsStage(audit, records, byId, loaded.model), - occurrenceContinuityStage(audit, records, loaded.model, programReadiness.asOf) + occurrenceContinuityStage(audit, records, loaded.model, programReadiness.asOf, loaded.root) ] : []; stages.push(fieldworkStage(audit, fieldworkSections, programReadiness)); @@ -956,7 +956,7 @@ function fieldworkStage(audit, sections, programReadiness) { ); } -function occurrenceContinuityStage(audit, records, model, asOf) { +function occurrenceContinuityStage(audit, records, model, asOf, root) { if (audit.auditKind !== "soc-2-type-2") { return stage( "occurrences", @@ -991,6 +991,7 @@ function occurrenceContinuityStage(audit, records, model, asOf) { || record.controlIds.some((id) => selectedControlIds.has(id)) )); const plan = planObligations(periodResources, { + root, programId: audit.programId, from: periodStart, asOf: periodThrough, diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index 8f4f94a..2e58d03 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -446,7 +446,7 @@ export async function runCli(argv = process.argv.slice(2)) { } if (command === "obligations") { const loaded = await loadWorkspace(root); - const result = planObligations(loaded.resources, { + const result = planObligations(loaded.resources, { root: loaded.root, programId: flags.program, asOf: flags["as-of"] ?? currentCalendarDate(loaded.workspace.timezone), from: flags.from, @@ -1093,6 +1093,7 @@ export async function runCli(argv = process.argv.slice(2)) { return result; } if (command === "reconcile-obligation") { + if (Object.hasOwn(flags, "preview")) throw new Error("reconcile-obligation does not support --preview. Use --scaffold to inspect the occurrence before saving."); const [obligationId, file] = positionals; if (!obligationId) throw new Error("An Obligation ID is required."); if (flags.scaffold) { diff --git a/packages/filegrc/src/evidence-packet.js b/packages/filegrc/src/evidence-packet.js index 6e89ac7..36accdf 100644 --- a/packages/filegrc/src/evidence-packet.js +++ b/packages/filegrc/src/evidence-packet.js @@ -99,7 +99,7 @@ export async function prepareEvidencePacket(input, options = {}) { byId.get(id)?.type === "evidence" ? byId.get(id).sourceResourceIds || [] : [] )) ); - const plan = planObligations(records, { + const plan = planObligations(records, { root: loaded.root, programId: program?.id || options.programId || audit?.programId, asOf: end, from: start, diff --git a/packages/filegrc/src/obligation-completion-context.js b/packages/filegrc/src/obligation-completion-context.js new file mode 100644 index 0000000..5655cd4 --- /dev/null +++ b/packages/filegrc/src/obligation-completion-context.js @@ -0,0 +1,21 @@ +import { reviewHistoryContext, reviewHistoricalWorkspace, reviewHistoryCommits } from "./historical-workspace.js"; + +// Reassess a committed reconciliation against the source facts that were +// committed with it, so later provider or grant changes do not reopen history. +// New or edited reconciliations and completion records use current facts. +export function occurrenceCompletionContext(root, occurrence, completion, model, resources, timezone) { + const current = { model, resources, workspace: { timezone } }; + if (!root || !["reconciled", "superseded"].includes(occurrence.status) || completion.type !== "access-review") return current; + const context = reviewHistoryContext(root, resources); + const history = context.index?.available + ? context.index.historiesById.get(occurrence.id) || [] + : reviewHistoryCommits(context).map((commit) => ({ commit })); + for (const { commit } of [...history].reverse()) { + const loaded = reviewHistoricalWorkspace(context, commit); + const prior = loaded?.byId.get(occurrence.id); + const priorCompletion = loaded?.byId.get(completion.id); + if (prior?.status === "reconciled" && JSON.stringify(prior) === JSON.stringify({ ...occurrence, status: "reconciled" }) + && JSON.stringify(priorCompletion) === JSON.stringify(completion)) return loaded; + } + return current; +} diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index b1942dd..27b3f72 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,3 +1,5 @@ +import { occurrenceCompletionContext } from "./obligation-completion-context.js"; +import { assessProgramAccessReview, providerAccessControls } from "./provider-access-completion.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { attestationBindingIssue } from "./attestation-integrity.js"; @@ -252,9 +254,9 @@ export function planObligations(resources, options = {}) { .filter((member) => occurrenceMemberIsResolved(member, reconciliation, byId, (completionId) => { const completion = byId.get(completionId); return completionMatchesObligation(completion, obligation, model) - && completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId, model, byId, obligation) + && completionMatchesOccurrenceMember(completion, activity.aggregate?.completionMemberField, member.resourceId, model, records, reconciliation, obligation, workspace?.timezone, options.root, byId, program?.id) && completionFallsInWindow(completion, window, rule?.timezone || workspace?.timezone || "UTC", reconciliation?.status === "reconciled" ? undefined : model) - && completionPassesActivity(completion, activity, model, byId, occurrenceProofDate(reconciliation, asOf, workspace?.timezone), workspace?.timezone); + && completionPassesOccurrence(completion, obligation, activity, model, records, reconciliation, asOf, workspace?.timezone, options.root, byId, program?.id); })) .map((member) => member.resourceId)); const resolutionsComplete = membershipFinal @@ -350,9 +352,9 @@ export function planObligations(resources, options = {}) { .filter((member) => occurrenceMemberIsResolved(member, occurrence, byId, (completionId) => { const completion = byId.get(completionId); return completionMatchesObligation(completion, obligation, model) - && completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId, model, byId, obligation) + && completionMatchesOccurrenceMember(completion, activity.aggregate?.completionMemberField, member.resourceId, model, records, occurrence, obligation, workspace?.timezone, options.root, byId, program?.id) && completionFallsInWindow(completion, window, rule.timezone || workspace?.timezone || "UTC") - && completionPassesActivity(completion, activity, model, byId, occurrenceProofDate(occurrence, asOf, workspace?.timezone), workspace?.timezone); + && completionPassesOccurrence(completion, obligation, activity, model, records, occurrence, asOf, workspace?.timezone, options.root, byId, program?.id); })) .map(({ resourceId }) => resourceId); const resolutionsComplete = membershipFinal && expectedMemberIds.every((resourceId) => resolvedMemberIds.includes(resourceId)); @@ -672,6 +674,7 @@ async function completeObligationOccurrenceUnlocked(input, options) { const completedOn = executionDate(completionRecord, loaded.model, loaded.workspace.timezone); if (!completedOn) throw new Error("An actual completion date is required."); const plan = planObligations(loaded.resources, { + root: loaded.root, programId: program.id, asOf: completedOn, through: completedOn, includeComplete: true, model: loaded.model }); @@ -698,6 +701,7 @@ async function completeObligationOccurrenceUnlocked(input, options) { throw new Error("The occurrence or governing rule changed. Reload the Work Queue before completing it."); } const assessment = assessCompletionForObligation(completionRecord, obligation, loaded, { + programId: program.id, window: item, asOf: currentCalendarDate(loaded.workspace.timezone) }); if (!assessment.complete) throw new Error(assessment.issues.join(" ")); @@ -818,6 +822,7 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const windowStart = requireDate(options.windowStart, "occurrence window start"); const asOf = requireDate(options.asOf || currentCalendarDate(loaded.workspace.timezone), "as-of date"); const plan = planObligations(loaded.resources, { + root: loaded.root, programId: program.id, from: windowStart, asOf, @@ -904,9 +909,9 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const matching = [...loaded.resources, ...(options.completionRecord && !byId.has(options.completionRecord.id) ? [options.completionRecord] : [])].filter((record) => ( completionMatchesObligation(record, obligation, loaded.model) && completionFallsInWindow(record, item, loaded.workspace.timezone, existing?.record.status === "reconciled" ? undefined : loaded.model) - && completionMatchesMember(record, completionMemberField, resourceId, loaded.model, byId, obligation) + && 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)); + const accepted = matching.filter((record) => completionPassesActivity(record, activity, loaded.model, byId, asOf, loaded.workspace.timezone, obligation, program.id)); return { ...prior, resourceId, @@ -1259,7 +1264,19 @@ function occurrenceProofDate(occurrence, asOf, timezone = "UTC") { return date && date <= asOf ? date : asOf; } -function completionPassesActivity(record, activity, model, byId, asOf, timezone = "UTC") { +function completionMatchesOccurrenceMember(record, field, memberId, model, resources, occurrence, obligation, timezone, root, currentById, programId) { + const context = occurrenceCompletionContext(root, occurrence, record, model, resources, timezone); + const byId = context.resources === resources ? currentById : resourceIndex(context.resources).byId; + return completionMatchesMember(record, field, memberId, context.model, byId, byId.get(obligation.id) || obligation, programId); +} + +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); +} + +function completionPassesActivity(record, activity, model, byId, asOf, timezone = "UTC", obligation, programId) { const assessmentDate = asOf || currentCalendarDate(timezone); if (record.type === "attestation") { if (record.status !== "completed" || !record.completedOn || record.completedOn > assessmentDate @@ -1278,6 +1295,17 @@ function completionPassesActivity(record, activity, model, byId, asOf, timezone if (!spec.statuses.includes(record.status) || spec.resultField && !spec.passingResults.includes(record[spec.resultField])) return false; if (activity.completionProfile && record.profileId !== activity.completionProfile) return false; } + 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 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; + const program = programs[0]; + const controls = providerAccessControls(loaded, program); + if (!assessProgramAccessReview(record, loaded, program, controls, assessmentDate).complete) return false; + } const aggregate = activity.aggregate; if (model?.operatingPreparation?.completionRecords?.[record.type]?.externalOperationProof) return true; if (!aggregate) return true; @@ -1289,7 +1317,7 @@ function completionPassesActivity(record, activity, model, byId, asOf, timezone return true; } -export function completionMatchesMember(record, field, resourceId, model, byId, obligation) { +export function completionMatchesMember(record, field, resourceId, model, byId, obligation, programId) { if (record?.type === "attestation") { const { expectedPeople, requiredSubjects } = attestationCompletionScope(obligation, byId); if (expectedPeople.length && !expectedPeople.includes(record.personId)) return false; @@ -1300,8 +1328,15 @@ export function completionMatchesMember(record, field, resourceId, model, byId, return Boolean(model?.resources.attestation?.fields.subjectResourceIds?.relation?.includes(memberType) && subjects.has(resourceId)); } + // The workspace is the planner sentinel for a single unscoped activity, + // not a literal member of the native completion record's population. if (byId?.get(resourceId)?.type === "workspace" && !obligation?.scopeResourceIds?.length - && record.obligationId === obligation?.id) return true; + && obligation) { + const programs = [...(byId?.values() || [])].filter((candidate) => candidate.type === "program" && candidate.status !== "retired" + && (!programId || candidate.id === programId) && recordBelongsToProgram(obligation, candidate, byId, model)); + return programs.length === 0 && (record.obligationId === obligation.id || !modelSupports(model, "program-scope")) + || programs.some((program) => recordBelongsToProgram(record, program, byId, model)); + } const includes = (value) => Array.isArray(value) ? value.includes(resourceId) : value === resourceId; if (field && includes(record[field])) return true; const spec = modelSupports(model, "operating-preparation") && model?.operatingPreparation?.completionRecords?.[record.type]; @@ -1465,6 +1500,7 @@ function obligationCompletionContext(loaded, item) { function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn, programId) { const start = requireDate(windowStart, "occurrence window start"); const plan = planObligations(loaded.resources, { + root: loaded.root, programId, from: start, asOf: completedOn, @@ -1483,6 +1519,7 @@ function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completed function plannedActionForScaffold(loaded, action, completedOn, programId) { const plan = planObligations(loaded.resources, { + root: loaded.root, programId, asOf: completedOn, through: completedOn, @@ -2076,16 +2113,17 @@ function completionFallsInWindow(record, window, timezone = "UTC", model) { } export function assessCompletionForObligation(record, obligation, loaded, options = {}) { + if (loaded.historicalCommit) obligation = loaded.resources.find(({ id }) => id === obligation.id) || obligation; const activity = obligationActivity(loaded.model, obligation); const byId = new Map(loaded.resources.map((current) => [current.id, current])); 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)) 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)) 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 || []; - const missingMemberIds = memberIds.filter((id) => !completionMatchesMember(record, activity.aggregate?.completionMemberField, id, loaded.model, byId, obligation)); + const missingMemberIds = memberIds.filter((id) => !completionMatchesMember(record, activity.aggregate?.completionMemberField, id, loaded.model, byId, obligation, options.programId)); if (missingMemberIds.length) issues.push("This result does not cover every requested population member."); return { complete: !issues.length, date, issues, missingMemberIds }; } diff --git a/packages/filegrc/src/operating-baseline.js b/packages/filegrc/src/operating-baseline.js index b2be2f1..834d134 100644 --- a/packages/filegrc/src/operating-baseline.js +++ b/packages/filegrc/src/operating-baseline.js @@ -1,4 +1,4 @@ -import { assessProviderAccessReconciliation } from "./provider-access-reconciliation.js"; +import { assessProgramAccessReview, providerAccessPopulation, providerAccessControls } from "./provider-access-completion.js"; import { readFile } from "node:fs/promises"; import { markdownEntries, resourceDataPath } from "./resource-markdown.js"; import { resolveDataPath } from "./paths.js"; @@ -53,11 +53,11 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak }; const timezone = loaded.workspace?.timezone || "UTC"; const assessedAt = asOf === currentCalendarDate(timezone) ? new Date().toISOString() : timestampFromLocalDateTime(`${asOf}T23:59:59`, timezone); - const calendar = planObligations(loaded.resources, { model: loaded.model, programId: program.id, asOf, from: asOf, through: asOf, includeComplete: true }).calendarItems; + const calendar = planObligations(loaded.resources, { root: loaded.root, model: loaded.model, programId: program.id, asOf, from: asOf, through: asOf, includeComplete: true }).calendarItems; const checks = []; for (const profile of loaded.model.operatingPreparation?.baselineProfiles || []) { const schedules = obligations.filter((record) => profile.activities.includes(record.activityType)); - const selected = controls.filter((control) => profile.controlCodes.includes(control.code) + const selected = profile.id === "provider-access" ? providerAccessControls(loaded, program, controls) : controls.filter((control) => profile.controlCodes.includes(control.code) || schedules.some((record) => record.controlIds?.includes(control.id) || ruleFor(record).controlIds?.includes(control.id))); if (!selected.length) continue; const controlIds = selected.map(({ id }) => id); @@ -187,72 +187,14 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak populationIds: population, subjectPopulations, subjectIds: subjects.map(({ id }) => id), missingMembers: missing, resultIds: unique(results), missingFacts: complete ? [] : [...(!subjects.length ? ["approved-subjects"] : []), ...(!population.length ? ["current-workforce"] : []), ...(missingAudiences.length ? ["configured-role-training-audience"] : []), ...unique(missing.flatMap(({ missingFacts }) => missingFacts))] }; } else if (profile.id === "provider-access") { - const componentIds = unique([...programComponents(loaded, program).filter(({ status }) => status === "active").map(({ id }) => id), - ...selected.flatMap((control) => [...control.componentIds || [], ...control.evidenceSourceComponentIds || []])]); - const grants = loaded.resources.filter((grant) => grant.type === "access-grant" && componentIds.includes(grant.componentId) - && grant.status === "active"); + const { componentIds, grants } = providerAccessPopulation(loaded, program, selected); const assessments = await Promise.all(candidates.map(async (candidate) => { - if (!valid(candidate)) return { resourceId: candidate.id, complete: false, missingFacts: ["valid-access-review"], missingScopeIds: [], missingGrantIds: [], missingSourceIds: [], invalidGrantIds: [], unresolvedDecisionIds: [], permittedExceptionIds: [], remediationIds: [] }; - const issues = [], missingScopeIds = componentIds.filter((id) => !candidate.componentIds?.includes(id)); - 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 - || !subject || subject.type === "person" && !personWasActiveOn(subject, asOf) - || subject.type === "service-account" && (subject.status !== "active" || currentPartyPeople(subject.ownerIds || [], byId).size === 0); - }).map(({ id }) => id); - if (invalidGrantIds.length) issues.push("current-authorized-access"); - const missingGrantIds = grants.filter((grant) => !decisions.some(({ accessGrantId }) => accessGrantId === grant.id)).map(({ id }) => id); - if (!valid(candidate) || candidate.status !== "complete" || !["passed", "passed-with-exceptions"].includes(candidate.outcome) - || !permittedDate(candidate.completedOn)) issues.push("completed-access-reconciliation"); - if (!candidate.approvedOn || candidate.approvedOn < candidate.completedOn || candidate.approvedOn > asOf - || !actualPeople(candidate.reviewerIds, candidate.completedOn) || !actualPeople(candidate.approvedByIds, candidate.approvedOn)) issues.push("existing-access-review-approval"); - if (missingScopeIds.length) issues.push("all-provider-components"); - if (missingGrantIds.length || unique(decisions.map(({ accessGrantId }) => accessGrantId)).length !== decisions.length) issues.push("per-grant-decisions"); - const reviewedGrants = decisions.map(({ accessGrantId }) => byId.get(accessGrantId)); - const unresolvedDecisionIds = []; - const permittedExceptionIds = []; - const remediationIds = []; - const followUps = (grant) => loaded.resources.filter((action) => action.type === "action-item" && action.sourceResourceId === grant.id && valid(action)); - const exceptionFor = (grant) => loaded.resources.find((exception) => exception.type === "exception" && exception.status === "approved" - && valid(exception) && exception.scopeResourceIds?.includes(grant.id) && exception.rationale?.trim() - && exception.approval.approvedOn <= candidate.approvedOn && exception.approval.expiresOn >= asOf - && actualPeople(exception.approval.approvedByIds, exception.approval.approvedOn) - && currentPartyPeople(exception.ownerIds || [], byId).size > 0 - && loaded.resources.some((action) => action.type === "action-item" && [grant.id, exception.id].includes(action.sourceResourceId) - && valid(action) && ["open", "in-progress", "blocked"].includes(action.status) - && currentPartyPeople(action.assigneeIds || [], byId).size > 0 - && (action.completionWindow.precision === "timestamp" ? action.completionWindow.dueAt >= assessedAt : action.completionWindow.dueOn >= asOf) - && (action.completionWindow.precision === "timestamp" ? action.completionWindow.dueAt <= timestampFromLocalDateTime(`${exception.approval.expiresOn}T23:59:59`, timezone) : action.completionWindow.dueOn <= exception.approval.expiresOn))); - const invalidDecisions = decisions.some((decision, index) => { - const grant = reviewedGrants[index]; - if (grant?.type !== "access-grant" || !componentIds.includes(grant.componentId) - || !valid(grant) || !grant.provisionedOn || grant.provisionedOn > candidate.completedOn) return true; - if (grant.status !== "active") return decision.decision !== "remove" || !grant.deprovisionedOn || grant.deprovisionedOn > asOf; - if (decision.decision === "retain") return false; - const completedChange = decision.decision === "change" && followUps(grant).find((action) => action.status === "done" - && action.completedOn >= candidate.completedOn && action.completedOn <= asOf - && action.completionResourceIds?.includes(grant.id) && proofValid(action)); - if (completedChange) { remediationIds.push(completedChange.id); return false; } - const exception = exceptionFor(grant); - if (exception && candidate.outcome === "passed-with-exceptions") { permittedExceptionIds.push(exception.id); return false; } - unresolvedDecisionIds.push(grant.id); - return false; - }); - if (unresolvedDecisionIds.length || candidate.exceptionCount < unique(permittedExceptionIds).length) issues.push("resolved-access-decisions"); - if (invalidDecisions) issues.push("supported-grant-decisions"); - if ((!candidate.sourceReconciliations && candidate.populationCount !== decisions.length) || candidate.exceptionCount === undefined) issues.push("reconciled-account-counts"); - const generatedOn = (proof) => proof?.generatedAt ? currentCalendarDate(timezone, new Date(proof.generatedAt)) : null; - const exports = evidence(candidate).filter((proof) => valid(proof) && !preparationEvidenceIssues(proof, byId, asOf).length - && proof.artifactKind === "population-export" && proof.readinessTest !== true && permittedDate(generatedOn(proof)) - && generatedOn(proof) <= candidate.completedOn && proof.collectedOn >= generatedOn(proof) && proof.collectedOn <= candidate.completedOn && proof.completenessValidation?.trim() && proof.accuracyValidation?.trim()); - const reconciliation = candidate.sourceReconciliations ? assessProviderAccessReconciliation(candidate, - { componentIds, exports, byId, asOf, valid, proofValid, grants, timezone }) : null; - if (reconciliation) issues.push(...reconciliation.issues); - 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 || !await currentProcedureContent(loaded, candidate)) issues.push("actual-provider-population-and-results"); - return { resourceId: candidate.id, complete: !issues.length, missingFacts: issues, missingScopeIds, missingGrantIds, missingSourceIds, invalidGrantIds, unresolvedDecisionIds, permittedExceptionIds, remediationIds, ...(reconciliation || {}) }; + const assessment = assessProgramAccessReview(candidate, loaded, program, selected, asOf, { permittedDate }); + if (!await currentProcedureContent(loaded, candidate)) { + assessment.missingFacts.push("actual-provider-population-and-results"); + assessment.complete = false; + } + return assessment; })); const result = assessments.find(({ complete }) => complete) || assessments.sort((a, b) => a.missingFacts.length - b.missingFacts.length)[0]; complete = componentIds.length > 0 && result?.complete === true; record = byId.get(result?.resourceId) || record; diff --git a/packages/filegrc/src/operating-preparation.js b/packages/filegrc/src/operating-preparation.js index 492a9ae..3f5e2d8 100644 --- a/packages/filegrc/src/operating-preparation.js +++ b/packages/filegrc/src/operating-preparation.js @@ -125,9 +125,9 @@ export async function assessOperatingPreparation(loaded, program, selectedContro const candidates = loaded.resources.filter((record) => task.completionResourceTypes.includes(record.type)); const assessments = candidates.map((record) => { const coveredMemberIds = populationIds.filter((memberId) => assessCompletionForObligation(record, obligation, loaded, - { asOf, notBefore: notBeforeFor(memberId), memberIds: [memberId] }).complete); + { programId: program.id, asOf, notBefore: notBeforeFor(memberId), memberIds: [memberId] }).complete); return { record, assessment: assessCompletionForObligation(record, obligation, loaded, - { asOf, memberIds: populationIds }), coveredMemberIds }; + { programId: program.id, asOf, memberIds: populationIds }), coveredMemberIds }; }); const results = assessments.filter(({ coveredMemberIds }) => coveredMemberIds.length > 0); const populationCovered = populationIds.length > 0 && populationIds.every((id) => results.some(({ coveredMemberIds }) => coveredMemberIds.includes(id))); @@ -233,7 +233,7 @@ export function firstOperatingTasks(loaded, program, options = {}) { const futureDates = records.filter((record) => record.type === "obligation-rule" || record.type === "obligation") .flatMap((record) => [record.effectiveAt?.slice(0, 10), record.recurrence?.anchorDate, record.startsOn]).filter(Boolean); const horizon = [through.toISOString().slice(0, 10), ...futureDates].sort().at(-1); - const plan = planObligations(records, { model: loaded.model, programId: program.id, asOf, + const plan = planObligations(records, { root: loaded.root, model: loaded.model, programId: program.id, asOf, through: horizon, includeComplete: true, firstOnly: true }); const byId = new Map(loaded.resources.map((record) => [record.id, record])); const first = new Map(); diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index 27f0da5..88b7af3 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -335,7 +335,7 @@ async function assessProgramReadinessUncached(input, options = {}) { program?.assuranceGoal === "soc-2-type-2" && progressWindow?.start <= asOf ); - const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, now: asOfTime, model: loaded.model }); + const obligations = planObligations(records, { root: loaded.root, programId: program.id, asOf, through: asOf, now: asOfTime, model: loaded.model }); const policyLibrary = await assessPolicyLibraryUpgrades(loaded); const operating = Boolean( evidenceReady @@ -2012,7 +2012,7 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi ]); } - const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model }); + const obligations = planObligations(records, { root: loaded.root, programId: workspace.id, asOf, through: asOf, model }); const start = progressWindow?.start || null; const end = progressWindow?.end || null; const formalWindow = progressWindow?.source === "audit"; diff --git a/packages/filegrc/src/provider-access-completion.js b/packages/filegrc/src/provider-access-completion.js new file mode 100644 index 0000000..3fcb4d8 --- /dev/null +++ b/packages/filegrc/src/provider-access-completion.js @@ -0,0 +1,111 @@ +import { assessProviderAccessReconciliation } from "./provider-access-reconciliation.js"; +import { preparationEvidenceIssues } from "./preparation-evidence.js"; +import { personWasActiveOn } from "./soc2.js"; +import { currentPartyPeople } from "./parties.js"; +import { programComponents, recordBelongsToProgram } from "./program.js"; +import { assertHistoricalRecordValid } from "./validate.js"; +import { resourceDataPath } from "./resource-markdown.js"; +import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js"; + +const unique = (values) => [...new Set(values.filter(Boolean))]; + +export function providerAccessControls(loaded, program, controls = loaded.resources.filter((record) => record.type === "control" && program.controlIds?.includes(record.id))) { + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const profile = loaded.model.operatingPreparation?.baselineProfiles?.find(({ id }) => id === "provider-access"); + if (!profile) return []; + const schedules = loaded.resources.filter((record) => record.type === "obligation" && record.status === "active" + && profile.activities.includes(record.activityType) && recordBelongsToProgram(record, program, byId, loaded.model)); + return controls.filter((control) => profile.controlCodes.includes(control.code) + || schedules.some((schedule) => schedule.controlIds?.includes(control.id) + || byId.get(schedule.activeRuleId)?.controlIds?.includes(control.id))); +} + +export function providerAccessPopulation(loaded, program, controls) { + const componentIds = unique([...programComponents(loaded, program).filter(({ status }) => status === "active").map(({ id }) => id), + ...controls.flatMap((control) => [...control.componentIds || [], ...control.evidenceSourceComponentIds || []])]); + const grants = loaded.resources.filter((grant) => grant.type === "access-grant" && componentIds.includes(grant.componentId) && grant.status === "active"); + return { componentIds, grants }; +} + +// 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 = {}) { + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const { componentIds, grants } = providerAccessPopulation(loaded, program, controls); + const valid = (record) => { + try { assertHistoricalRecordValid(record, loaded.model, resourceDataPath(loaded.model, record)); return true; } + catch { return false; } + }; + const actualPeople = (ids, date) => ids?.length > 0 && ids.every((id) => personWasActiveOn(byId.get(id), date)); + const evidence = (record) => (record.evidenceIds || []).map((id) => byId.get(id)); + const proofValid = (record) => { + const proofs = evidence(record); + return proofs.length > 0 && proofs.every((proof) => valid(proof) && !preparationEvidenceIssues(proof, byId, asOf).length) + && proofs.some((proof) => proof.readinessTest !== true); + }; + const timezone = loaded.workspace?.timezone || "UTC"; + const assessedAt = asOf === currentCalendarDate(timezone) ? new Date().toISOString() : timestampFromLocalDateTime(`${asOf}T23:59:59`, timezone); + const permittedDate = options.permittedDate || ((date) => Boolean(date && date <= asOf)); + + if (!valid(candidate)) return { resourceId: candidate.id, complete: false, missingFacts: ["valid-access-review"], missingScopeIds: [], missingGrantIds: [], missingSourceIds: [], invalidGrantIds: [], unresolvedDecisionIds: [], permittedExceptionIds: [], remediationIds: [] }; + const issues = [], missingScopeIds = componentIds.filter((id) => !candidate.componentIds?.includes(id)); + 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 + || !subject || subject.type === "person" && !personWasActiveOn(subject, asOf) + || subject.type === "service-account" && (subject.status !== "active" || currentPartyPeople(subject.ownerIds || [], byId).size === 0); + }).map(({ id }) => id); + if (invalidGrantIds.length) issues.push("current-authorized-access"); + const missingGrantIds = grants.filter((grant) => !decisions.some(({ accessGrantId }) => accessGrantId === grant.id)).map(({ id }) => id); + if (!valid(candidate) || candidate.status !== "complete" || !["passed", "passed-with-exceptions"].includes(candidate.outcome) + || !permittedDate(candidate.completedOn)) issues.push("completed-access-reconciliation"); + if (!candidate.approvedOn || candidate.approvedOn < candidate.completedOn || candidate.approvedOn > asOf + || !actualPeople(candidate.reviewerIds, candidate.completedOn) || !actualPeople(candidate.approvedByIds, candidate.approvedOn)) issues.push("existing-access-review-approval"); + if (missingScopeIds.length) issues.push("all-provider-components"); + if (missingGrantIds.length || unique(decisions.map(({ accessGrantId }) => accessGrantId)).length !== decisions.length) issues.push("per-grant-decisions"); + const reviewedGrants = decisions.map(({ accessGrantId }) => byId.get(accessGrantId)); + const unresolvedDecisionIds = []; + const permittedExceptionIds = []; + const remediationIds = []; + const followUps = (grant) => loaded.resources.filter((action) => action.type === "action-item" && action.sourceResourceId === grant.id && valid(action)); + const exceptionFor = (grant) => loaded.resources.find((exception) => exception.type === "exception" && exception.status === "approved" + && valid(exception) && exception.scopeResourceIds?.includes(grant.id) && exception.rationale?.trim() + && exception.approval.approvedOn <= candidate.approvedOn && exception.approval.expiresOn >= asOf + && actualPeople(exception.approval.approvedByIds, exception.approval.approvedOn) + && currentPartyPeople(exception.ownerIds || [], byId).size > 0 + && loaded.resources.some((action) => action.type === "action-item" && [grant.id, exception.id].includes(action.sourceResourceId) + && valid(action) && ["open", "in-progress", "blocked"].includes(action.status) + && currentPartyPeople(action.assigneeIds || [], byId).size > 0 + && (action.completionWindow.precision === "timestamp" ? action.completionWindow.dueAt >= assessedAt : action.completionWindow.dueOn >= asOf) + && (action.completionWindow.precision === "timestamp" ? action.completionWindow.dueAt <= timestampFromLocalDateTime(`${exception.approval.expiresOn}T23:59:59`, timezone) : action.completionWindow.dueOn <= exception.approval.expiresOn))); + const invalidDecisions = decisions.some((decision, index) => { + const grant = reviewedGrants[index]; + if (grant?.type !== "access-grant" || !componentIds.includes(grant.componentId) + || !valid(grant) || !grant.provisionedOn || grant.provisionedOn > candidate.completedOn) return true; + if (grant.status !== "active") return decision.decision !== "remove" || !grant.deprovisionedOn || grant.deprovisionedOn > asOf; + if (decision.decision === "retain") return false; + const completedChange = decision.decision === "change" && followUps(grant).find((action) => action.status === "done" + && action.completedOn >= candidate.completedOn && action.completedOn <= asOf + && action.completionResourceIds?.includes(grant.id) && proofValid(action)); + if (completedChange) { remediationIds.push(completedChange.id); return false; } + const exception = exceptionFor(grant); + if (exception && candidate.outcome === "passed-with-exceptions") { permittedExceptionIds.push(exception.id); return false; } + unresolvedDecisionIds.push(grant.id); + return false; + }); + if (unresolvedDecisionIds.length || candidate.exceptionCount < unique(permittedExceptionIds).length) issues.push("resolved-access-decisions"); + if (invalidDecisions) issues.push("supported-grant-decisions"); + if ((!candidate.sourceReconciliations && candidate.populationCount !== decisions.length) || candidate.exceptionCount === undefined) issues.push("reconciled-account-counts"); + const generatedOn = (proof) => proof?.generatedAt ? currentCalendarDate(timezone, new Date(proof.generatedAt)) : null; + const exports = evidence(candidate).filter((proof) => valid(proof) && !preparationEvidenceIssues(proof, byId, asOf).length + && proof.artifactKind === "population-export" && proof.readinessTest !== true && permittedDate(generatedOn(proof)) + && generatedOn(proof) <= candidate.completedOn && proof.collectedOn >= generatedOn(proof) && proof.collectedOn <= candidate.completedOn && proof.completenessValidation?.trim() && proof.accuracyValidation?.trim()); + const reconciliation = candidate.sourceReconciliations ? assessProviderAccessReconciliation(candidate, + { componentIds, exports, byId, asOf, valid, proofValid, grants, timezone }) : null; + if (reconciliation) issues.push(...reconciliation.issues); + 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 || {}) }; +} diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index e67af50..92930a9 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -257,7 +257,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { } if (request.method === "GET" && url.pathname === "/api/obligations") { const loaded = await loadWorkspace(input); - return json(response, 200, { ...planObligations(loaded.resources, { + return json(response, 200, { ...planObligations(loaded.resources, { root: loaded.root, programId: url.searchParams.get("programId") || undefined, asOf: url.searchParams.get("asOf") || undefined, from: url.searchParams.get("from") || undefined, diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index 4aba93f..c06e76f 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -172,7 +172,7 @@ export async function createAppStateSection(input, section, options = {}) { return { generatedAt, asOf, - obligations: planObligations(loaded.resources, { + obligations: planObligations(loaded.resources, { root: loaded.root, programId: activeProgram.id, asOf, now: options.now ?? generatedAt, @@ -293,7 +293,7 @@ async function createAppStateUnlocked(input, options) { return [audit?.id || "none", preparation]; }) ))); - const obligations = planObligations(entries, { + const obligations = planObligations(entries, { root: loaded.root, programId: activeProgram.id, asOf, now: options.now ?? generatedAt, diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 30d4ff2..534d67e 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,4 @@ +import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { attestationBindingIssue } from "./attestation-integrity.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { retentionApplicationIssues } from "./retention-applications.js"; @@ -689,9 +690,11 @@ function validateObligationOccurrence(record, model, resources, entries, root, w )); continue; } + const completionContext = occurrenceCompletionContext(root, record, completion, model, resources, workspaceTimezone); + const completionById = completionContext.resources === resources ? byId : indexResources(completionContext.resources).byId; const memberField = activity.aggregate?.completionMemberField; const memberValue = memberField ? completion[memberField] : null; - if (!completionMatchesMember(completion, memberField, member.resourceId, model, byId, obligation)) { + if (!completionMatchesMember(completion, memberField, member.resourceId, completionContext.model, completionById, completionById.get(obligation.id) || obligation, record.programId)) { diagnostics.push(error( "wrong-member-completion", path, @@ -709,8 +712,8 @@ function validateObligationOccurrence(record, model, resources, entries, root, w } if (completionPassesObligationActivity(completion, activity) && (!modelSupports(model, "operating-preparation") || assessCompletionForObligation(completion, obligation, - { model, resources, workspace: { timezone: workspaceTimezone } }, - { asOf: record.status === "reconciled" && record.reconciledAt + completionContext, + { programId: record.programId, asOf: ["reconciled", "superseded"].includes(record.status) && record.reconciledAt ? currentCalendarDate(workspaceTimezone, new Date(record.reconciledAt)) : asOf, window: { dueWindowStart: record.coverage.startsOn || record.coverage.on, dueWindowEnd: record.coverage.endsOn || record.coverage.on } }).complete)) validCompletions.push(completion); } if (member.result === "passed" && validCompletions.length === 0) { diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index c60bcb8..f28816c 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -130,7 +130,7 @@ async function assessWorkflowUnmeasured(input, options = {}) { const auditPreparations = Object.fromEntries( Object.entries(suppliedAuditPreparations).filter(([id]) => auditIds.has(id)) ); - const obligationPlan = options.obligations || planObligations(loaded.resources, { + const obligationPlan = options.obligations || planObligations(loaded.resources, { root: loaded.root, programId: programRecord.id, asOf, through: options.through || asOf, @@ -916,7 +916,7 @@ async function assessPeriodHealth(loaded, options) { || !(record.controlIds || []).length || record.controlIds.some((id) => selectedControlIds.has(id)) )); - const occurrences = planObligations(periodResources, { + const occurrences = planObligations(periodResources, { root: loaded.root, programId: options.programId, from: coverage.start, asOf: periodThrough, diff --git a/packages/filegrc/test/fixtures/program-access-occurrence.js b/packages/filegrc/test/fixtures/program-access-occurrence.js new file mode 100644 index 0000000..7850769 --- /dev/null +++ b/packages/filegrc/test/fixtures/program-access-occurrence.js @@ -0,0 +1,39 @@ +import { mkdir, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { makeComprehensiveWorkspace } from "../fixtures.js"; +import { writeJson } from "../helpers.js"; +import { resourceDataPath } from "../../src/resource-markdown.js"; +import { loadWorkspace } from "../../src/index.js"; + +export async function programAccessFixture(root) { + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const entry = (type) => loaded.entries.find(({ record }) => record.type === type); + const save = async (record) => { + const path = loaded.entries.find((entry) => entry.record.id === record.id)?.path || join(root, "data", resourceDataPath(loaded.model, record)); + await mkdir(dirname(path), { recursive: true }); + await writeJson(path, record); + }; + const obligation = { ...entry("obligation").record, activityType: "access-review", scopeResourceIds: [] }; + const rule = { ...entry("obligation-rule").record, recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-01-01" }, + window: { precision: "date", startsAfter: 0, dueAfter: 20 }, effectiveAt: "2026-07-05T00:00:00-05:00" }; + delete rule.selector; + await save(obligation); await save(rule); + for (const current of loaded.entries.filter(({ record }) => record.type === "obligation-occurrence")) await rm(current.path); + const component = { ...entry("component").record, id: "component-secondary", title: "Secondary identity boundary" }; + await save(component); + const proof = { ...entry("evidence").record, artifactKind: "population-export", sourceKind: "component", sourceComponentId: "component-example", + collectedOn: "2026-07-07", verifiedOn: "2026-07-07", generatedAt: "2026-07-07T10:00:00Z", timezone: "UTC", + queryDescription: "All human and service accounts in the provider", populationCount: 1, + completenessValidation: "Compared all account classes with provider totals", accuracyValidation: "Checked account identifiers and permissions", + coverage: { kind: "range", startsOn: "2026-07-07", endsOn: "2026-07-07" } }; + delete proof.filePaths; + const secondaryProof = { ...proof, id: "evidence-secondary", title: "Secondary provider population", sourceComponentId: component.id, componentIds: [component.id], populationCount: 0 }; + await save(proof); await save(secondaryProof); + const review = { ...entry("access-review").record, componentIds: ["component-example", component.id], + completedOn: "2026-07-07", approvedOn: "2026-07-07", populationCount: 1, exceptionCount: 0, + grantDecisions: [{ accessGrantId: entry("access-grant").record.id, decision: "retain" }], + evidenceIds: [proof.id, secondaryProof.id], coverage: { kind: "range", startsOn: "2026-07-05", endsOn: "2026-07-07" } }; + await save(review); + return { root, obligation, rule, review, component, proof, secondaryProof, save }; +} diff --git a/packages/filegrc/test/fixtures/retention-applications.js b/packages/filegrc/test/fixtures/retention-applications.js index 5f6c150..202b20c 100644 --- a/packages/filegrc/test/fixtures/retention-applications.js +++ b/packages/filegrc/test/fixtures/retention-applications.js @@ -41,7 +41,7 @@ export async function makeRetentionApplicationWorkspace(root, { programWide = fa const scaffold = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); await applyCollectionReview(root, { ...scaffold, decision: "complete", rationale: "Reviewed the complete schedule and all approved rule requirements.", - reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate("UTC"), confirmed: true + reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate(loaded.workspace.timezone), confirmed: true }); await commitWorkspaceFiles(root, "Approve retention schedule"); loaded = await loadWorkspace(root); diff --git a/packages/filegrc/test/nonmaterial-amendments.test.js b/packages/filegrc/test/nonmaterial-amendments.test.js index 8a1e1ae..1fc3019 100644 --- a/packages/filegrc/test/nonmaterial-amendments.test.js +++ b/packages/filegrc/test/nonmaterial-amendments.test.js @@ -16,6 +16,10 @@ async function fixture(t) { if (!base) { base = await mkdtemp(join(tmpdir(), "filegrc-amendment-base-")); const { records } = await makeComprehensiveWorkspace(base, "13"); + // Keep approval timestamps, evidence dates and owner decisions in one timezone. + const workspace = records.find(({ type }) => type === "workspace"); + workspace.timezone = "UTC"; + await writeFile(join(base, "data/workspace.json"), JSON.stringify(workspace)); await writeFile(join(base, "package.json"), JSON.stringify({ private: true, dependencies: { filegrc: "0.19.0" } })); await writeFile(join(base, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/filegrc": { version: "0.19.0" } } })); for (const record of records.filter((r) => ["policy", "document", "training"].includes(r.type) && r.status === "active")) { diff --git a/packages/filegrc/test/program-access-occurrence.test.js b/packages/filegrc/test/program-access-occurrence.test.js new file mode 100644 index 0000000..1a88ad5 --- /dev/null +++ b/packages/filegrc/test/program-access-occurrence.test.js @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { programAccessFixture } from "./fixtures/program-access-occurrence.js"; +import { executeCli, initializeGitWorkspace, writeJson } from "./helpers.js"; +import { runCli } from "../src/cli.js"; +import { scaffoldObligationOccurrence, saveObligationOccurrence, loadWorkspace, planObligations, validateWorkspace } from "../src/index.js"; +import { occurrenceCompletionContext } from "../src/obligation-completion-context.js"; +import { reviewHistoryContext } from "../src/historical-workspace.js"; +import { assessCompletionForObligation, completionMatchesMember } from "../src/obligations.js"; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "filegrc-program-access-")); + t.after(() => rm(root, { recursive: true, force: true })); + return programAccessFixture(root); +} +const scaffold = (f) => scaffoldObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); +const reconcile = (record) => ({ ...record, status: "reconciled", completedCount: 1, conclusion: "complete", reviewedByIds: ["person-example"], reconciledAt: "2026-07-07T18:00:00Z" }); + +test("native full-program review closes workspace occurrence through CLI without invented linkage", async (t) => { + const f = await fixture(t); + const before = await readFile(join(f.root, "data/access-reviews", `${f.review.id}.json`), "utf8"); + const payload = await scaffold(f); + assert.deepEqual(payload.record.members.map(({ resourceId, result }) => ({ resourceId, result })), [{ resourceId: "workspace", result: "passed" }]); + assert.equal(f.review.obligationId, undefined); + const path = join(f.root, "reconciliation.json"); + await writeJson(path, { ...payload, record: reconcile(payload.record) }); + await executeCli(runCli, process.execPath, [new URL("../bin/filegrc.js", import.meta.url).pathname, "reconcile-obligation", f.obligation.id, path, "--root", f.root, "--json"]); + assert.equal(await readFile(join(f.root, "data/access-reviews", `${f.review.id}.json`), "utf8"), before); + const loaded = await loadWorkspace(f.root); + const item = planObligations(loaded.resources, { model: loaded.model, asOf: "2026-07-07", through: "2026-07-07", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.equal(item.status, "complete"); + assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); +}); + +test("unscoped review rejects incomplete provider boundaries, grants, proof, result and dates", async (t) => { + const f = await fixture(t); + const variants = [ + { ...f.review, componentIds: ["component-example"] }, + { ...f.review, grantDecisions: [] }, + { ...f.review, evidenceIds: [f.proof.id] }, + { ...f.review, completedOn: "2026-07-04", approvedOn: "2026-07-04", coverage: { kind: "as-of", on: "2026-07-04" } }, + { ...f.review, status: "planned" }, + { ...f.review, outcome: "failed" } + ]; + for (const review of variants) { + await f.save(review); + const loaded = await loadWorkspace(f.root); + assert.equal(assessCompletionForObligation(review, f.obligation, loaded, { memberIds: ["workspace"], window: { dueWindowStart: "2026-07-05", dueWindowEnd: "2026-07-21" }, asOf: "2026-07-07" }).complete, false); + const payload = await scaffold(f); + assert.notEqual(payload.record.members[0].result, "passed"); + const injected = reconcile(payload.record); + injected.members[0] = { ...injected.members[0], result: "passed", completionResourceIds: [review.id] }; + await assert.rejects(saveObligationOccurrence(f.root, { record: injected }), /completion|population|grant|status|outcome/i); + } +}); + +test("explicit scope retains per-member proof and adjacent native types can match unscoped sentinel", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root), byId = new Map(loaded.resources.map((r) => [r.id, r])); + const obligation = { ...f.obligation, scopeResourceIds: ["component-example", f.component.id] }; + assert.equal(completionMatchesMember({ ...f.review, componentIds: ["component-example"] }, "componentIds", f.component.id, loaded.model, byId, obligation), false); + assert.equal(completionMatchesMember(f.review, "componentIds", "workspace", loaded.model, byId, obligation), false); + for (const type of ["meeting", "risk-assessment", "exercise", "backup-test", "vulnerability-scan"]) { + const record = loaded.resources.find((r) => r.type === type); + assert.equal(completionMatchesMember(record, loaded.model.obligationActivities[type]?.aggregate?.completionMemberField, "workspace", loaded.model, byId, { ...f.obligation, id: "obligation-native" }), true, type); + } +}); + + +test("committed reconciliations retain their dated population after provider and grant additions", async (t) => { + const f = await fixture(t); + const payload = await scaffold(f); + await saveObligationOccurrence(f.root, { record: reconcile(payload.record) }); + await initializeGitWorkspace(f.root); + await f.save({ ...f.component, id: "component-added-later", title: "Later provider" }); + const loaded = await loadWorkspace(f.root); + const grant = loaded.resources.find(({ type }) => type === "access-grant"); + await f.save({ ...grant, id: "access-grant-added-later", provisionedOn: "2026-08-01", approvedOn: "2026-08-01" }); + const current = await loadWorkspace(f.root); + const item = planObligations(current.resources, { root: f.root, model: current.model, asOf: "2026-08-10", from: "2026-07-05", through: "2026-07-21", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.equal(item.status, "complete"); + assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); + const occurrence = current.resources.find(({ type }) => type === "obligation-occurrence"); + await f.save({ ...occurrence, reconciledAt: "2026-07-08T18:00:00Z" }); + assert.ok((await validateWorkspace(f.root)).diagnostics.some(({ code }) => code === "missing-passing-member-completion")); +}); + + +test("rule-only selected Control links retain all provider boundaries", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const control = loaded.resources.find(({ type }) => type === "control"); + await f.save({ ...control, code: "CUSTOM", componentIds: ["component-control-only"] }); + await f.save({ ...f.component, id: "component-control-only", systemUses: [] }); + await f.save({ ...f.rule, controlIds: [control.id] }); + await f.save({ ...f.obligation, controlIds: [] }); + const current = await loadWorkspace(f.root); + const obligation = current.resources.find(({ type }) => type === "obligation"); + assert.equal(assessCompletionForObligation(f.review, obligation, current, { asOf: "2026-07-07", memberIds: ["workspace"] }).complete, false); +}); + +test("superseded committed review keeps its original reconciliation date after grant expiry", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const grant = loaded.resources.find(({ type }) => type === "access-grant"); + await f.save({ ...grant, expiresOn: "2026-07-31" }); + const payload = await scaffold(f); + const occurrence = reconcile(payload.record); + await saveObligationOccurrence(f.root, { record: occurrence }); + await initializeGitWorkspace(f.root); + await f.save({ ...occurrence, status: "superseded" }); + assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); +}); + + +test("other active provider schedules contribute selected Control boundaries to program-wide scope", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const control = loaded.resources.find(({ type }) => type === "control"); + await f.save({ ...control, code: "CUSTOM", componentIds: ["component-other-schedule"] }); + await f.save({ ...f.component, id: "component-other-schedule", systemUses: [] }); + await f.save({ ...f.obligation, controlIds: [] }); + await f.save({ ...f.rule, controlIds: [] }); + await f.save({ ...f.obligation, id: "obligation-other", controlIds: [control.id], activeRuleId: "obligation-rule-other" }); + await f.save({ ...f.rule, id: "obligation-rule-other", obligationId: "obligation-other", controlIds: [control.id] }); + const current = await loadWorkspace(f.root); + const obligation = current.resources.find(({ id }) => id === f.obligation.id); + assert.equal(assessCompletionForObligation(f.review, obligation, current, { asOf: "2026-07-07", memberIds: ["workspace"] }).complete, false); +}); + + +test("uncommitted or altered reconciliations cannot bypass live provider scope in the planner", async (t) => { + const f = await fixture(t); + const payload = await scaffold(f); + await f.save(reconcile(payload.record)); + await f.save({ ...f.review, componentIds: ["component-example"] }); + const current = await loadWorkspace(f.root); + for (const root of [undefined, f.root]) { + const item = planObligations(current.resources, { root, model: current.model, asOf: "2026-07-07", through: "2026-07-07", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.notEqual(item.status, "complete"); + } +}); + +test("native unscoped work from another System cannot satisfy the selected Program", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const assessment = loaded.resources.find(({ type }) => type === "risk-assessment"); + const obligation = { ...f.obligation, activityType: "risk-assessment" }; + const record = { ...assessment, systemIds: ["system-unrelated"], componentIds: [], completedOn: "2026-07-07", approvedOn: "2026-07-07" }; + const unrelated = { ...loaded.resources.find(({ type }) => type === "system"), id: "system-unrelated" }; + const context = { ...loaded, resources: [...loaded.resources, unrelated] }; + assert.equal(assessCompletionForObligation(record, obligation, context, { asOf: "2026-07-07", memberIds: ["workspace"] }).complete, false); +}); + + +test("workspace fallback retains explicit native linkage without a Program", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const byId = new Map([["workspace", loaded.workspace]]); + const obligation = { id: "obligation-legacy" }; + assert.equal(completionMatchesMember({ type: "meeting", obligationId: obligation.id }, null, "workspace", loaded.model, byId, obligation), true); + assert.equal(completionMatchesMember({ type: "meeting", obligationId: "obligation-other" }, null, "workspace", loaded.model, byId, obligation), false); +}); + +test("finalized provider context falls back to Git traversal when its history index is unavailable", async (t) => { + const f = await fixture(t); + const payload = await scaffold(f), occurrence = reconcile(payload.record); + await saveObligationOccurrence(f.root, { record: occurrence }); + await initializeGitWorkspace(f.root); + await f.save({ ...f.component, id: "component-later" }); + const loaded = await loadWorkspace(f.root); + const history = reviewHistoryContext(f.root, loaded.resources); + history.index = null; + const context = occurrenceCompletionContext(f.root, occurrence, f.review, loaded.model, loaded.resources, loaded.workspace.timezone); + assert.ok(context.historicalCommit); + assert.equal(context.resources.some(({ id }) => id === "component-later"), false); +}); + + +test("historical membership survives later reassignment of provider Components", async (t) => { + const f = await fixture(t), payload = await scaffold(f); + await saveObligationOccurrence(f.root, { record: reconcile(payload.record) }); + await initializeGitWorkspace(f.root); + const loaded = await loadWorkspace(f.root); + const system = loaded.resources.find(({ type }) => type === "system"); + await f.save({ ...system, id: "system-reassigned" }); + for (const component of loaded.resources.filter(({ type }) => type === "component")) { + await f.save({ ...component, systemUses: component.systemUses.map((use) => ({ ...use, systemId: "system-reassigned" })) }); + } + const current = await loadWorkspace(f.root); + const item = planObligations(current.resources, { root: f.root, model: current.model, asOf: "2026-07-07", through: "2026-07-07", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.equal(item.status, "complete"); + assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); +}); + + +test("CLI rejects unsupported reconciliation preview before changing any source record", async (t) => { + const f = await fixture(t), payload = await scaffold(f); + const path = join(f.root, "reconciliation.json"); + await writeJson(path, { ...payload, record: reconcile(payload.record) }); + const before = (await loadWorkspace(f.root)).entries.map(({ relativePath, source }) => ({ relativePath, source })); + await assert.rejects(executeCli(runCli, process.execPath, [new URL("../bin/filegrc.js", import.meta.url).pathname, + "reconcile-obligation", f.obligation.id, path, "--preview", "--root", f.root, "--json"]), /does not support --preview/); + const after = (await loadWorkspace(f.root)).entries.map(({ relativePath, source }) => ({ relativePath, source })); + assert.deepEqual(after, before); +}); + + +test("full-provider completion uses the requested Program when schedules are shared", async (t) => { + const f = await fixture(t), loaded = await loadWorkspace(f.root); + const program = loaded.resources.find(({ type }) => type === "program"); + const system = loaded.resources.find(({ type }) => type === "system"); + await f.save({ ...system, id: "system-other-program" }); + await f.save({ ...f.component, id: "component-other-program", systemUses: f.component.systemUses.map((use) => ({ ...use, systemId: "system-other-program" })) }); + await f.save({ ...program, id: "program-other", systemIds: ["system-other-program"] }); + const selected = await scaffoldObligationOccurrence(f.root, { programId: program.id, obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); + assert.equal(selected.record.members[0].result, "passed"); + const other = await scaffoldObligationOccurrence(f.root, { programId: "program-other", obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" }); + assert.notEqual(other.record.members[0].result, "passed"); +});