filegrc 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/v11.json +13 -20
- package/package.json +1 -1
- package/src/audit-preparation.js +121 -14
- package/src/audit-selection.js +33 -0
- package/src/cli.js +13 -3
- package/src/collection-review.js +124 -31
- package/src/collection-revision.js +6 -1
- package/src/collection-scope.js +121 -5
- package/src/files.js +13 -1
- package/src/git.js +86 -0
- package/src/index.js +3 -2
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +287 -34
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +81 -23
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +33 -3
- package/src/web.js +264 -134
- package/src/workflow.js +5 -1
package/src/program-readiness.js
CHANGED
|
@@ -32,6 +32,100 @@ import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
|
32
32
|
import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
|
|
33
33
|
import { loadWorkspace } from "./workspace.js";
|
|
34
34
|
|
|
35
|
+
export function calculateProgramProgress({
|
|
36
|
+
stages = [],
|
|
37
|
+
target = {},
|
|
38
|
+
evidenceReady = false,
|
|
39
|
+
operating = false,
|
|
40
|
+
asOf,
|
|
41
|
+
window = null
|
|
42
|
+
} = {}) {
|
|
43
|
+
const typeTwo = target.goal === "soc-2-type-2";
|
|
44
|
+
const trackedWindow = normalizeProgressWindow(window || target.candidateCoverage, window ? "audit" : "candidate");
|
|
45
|
+
const windowStarted = Boolean(
|
|
46
|
+
typeTwo
|
|
47
|
+
&& trackedWindow
|
|
48
|
+
&& asOf
|
|
49
|
+
&& trackedWindow.start <= asOf
|
|
50
|
+
);
|
|
51
|
+
const setupProgress = calculateSetupActionProgress(stages, target, Boolean(trackedWindow));
|
|
52
|
+
if (windowStarted) {
|
|
53
|
+
const totalDays = calendarDayDistance(trackedWindow.start, trackedWindow.end) + 1;
|
|
54
|
+
const elapsedDays = Math.min(
|
|
55
|
+
totalDays,
|
|
56
|
+
Math.max(0, calendarDayDistance(trackedWindow.start, asOf) + 1)
|
|
57
|
+
);
|
|
58
|
+
const percent = totalDays ? Math.round((elapsedDays / totalDays) * 100) : 0;
|
|
59
|
+
const complete = elapsedDays >= totalDays;
|
|
60
|
+
const healthy = Boolean(operating);
|
|
61
|
+
return {
|
|
62
|
+
mode: "operating-window",
|
|
63
|
+
label: trackedWindow.source === "audit" ? "Audit window" : "Operating window",
|
|
64
|
+
status: healthy ? complete ? "Window complete" : "Operating" : "Needs attention",
|
|
65
|
+
complete: elapsedDays,
|
|
66
|
+
total: totalDays,
|
|
67
|
+
remaining: Math.max(0, totalDays - elapsedDays),
|
|
68
|
+
percent,
|
|
69
|
+
unit: "day",
|
|
70
|
+
start: trackedWindow.start,
|
|
71
|
+
end: trackedWindow.end,
|
|
72
|
+
source: trackedWindow.source,
|
|
73
|
+
detail: complete
|
|
74
|
+
? `${totalDays} of ${totalDays} days elapsed.`
|
|
75
|
+
: `Day ${elapsedDays} of ${totalDays}; ${Math.max(0, totalDays - elapsedDays)} days remain.`,
|
|
76
|
+
operating
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
mode: "setup-progress",
|
|
82
|
+
label: "Program setup",
|
|
83
|
+
status: !setupProgress.remaining
|
|
84
|
+
? evidenceReady && typeTwo && trackedWindow?.start > asOf
|
|
85
|
+
? "Scheduled"
|
|
86
|
+
: evidenceReady ? "Evidence ready" : "Ready"
|
|
87
|
+
: evidenceReady && typeTwo ? "Ready to start" : "Needs work",
|
|
88
|
+
...setupProgress,
|
|
89
|
+
unit: "action",
|
|
90
|
+
detail: setupProgress.remaining
|
|
91
|
+
? `${setupProgress.complete} of ${setupProgress.total} required actions complete.`
|
|
92
|
+
: `All ${setupProgress.total} required actions are complete.`,
|
|
93
|
+
operating
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
|
|
98
|
+
const units = new Map();
|
|
99
|
+
for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
|
|
100
|
+
for (const current of currentStage.items || []) {
|
|
101
|
+
if (current.progressUnit === false) continue;
|
|
102
|
+
const candidates = current.progressUnits?.length
|
|
103
|
+
? current.progressUnits
|
|
104
|
+
: [current];
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (["info", "later"].includes(candidate.status) || candidate.progressUnit === false) continue;
|
|
107
|
+
const key = `${currentStage.id}:${candidate.progressUnitId || current.progressUnitId || candidate.id || current.id}`;
|
|
108
|
+
const existing = units.get(key);
|
|
109
|
+
if (!existing || existing.status === "complete" && candidate.status !== "complete") {
|
|
110
|
+
units.set(key, candidate);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const setupUnits = [...units.values()];
|
|
117
|
+
const complete = setupUnits.filter(({ status }) => status === "complete").length;
|
|
118
|
+
const total = setupUnits.length + (target.goal === "soc-2-type-2" ? 1 : 0);
|
|
119
|
+
const completed = complete + (windowMilestoneComplete ? 1 : 0);
|
|
120
|
+
const remaining = Math.max(0, total - completed);
|
|
121
|
+
return {
|
|
122
|
+
complete: completed,
|
|
123
|
+
total,
|
|
124
|
+
remaining,
|
|
125
|
+
percent: total ? Math.round((completed / total) * 100) : 0
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
35
129
|
export async function assessProgramReadiness(input, options = {}) {
|
|
36
130
|
const loaded = input?.resources && input?.model && input?.entries
|
|
37
131
|
? input
|
|
@@ -41,6 +135,15 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
41
135
|
const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
|
|
42
136
|
const program = resolveProgram(loaded, options.programId);
|
|
43
137
|
const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
|
|
138
|
+
const requestedAudit = options.auditId
|
|
139
|
+
? records.find((record) => record.type === "audit" && record.id === options.auditId)
|
|
140
|
+
: null;
|
|
141
|
+
const auditWindow = requestedAudit?.auditKind === "soc-2-type-2"
|
|
142
|
+
? requestedAudit.coverage
|
|
143
|
+
: selectedAuditWindow(records, program, asOf);
|
|
144
|
+
const progressWindow = requestedAudit?.auditKind === "soc-2-type-2"
|
|
145
|
+
? normalizeProgressWindow(auditWindow, "audit")
|
|
146
|
+
: selectedProgressWindow(program?.candidateCoverage, auditWindow, asOf);
|
|
44
147
|
const scope = programScope(program, records, byId, loaded.model, loaded);
|
|
45
148
|
const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
|
|
46
149
|
const markdown = new Map();
|
|
@@ -57,13 +160,24 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
57
160
|
.map(collectionReviewReadinessItem));
|
|
58
161
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
59
162
|
controlStage.items.push(...sourceStage.items);
|
|
163
|
+
const retentionScheduleReview = collectionReviews.find(({ resourceType }) => resourceType === "retention-schedule-item");
|
|
60
164
|
const retentionItems = await assessRetentionReadiness(loaded, program, {
|
|
61
|
-
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
|
|
165
|
+
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true,
|
|
166
|
+
scheduleApproved: retentionScheduleReview?.complete === true
|
|
62
167
|
});
|
|
63
|
-
const retentionReviewItems =
|
|
64
|
-
|
|
65
|
-
|
|
168
|
+
const retentionReviewItems = retentionScheduleReview
|
|
169
|
+
? [collectionReviewReadinessItem(retentionScheduleReview)]
|
|
170
|
+
: [];
|
|
66
171
|
if (modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
172
|
+
const scheduleDocumentIds = new Set(records.filter((record) => (
|
|
173
|
+
record.type === "document"
|
|
174
|
+
&& record.documentKind === "schedule"
|
|
175
|
+
&& record.workflowScope === "program"
|
|
176
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
177
|
+
)).map(({ id }) => id));
|
|
178
|
+
policyStage.items = policyStage.items.filter(({ id }) => (
|
|
179
|
+
![...scheduleDocumentIds].some((documentId) => id === `document-approval-${documentId}`)
|
|
180
|
+
));
|
|
67
181
|
policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
|
|
68
182
|
controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
|
|
69
183
|
policyStage.items.push(...retentionReviewItems);
|
|
@@ -123,26 +237,54 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
123
237
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
124
238
|
const stages = [
|
|
125
239
|
...evidenceGateStages,
|
|
126
|
-
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
240
|
+
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
|
|
127
241
|
];
|
|
128
242
|
finalizeStage(stages.at(-1));
|
|
129
|
-
const
|
|
243
|
+
const periodStarted = Boolean(
|
|
130
244
|
program?.assuranceGoal === "soc-2-type-2"
|
|
131
|
-
&&
|
|
132
|
-
&& coverageStart(program.candidateCoverage) <= asOf
|
|
245
|
+
&& progressWindow?.start <= asOf
|
|
133
246
|
);
|
|
134
247
|
const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
|
|
135
248
|
const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
|
|
136
|
-
const operating =
|
|
249
|
+
const operating = Boolean(
|
|
250
|
+
evidenceReady
|
|
251
|
+
&& periodStarted
|
|
252
|
+
&& stages.at(-1).counts.action === 0
|
|
253
|
+
);
|
|
137
254
|
const canStartCandidatePeriod = Boolean(
|
|
138
255
|
evidenceReady
|
|
139
256
|
&& program?.assuranceGoal === "soc-2-type-2"
|
|
140
257
|
&& !program.candidateCoverage
|
|
258
|
+
&& !periodStarted
|
|
141
259
|
);
|
|
142
260
|
const items = stages.flatMap((current) => current.items);
|
|
143
261
|
const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
|
|
144
262
|
const complete = managedItems.filter((current) => current.status === "complete").length;
|
|
145
263
|
const firstAction = items.find((current) => current.status === "action") || null;
|
|
264
|
+
const target = {
|
|
265
|
+
programId: program?.id || null,
|
|
266
|
+
goal: program?.assuranceGoal || "none",
|
|
267
|
+
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
268
|
+
candidateCoverage: program?.candidateCoverage || null
|
|
269
|
+
};
|
|
270
|
+
const checkProgress = {
|
|
271
|
+
complete,
|
|
272
|
+
total: managedItems.length,
|
|
273
|
+
percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
|
|
274
|
+
};
|
|
275
|
+
const progress = calculateProgramProgress({
|
|
276
|
+
stages,
|
|
277
|
+
target,
|
|
278
|
+
evidenceReady,
|
|
279
|
+
operating,
|
|
280
|
+
asOf,
|
|
281
|
+
window: progressWindow
|
|
282
|
+
});
|
|
283
|
+
const setupProgress = calculateSetupActionProgress(
|
|
284
|
+
stages,
|
|
285
|
+
target,
|
|
286
|
+
Boolean(progressWindow)
|
|
287
|
+
);
|
|
146
288
|
|
|
147
289
|
return {
|
|
148
290
|
program,
|
|
@@ -150,12 +292,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
150
292
|
dataModelVersion: String(loaded.model.modelVersion),
|
|
151
293
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
152
294
|
asOf,
|
|
153
|
-
target
|
|
154
|
-
programId: program?.id || null,
|
|
155
|
-
goal: program?.assuranceGoal || "none",
|
|
156
|
-
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
157
|
-
candidateCoverage: program?.candidateCoverage || null
|
|
158
|
-
},
|
|
295
|
+
target,
|
|
159
296
|
status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
|
|
160
297
|
evidenceReady,
|
|
161
298
|
operating,
|
|
@@ -168,11 +305,9 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
168
305
|
...policyLibrary.proposals,
|
|
169
306
|
...legacyPolicyLibraryProposals(records)
|
|
170
307
|
],
|
|
171
|
-
progress
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
|
|
175
|
-
},
|
|
308
|
+
progress,
|
|
309
|
+
setupProgress,
|
|
310
|
+
checkProgress,
|
|
176
311
|
counts: countStatuses(items),
|
|
177
312
|
firstAction,
|
|
178
313
|
scope: {
|
|
@@ -277,7 +412,10 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
277
412
|
? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
|
|
278
413
|
: "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
|
|
279
414
|
scope.components[0] || { type: "component" },
|
|
280
|
-
{
|
|
415
|
+
{
|
|
416
|
+
componentIds: scope.components.map(({ id }) => id),
|
|
417
|
+
progressUnit: false
|
|
418
|
+
}
|
|
281
419
|
));
|
|
282
420
|
}
|
|
283
421
|
|
|
@@ -444,7 +582,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
444
582
|
return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
445
583
|
}
|
|
446
584
|
|
|
447
|
-
function reportingRouteSetItem(assessment) {
|
|
585
|
+
export function reportingRouteSetItem(assessment) {
|
|
448
586
|
const current = assessment.routeSets.find(({ effective, canceled }) => effective && !canceled);
|
|
449
587
|
const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
|
|
450
588
|
const required = assessment.requirements.length > 0;
|
|
@@ -453,13 +591,18 @@ function reportingRouteSetItem(assessment) {
|
|
|
453
591
|
.map(({ purposeKey }) => purposeKey)
|
|
454
592
|
.filter(Boolean))];
|
|
455
593
|
const preparedPurposeKeys = new Set(assessment.routeSets
|
|
456
|
-
.filter(({ record, committed, canceled, proposedRequirementIssues }) => (
|
|
457
|
-
["proposed", "approved"].includes(record.status)
|
|
594
|
+
.filter(({ record, committed, effective, canceled, proposedRequirementIssues }) => (
|
|
595
|
+
(["proposed", "approved"].includes(record.status) || effective)
|
|
458
596
|
&& committed
|
|
459
597
|
&& !canceled
|
|
460
598
|
&& proposedRequirementIssues.length === 0
|
|
461
599
|
))
|
|
462
600
|
.map(({ record }) => record.purposeKey));
|
|
601
|
+
const requiredPurposeKeys = new Set(assessment.requirements.map(({ purposeKey }) => purposeKey).filter(Boolean));
|
|
602
|
+
const purposeKeys = [...new Set([
|
|
603
|
+
...requiredPurposeKeys,
|
|
604
|
+
...proposedPurposeKeys
|
|
605
|
+
])];
|
|
463
606
|
const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
|
|
464
607
|
const ready = assessment.issues.length === 0
|
|
465
608
|
&& (!required || Boolean(current))
|
|
@@ -494,6 +637,42 @@ function reportingRouteSetItem(assessment) {
|
|
|
494
637
|
proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
|
|
495
638
|
unpreparedPurposeKeys,
|
|
496
639
|
issues: assessment.issues,
|
|
640
|
+
progressUnits: purposeKeys.flatMap((purposeKey) => {
|
|
641
|
+
const purposeResourceIds = new Set([
|
|
642
|
+
...assessment.routeSets.filter(({ record }) => record.purposeKey === purposeKey).map(({ record }) => record.id),
|
|
643
|
+
...assessment.requirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId),
|
|
644
|
+
...assessment.proposedRequirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId)
|
|
645
|
+
].filter(Boolean));
|
|
646
|
+
const purposeIssues = assessment.issues.filter(({ resourceId }) => (
|
|
647
|
+
resourceId === assessment.programId || purposeResourceIds.has(resourceId)
|
|
648
|
+
));
|
|
649
|
+
const approvedSet = assessment.routeSets.find(({ record, effective, canceled }) => (
|
|
650
|
+
record.purposeKey === purposeKey && effective && !canceled
|
|
651
|
+
));
|
|
652
|
+
const proposedSet = assessment.routeSets.find(({ record, effective, canceled }) => (
|
|
653
|
+
record.purposeKey === purposeKey
|
|
654
|
+
&& !canceled
|
|
655
|
+
&& (effective || ["proposed", "approved"].includes(record.status))
|
|
656
|
+
));
|
|
657
|
+
const proposalComplete = Boolean(
|
|
658
|
+
(approvedSet || proposedSet?.committed)
|
|
659
|
+
&& proposedSet?.proposedRequirementIssues.length === 0
|
|
660
|
+
);
|
|
661
|
+
return [
|
|
662
|
+
{
|
|
663
|
+
id: `reporting-route-proposal-${purposeKey}`,
|
|
664
|
+
status: proposalComplete ? "complete" : "action",
|
|
665
|
+
title: `${proposedSet?.record.title || purposeKey} proposal`
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
id: `reporting-route-approval-${purposeKey}`,
|
|
669
|
+
status: approvedSet && purposeIssues.length === 0
|
|
670
|
+
? "complete"
|
|
671
|
+
: requiredPurposeKeys.has(purposeKey) ? "action" : "later",
|
|
672
|
+
title: `${proposedSet?.record.title || purposeKey} approval`
|
|
673
|
+
}
|
|
674
|
+
];
|
|
675
|
+
}),
|
|
497
676
|
commands: [
|
|
498
677
|
"npx filegrc reporting-route-sets --json",
|
|
499
678
|
"npx filegrc reporting-route-set --scaffold"
|
|
@@ -529,6 +708,11 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
|
|
|
529
708
|
}
|
|
530
709
|
|
|
531
710
|
function collectionReviewReadinessItem(assessment) {
|
|
711
|
+
const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
|
|
712
|
+
id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
|
|
713
|
+
status: proposal.complete ? "complete" : "action",
|
|
714
|
+
title: `${proposal.title} proposal`
|
|
715
|
+
}));
|
|
532
716
|
return item(
|
|
533
717
|
`collection-review-${assessment.resourceType}`,
|
|
534
718
|
assessment.complete ? "complete" : "action",
|
|
@@ -540,6 +724,18 @@ function collectionReviewReadinessItem(assessment) {
|
|
|
540
724
|
{
|
|
541
725
|
resourceType: assessment.resourceType,
|
|
542
726
|
reviewPoints: assessment.configuration.reviewPoints,
|
|
727
|
+
...(proposalUnits.length ? {
|
|
728
|
+
progressUnits: [
|
|
729
|
+
...proposalUnits,
|
|
730
|
+
{
|
|
731
|
+
id: `${assessment.resourceType}-collection-review`,
|
|
732
|
+
status: proposalUnits.every(({ status }) => status === "complete")
|
|
733
|
+
? assessment.complete ? "complete" : "action"
|
|
734
|
+
: "blocked",
|
|
735
|
+
title: `${assessment.configuration.title} collection review`
|
|
736
|
+
}
|
|
737
|
+
]
|
|
738
|
+
} : {}),
|
|
543
739
|
commands: [
|
|
544
740
|
`npx filegrc review-collection ${assessment.resourceType} --scaffold`,
|
|
545
741
|
`npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
|
|
@@ -724,6 +920,7 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
|
|
|
724
920
|
: "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
|
|
725
921
|
appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
|
|
726
922
|
{
|
|
923
|
+
progressUnit: false,
|
|
727
924
|
commands: [
|
|
728
925
|
"npx filegrc list appointment --workflow --json",
|
|
729
926
|
"npx filegrc guide appointment --json",
|
|
@@ -1468,7 +1665,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1468
1665
|
return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
|
|
1469
1666
|
}
|
|
1470
1667
|
|
|
1471
|
-
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
1668
|
+
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model, progressWindow = null) {
|
|
1472
1669
|
const goal = workspace?.assuranceGoal || "none";
|
|
1473
1670
|
if (!evidenceReady) {
|
|
1474
1671
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
|
|
@@ -1500,12 +1697,9 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
|
|
|
1500
1697
|
}
|
|
1501
1698
|
|
|
1502
1699
|
const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model });
|
|
1503
|
-
const start =
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
const end = workspace.candidateCoverage?.kind === "range"
|
|
1507
|
-
? coverageEnd(workspace.candidateCoverage)
|
|
1508
|
-
: null;
|
|
1700
|
+
const start = progressWindow?.start || null;
|
|
1701
|
+
const end = progressWindow?.end || null;
|
|
1702
|
+
const formalWindow = progressWindow?.source === "audit";
|
|
1509
1703
|
const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
|
|
1510
1704
|
const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
|
|
1511
1705
|
const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
|
|
@@ -1517,16 +1711,20 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
|
|
|
1517
1711
|
!start
|
|
1518
1712
|
? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
|
|
1519
1713
|
: start <= asOf
|
|
1520
|
-
?
|
|
1714
|
+
? formalWindow
|
|
1715
|
+
? `The auditor-agreed Type 2 period began on ${start}. Keep evidence collection and operating work current through the period.`
|
|
1716
|
+
: `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
|
|
1521
1717
|
: `Evidence collection is scheduled to begin on ${start}.`,
|
|
1522
1718
|
workspace
|
|
1523
1719
|
),
|
|
1524
1720
|
item(
|
|
1525
1721
|
"candidate-period-end",
|
|
1526
1722
|
end ? "complete" : start ? "later" : "info",
|
|
1527
|
-
"Plan the candidate period end",
|
|
1723
|
+
formalWindow ? "Confirm the auditor-agreed period end" : "Plan the candidate period end",
|
|
1528
1724
|
end
|
|
1529
|
-
?
|
|
1725
|
+
? formalWindow
|
|
1726
|
+
? `Auditor-agreed period: ${start || "start not set"} through ${end}.`
|
|
1727
|
+
: `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
|
|
1530
1728
|
: "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
|
|
1531
1729
|
workspace
|
|
1532
1730
|
),
|
|
@@ -1784,6 +1982,61 @@ function countStatuses(items) {
|
|
|
1784
1982
|
return counts;
|
|
1785
1983
|
}
|
|
1786
1984
|
|
|
1985
|
+
export function selectedAuditWindow(records, program, asOf) {
|
|
1986
|
+
if (program?.assuranceGoal !== "soc-2-type-2") return null;
|
|
1987
|
+
const candidates = records.filter((record) => (
|
|
1988
|
+
record.type === "audit"
|
|
1989
|
+
&& record.auditKind === "soc-2-type-2"
|
|
1990
|
+
&& (!record.programId || record.programId === program.id)
|
|
1991
|
+
&& !["canceled", "closed", "complete", "delivered"].includes(record.status)
|
|
1992
|
+
&& normalizeProgressWindow(record.coverage)
|
|
1993
|
+
&& coverageStart(record.coverage) <= asOf
|
|
1994
|
+
));
|
|
1995
|
+
const statusOrder = new Map([
|
|
1996
|
+
["planned", 0],
|
|
1997
|
+
["in-progress", 1],
|
|
1998
|
+
["fieldwork", 2],
|
|
1999
|
+
["report-draft", 3],
|
|
2000
|
+
["issued", 4]
|
|
2001
|
+
]);
|
|
2002
|
+
candidates.sort((left, right) => (
|
|
2003
|
+
coverageStart(right.coverage).localeCompare(coverageStart(left.coverage))
|
|
2004
|
+
|| (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
|
|
2005
|
+
|| coverageEnd(right.coverage).localeCompare(coverageEnd(left.coverage))
|
|
2006
|
+
|| left.id.localeCompare(right.id)
|
|
2007
|
+
));
|
|
2008
|
+
return candidates[0]?.coverage || null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
export function selectedProgressWindow(candidateCoverage, auditCoverage, asOf) {
|
|
2012
|
+
const candidates = [
|
|
2013
|
+
normalizeProgressWindow(candidateCoverage, "candidate"),
|
|
2014
|
+
normalizeProgressWindow(auditCoverage, "audit")
|
|
2015
|
+
].filter((window) => window && (!asOf || window.start <= asOf));
|
|
2016
|
+
candidates.sort((left, right) => (
|
|
2017
|
+
right.start.localeCompare(left.start)
|
|
2018
|
+
|| Number(right.source === "audit") - Number(left.source === "audit")
|
|
2019
|
+
|| right.end.localeCompare(left.end)
|
|
2020
|
+
));
|
|
2021
|
+
if (candidates.length) return candidates[0];
|
|
2022
|
+
return normalizeProgressWindow(candidateCoverage, "candidate")
|
|
2023
|
+
|| normalizeProgressWindow(auditCoverage, "audit");
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function normalizeProgressWindow(coverage, source = "candidate") {
|
|
2027
|
+
const start = coverage?.start || coverageStart(coverage);
|
|
2028
|
+
const end = coverage?.end || coverageEnd(coverage);
|
|
2029
|
+
if (!start || !end || end < start) return null;
|
|
2030
|
+
return { start, end, source: coverage?.source || source };
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function calendarDayDistance(start, end) {
|
|
2034
|
+
const startTime = Date.parse(`${start}T00:00:00Z`);
|
|
2035
|
+
const endTime = Date.parse(`${end}T00:00:00Z`);
|
|
2036
|
+
if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0;
|
|
2037
|
+
return Math.round((endTime - startTime) / 86_400_000);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
1787
2040
|
function shiftYear(value, offset) {
|
|
1788
2041
|
const date = new Date(`${value}T00:00:00Z`);
|
|
1789
2042
|
date.setUTCFullYear(date.getUTCFullYear() + offset);
|
|
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
4
4
|
import { markdownEntries } from "./resource-markdown.js";
|
|
5
5
|
import { resolveDataPath } from "./paths.js";
|
|
6
|
-
import { currentPartyPeople
|
|
6
|
+
import { currentPartyPeople } from "./parties.js";
|
|
7
7
|
import {
|
|
8
8
|
nearDuplicateInformationTypes,
|
|
9
9
|
resourceReviewRevisionsSync,
|
|
@@ -26,7 +26,7 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
26
26
|
&& !["superseded", "retired"].includes(record.status)
|
|
27
27
|
));
|
|
28
28
|
if (!schedules.length) {
|
|
29
|
-
issues.push(issue("missing-retention-schedule-document", "Add
|
|
29
|
+
issues.push(issue("missing-retention-schedule-document", "Add the Data Retention Schedule document before requesting approval of the complete schedule."));
|
|
30
30
|
}
|
|
31
31
|
if (schedules.length !== 1) {
|
|
32
32
|
if (schedules.length) {
|
|
@@ -38,17 +38,22 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
38
38
|
const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
|
|
39
39
|
if (
|
|
40
40
|
schedule
|
|
41
|
-
&& (
|
|
42
|
-
|| !schedule.approvedOn
|
|
43
|
-
|| !schedule.approvedContentRevisions
|
|
44
|
-
|| currentPartyPeople(schedule.ownerIds || [], byId).size === 0
|
|
45
|
-
|| !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
|
|
41
|
+
&& (currentPartyPeople(schedule.ownerIds || [], byId).size === 0
|
|
46
42
|
|| !linkedControl
|
|
47
43
|
|| !substantiveMarkdown(scheduleContent)
|
|
48
|
-
|| openPlaceholderCount(scheduleContent) > 0
|
|
44
|
+
|| openPlaceholderCount(scheduleContent) > 0)
|
|
45
|
+
) {
|
|
46
|
+
issues.push(issue("incomplete-retention-schedule-document", "Complete the Data Retention Schedule document, its owner, linked Control, and Markdown before requesting approval of the complete schedule."));
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
schedule
|
|
50
|
+
&& options.approvalReview
|
|
51
|
+
&& (!["approved", "active"].includes(schedule.status)
|
|
52
|
+
|| schedule.approvedOn !== options.approvalReview.reviewedOn
|
|
53
|
+
|| !sameIds(schedule.approverIds, options.approvalReview.reviewedByIds)
|
|
49
54
|
|| !approvalBindingsMatch(loaded, schedule))
|
|
50
55
|
) {
|
|
51
|
-
issues.push(issue("
|
|
56
|
+
issues.push(issue("invalid-retention-schedule-approval-binding", "The Data Retention Schedule document no longer matches the recorded whole-schedule approval. Review and approve the current complete schedule again."));
|
|
52
57
|
}
|
|
53
58
|
const activeRows = rows.filter(({ status }) => status === "active");
|
|
54
59
|
const mismatched = schedule
|
|
@@ -65,15 +70,20 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
65
70
|
if (incomplete.length) {
|
|
66
71
|
issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
|
|
67
72
|
}
|
|
68
|
-
const
|
|
69
|
-
const
|
|
73
|
+
const proposedRows = rows.filter(rowProposesCoverage);
|
|
74
|
+
const scopedPrograms = program.programIds
|
|
75
|
+
? program.programIds.map((id) => byId.get(id)).filter((record) => record?.type === "program")
|
|
76
|
+
: [program];
|
|
77
|
+
const uncovered = scopedPrograms.flatMap((candidate) => (
|
|
78
|
+
retentionUses(loaded, candidate).map((use) => ({ ...use, programId: candidate.id }))
|
|
79
|
+
)).find((use) => !proposedRows.some((row) => (
|
|
70
80
|
(row.informationTypeIds || []).includes(use.informationTypeId)
|
|
71
|
-
&& ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(
|
|
81
|
+
&& ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(use.programId))
|
|
72
82
|
)));
|
|
73
83
|
if (uncovered) {
|
|
74
84
|
issues.push(issue(
|
|
75
85
|
"uncovered-retention-information-use",
|
|
76
|
-
`${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no
|
|
86
|
+
`${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no proposed retention schedule row covers both the type and scope.`
|
|
77
87
|
));
|
|
78
88
|
}
|
|
79
89
|
if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
|
|
@@ -82,6 +92,10 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
82
92
|
return issues;
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
function sameIds(left = [], right = []) {
|
|
96
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
function issue(code, message) {
|
|
86
100
|
return { code, message };
|
|
87
101
|
}
|
|
@@ -99,6 +113,14 @@ function primaryMarkdown(loaded, record) {
|
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
function rowProposesCoverage(row) {
|
|
117
|
+
return Boolean(
|
|
118
|
+
(row.informationTypeIds || []).length
|
|
119
|
+
&& (row.scopeResourceIds || []).length
|
|
120
|
+
&& row.scheduleDocumentId
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
102
124
|
function approvalBindingsMatch(loaded, record) {
|
|
103
125
|
const actual = {};
|
|
104
126
|
for (const entry of markdownEntries(loaded.model, record)) {
|
|
@@ -114,8 +136,5 @@ function approvalBindingsMatch(loaded, record) {
|
|
|
114
136
|
const actualKeys = Object.keys(actual).sort();
|
|
115
137
|
const expectedKeys = Object.keys(expected).sort();
|
|
116
138
|
return actualKeys.length === expectedKeys.length
|
|
117
|
-
&& actualKeys.every((key, index) =>
|
|
118
|
-
key === expectedKeys[index]
|
|
119
|
-
&& actual[key] === expected[key]
|
|
120
|
-
));
|
|
139
|
+
&& actualKeys.every((key, index) => key === expectedKeys[index] && actual[key] === expected[key]);
|
|
121
140
|
}
|