filegrc 0.16.12 → 0.16.14
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 +4 -4
- package/package.json +1 -1
- package/src/applicability-scope.js +80 -8
- package/src/audit-preparation.js +40 -13
- package/src/batch-review.js +16 -11
- package/src/collection-review.js +13 -6
- package/src/collection-revision.js +94 -2
- package/src/collection-scope.js +20 -10
- package/src/model-docs.js +1 -1
- package/src/program-readiness.js +30 -6
- package/src/retention.js +46 -4
- package/src/validate.js +13 -20
- package/src/web.js +2 -2
package/model/v11.json
CHANGED
|
@@ -657,9 +657,9 @@
|
|
|
657
657
|
},
|
|
658
658
|
"control": {
|
|
659
659
|
"title": "Control implementation oversight",
|
|
660
|
-
"description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate.
|
|
660
|
+
"description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate. Handle later changes through affected Controls, event work, and scheduled program reviews.",
|
|
661
661
|
"reviewPoints": [
|
|
662
|
-
"Perform this one
|
|
662
|
+
"Perform this one setup review after Controls, evidence sources, and Obligations are ready, before management activates the approved program content.",
|
|
663
663
|
"Confirm owners, procedures, scope, operation patterns, mappings, Obligations, and authoritative evidence sources remain current.",
|
|
664
664
|
"Record follow-up work separately when the review finds a gap."
|
|
665
665
|
],
|
|
@@ -2875,7 +2875,7 @@
|
|
|
2875
2875
|
"description": "Management's actual Control implementation, mapped to Requirements, bounded Systems, operating Components, and authoritative evidence-source Components.",
|
|
2876
2876
|
"guidance": {
|
|
2877
2877
|
"policyBasis": "Controls translate approved Policies and applicable Requirements into owned procedures that management can operate and prove. FileGRC does not infer technical implementation from policy prose. Configuration facts belong in Controls, Components, Systems, Obligations, and Evidence.",
|
|
2878
|
-
"cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review
|
|
2878
|
+
"cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review affected Controls after material changes and use scheduled program reviews for periodic oversight.",
|
|
2879
2879
|
"reviewPoints": [
|
|
2880
2880
|
"Confirm the Control is needed for the applicable Requirements, Commitments, and service boundary.",
|
|
2881
2881
|
"Describe what people actually do to operate the Control.",
|
|
@@ -8669,7 +8669,7 @@
|
|
|
8669
8669
|
"description": "A logical operational or technical building block that materially delivers a System, supports a Control, produces authoritative Evidence, or supports relevant operations.",
|
|
8670
8670
|
"guidance": {
|
|
8671
8671
|
"policyBasis": "System boundaries and Control implementations need explicit operational building blocks and evidence sources.",
|
|
8672
|
-
"cadence": "Create only when the capability materially participates in an in-scope System.
|
|
8672
|
+
"cadence": "Create only when the capability materially participates in an in-scope System. After a change, inspect its System uses and affected Controls. If the change is material to risk, data use, evidence, access, or continuity, assess it through the relevant System change Policy Event and its linked work. Follow the scheduled policy and program reviews; do not repeat setup collection reviews.",
|
|
8673
8673
|
"reviewPoints": [
|
|
8674
8674
|
"Choose at least one role for each related System and explain why the Component matters there.",
|
|
8675
8675
|
"Link a Vendor only when that external organization primarily supplies this Component.",
|
package/package.json
CHANGED
|
@@ -27,7 +27,50 @@ const unorderedStringArrayFields = new Set([
|
|
|
27
27
|
"tags"
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
const applicabilityDecisionFields = {
|
|
31
|
+
requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
|
|
32
|
+
control: ["statement", "requirementIds", "code", "systemIds"],
|
|
33
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
|
|
34
|
+
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds"]
|
|
35
|
+
};
|
|
36
|
+
const legacyApplicabilityDecisionFields = {
|
|
37
|
+
requirement: applicabilityDecisionFields.requirement,
|
|
38
|
+
control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
|
|
39
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
|
|
40
|
+
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"]
|
|
41
|
+
};
|
|
42
|
+
|
|
30
43
|
export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
|
|
44
|
+
if (Number(model.modelVersion) >= 11 && !options.legacyDecisionScope) {
|
|
45
|
+
const linkedRequirements = resources.filter((candidate) => candidate.type === "requirement"
|
|
46
|
+
&& (record.type === "requirement" ? candidate.id === record.id : (record.requirementIds || []).includes(candidate.id)));
|
|
47
|
+
const frameworkIds = new Set(linkedRequirements.map(({ frameworkId }) => frameworkId));
|
|
48
|
+
const scopedProgram = record.type === "requirement" ? {
|
|
49
|
+
...program,
|
|
50
|
+
systemIds: [],
|
|
51
|
+
frameworkIds: (program?.frameworkIds || []).filter((id) => id === record.frameworkId),
|
|
52
|
+
requirementIds: (program?.requirementIds || []).filter((id) => id === record.id)
|
|
53
|
+
} : {
|
|
54
|
+
...program,
|
|
55
|
+
systemIds: (program?.systemIds || []).filter((id) => (record.systemIds || []).includes(id)),
|
|
56
|
+
frameworkIds: (program?.frameworkIds || []).filter((id) => !frameworkIds.size || frameworkIds.has(id)),
|
|
57
|
+
requirementIds: (program?.requirementIds || []).filter((id) => linkedRequirements.some((candidate) => candidate.id === id))
|
|
58
|
+
};
|
|
59
|
+
const facts = {
|
|
60
|
+
modelVersion: model.modelVersion,
|
|
61
|
+
resource: applicabilityResourceFacts(record, model, options),
|
|
62
|
+
program: pick(scopedProgram || {}, ["id", "assuranceGoal", "systemIds", "frameworkIds", "requirementIds"], model, "program"),
|
|
63
|
+
requirements: linkedRequirements.sort(compareRecordIds).map((candidate) => pick(candidate,
|
|
64
|
+
["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
|
|
65
|
+
frameworks: resources.filter(({ type, id }) => type === "framework" && frameworkIds.has(id))
|
|
66
|
+
.sort(compareRecordIds).map((candidate) => pick(candidate, ["id", "version", "status"], model)),
|
|
67
|
+
requirementDecisions: (record.type === "requirement" ? [] : program?.requirementApplicability || [])
|
|
68
|
+
.filter(({ requirementId }) => linkedRequirements.some(({ id }) => id === requirementId))
|
|
69
|
+
.map(({ requirementId, decision }) => ({ requirementId, decision }))
|
|
70
|
+
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
71
|
+
};
|
|
72
|
+
return calculateRevision("applicability-scope", stableJson(facts));
|
|
73
|
+
}
|
|
31
74
|
const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
|
|
32
75
|
&& !options.legacyBroadScope && !options.legacyBroadCommitment;
|
|
33
76
|
const linkedRequirementIds = new Set(record?.requirementIds || []);
|
|
@@ -83,8 +126,7 @@ export function applicabilityScopeRevision(record, program, resources, model, op
|
|
|
83
126
|
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
84
127
|
})
|
|
85
128
|
},
|
|
86
|
-
resource:
|
|
87
|
-
.filter(([field]) => !excludedResourceFields.has(field)))),
|
|
129
|
+
resource: applicabilityResourceFacts(record, model, options),
|
|
88
130
|
systems: resources
|
|
89
131
|
.filter(({ type, id }) => type === "system" && selectedSystemIds.has(id))
|
|
90
132
|
.sort(compareRecordIds)
|
|
@@ -196,6 +238,17 @@ function compareRecordIds(left, right) {
|
|
|
196
238
|
return left.id.localeCompare(right.id);
|
|
197
239
|
}
|
|
198
240
|
|
|
241
|
+
function applicabilityResourceFacts(record, model, options) {
|
|
242
|
+
const fields = Number(model.modelVersion) >= 11 && !options.legacyResourceProjection
|
|
243
|
+
? (options.legacyDecisionScope ? legacyApplicabilityDecisionFields : applicabilityDecisionFields)[record.type] : null;
|
|
244
|
+
if (!fields) return canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
|
|
245
|
+
.filter(([field]) => !excludedResourceFields.has(field))));
|
|
246
|
+
const value = Object.fromEntries(["id", "type", ...(options.legacyDecisionScope ? ["title"] : []), "externalIds", "extensions", ...fields]
|
|
247
|
+
.filter((field) => record[field] !== undefined).map((field) => [field, record[field]]));
|
|
248
|
+
value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
249
|
+
return canonicalObject(model, record.type, value);
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
export function applicabilityReviewIsCurrent(review, record, program, resources, model, root = null) {
|
|
200
253
|
if (
|
|
201
254
|
review?.scopeRevision
|
|
@@ -204,12 +257,22 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
204
257
|
&& Number(model?.modelVersion || 0) < 7
|
|
205
258
|
) return true;
|
|
206
259
|
if (!review?.scopeRevision) return false;
|
|
260
|
+
if (Number(model.modelVersion) >= 11 && record?.type === "control"
|
|
261
|
+
&& (review.decision === "not-applicable") !== (record.status === "not-applicable")) return false;
|
|
262
|
+
if (Number(model.modelVersion) >= 11 && ["retired", "superseded"].includes(record?.status)) return false;
|
|
207
263
|
const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
|
|
208
264
|
if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
|
|
265
|
+
if (Number(model.modelVersion) >= 11 && matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
266
|
+
legacyDecisionScope: true
|
|
267
|
+
}))) return true;
|
|
268
|
+
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
|
|
269
|
+
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyDecisionScope: true, legacyResourceProjection: true }))) return true;
|
|
270
|
+
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
|
|
271
|
+
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyResourceProjection: true }))) return true;
|
|
209
272
|
if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
|
|
210
|
-
record, program, resources, model, { legacyBroadScope: true }
|
|
273
|
+
record, program, resources, model, { legacyDecisionScope: true, legacyBroadScope: true }
|
|
211
274
|
))) return true;
|
|
212
|
-
if (
|
|
275
|
+
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type] && root && historicalScopedReviewMatches(
|
|
213
276
|
review.scopeRevision, record, program, resources, model, root
|
|
214
277
|
)) return true;
|
|
215
278
|
// Older reviews hashed Policy approval metadata alongside scope facts.
|
|
@@ -220,6 +283,7 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
220
283
|
for (const legacyPolicyWithoutEffectiveOn of [false, true]) {
|
|
221
284
|
if (matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
222
285
|
legacyPolicyStatus,
|
|
286
|
+
legacyDecisionScope: true,
|
|
223
287
|
legacyPolicyWithoutApprovalBinding,
|
|
224
288
|
legacyPolicyWithoutEffectiveOn
|
|
225
289
|
}))) return true;
|
|
@@ -238,7 +302,8 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
238
302
|
for (const policy of policies) {
|
|
239
303
|
for (const value of legacyPolicyAlternatives(policy)) {
|
|
240
304
|
if (matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
241
|
-
legacyPolicyOverrides: { [policy.id]: value }
|
|
305
|
+
legacyPolicyOverrides: { [policy.id]: value },
|
|
306
|
+
legacyDecisionScope: true
|
|
242
307
|
}))) return true;
|
|
243
308
|
}
|
|
244
309
|
}
|
|
@@ -263,9 +328,16 @@ function historicalScopedReviewMatches(stored, record, program, resources, model
|
|
|
263
328
|
if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
|
|
264
329
|
historicalRecord, historicalProgram, historicalResources, historicalModel
|
|
265
330
|
))) continue;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
331
|
+
for (const legacyDecisionScope of [false, true]) {
|
|
332
|
+
for (const legacyBroadScope of [false, true]) {
|
|
333
|
+
for (const legacyResourceProjection of [false, true]) {
|
|
334
|
+
if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
|
|
335
|
+
historicalRecord, historicalProgram, historicalResources, historicalModel,
|
|
336
|
+
{ legacyDecisionScope, legacyBroadScope, legacyResourceProjection }
|
|
337
|
+
))) return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
269
341
|
}
|
|
270
342
|
return false;
|
|
271
343
|
}
|
package/src/audit-preparation.js
CHANGED
|
@@ -690,18 +690,24 @@ function scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit) {
|
|
|
690
690
|
historical = null;
|
|
691
691
|
}
|
|
692
692
|
if (
|
|
693
|
-
!scopeRecordIsRelevant(current, audit)
|
|
694
|
-
&& !scopeRecordIsRelevant(historical, historicalAudit)
|
|
693
|
+
!scopeRecordIsRelevant(current, audit, loaded)
|
|
694
|
+
&& !scopeRecordIsRelevant(historical, historicalAudit, loaded)
|
|
695
695
|
) continue;
|
|
696
|
-
if (JSON.stringify(canonicalScopeValue(current))
|
|
696
|
+
if (JSON.stringify(canonicalScopeValue(auditScopeRecordValue(current)))
|
|
697
|
+
!== JSON.stringify(canonicalScopeValue(auditScopeRecordValue(historical)))) changed.push(path);
|
|
697
698
|
}
|
|
698
699
|
return changed;
|
|
699
700
|
}
|
|
700
701
|
|
|
701
|
-
function scopeRecordIsRelevant(record, audit) {
|
|
702
|
+
function scopeRecordIsRelevant(record, audit, loaded) {
|
|
702
703
|
if (!record || !audit) return false;
|
|
703
704
|
const selectedSystemIds = new Set(audit.systemIds || []);
|
|
704
|
-
|
|
705
|
+
if (["commitment", "complementary-control"].includes(record.type)
|
|
706
|
+
&& (record.systemIds || []).some((id) => selectedSystemIds.has(id))) return true;
|
|
707
|
+
if (record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId)) return true;
|
|
708
|
+
if (record.type === "component" && (record.systemUses || []).some(({ systemId, roles }) => (
|
|
709
|
+
selectedSystemIds.has(systemId) && (roles || []).includes("service-delivery")
|
|
710
|
+
))) return true;
|
|
705
711
|
const selectedIds = new Set([
|
|
706
712
|
audit.programId,
|
|
707
713
|
...(audit.frameworkIds || []),
|
|
@@ -714,15 +720,36 @@ function scopeRecordIsRelevant(record, audit) {
|
|
|
714
720
|
...auditSubserviceVendorIds(audit),
|
|
715
721
|
...auditSubserviceComponentIds(audit)
|
|
716
722
|
].filter(Boolean));
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
return (record.systemIds || []).some((id) => selectedSystemIds.has(id));
|
|
723
|
+
const selectedControls = new Set(audit.controlIds || []);
|
|
724
|
+
for (const control of loaded.resources.filter(({ type, id }) => type === "control" && selectedControls.has(id))) {
|
|
725
|
+
for (const id of control.evidenceSourceComponentIds || []) selectedIds.add(id);
|
|
721
726
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
727
|
+
return selectedIds.has(record.id);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const AUDIT_SCOPE_RECORD_FIELDS = {
|
|
731
|
+
program: ["id", "status", "assuranceGoal"],
|
|
732
|
+
framework: ["id", "version", "status", "publisher", "sourceReference", "effectiveOn", "retiredOn"],
|
|
733
|
+
requirement: ["id", "frameworkId", "reference", "description", "parentRequirementId"],
|
|
734
|
+
system: ["id", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "ownerIds", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
735
|
+
control: ["id", "status", "statement", "requirementIds", "code", "activity", "controlType", "systemIds", "ownerIds", "effectiveOn", "retiredOn", "operationMode", "operationPattern", "componentIds", "evidenceSourceComponentIds", "policyIds", "procedureRevision", "procedureEffectiveOn"],
|
|
736
|
+
commitment: ["id", "status", "commitmentKind", "statement", "systemIds", "requirementIds", "controlIds", "customerFacing", "sourceResourceIds", "effectiveOn", "ownerIds", "reportingRouteRequirements"],
|
|
737
|
+
"complementary-control": ["id", "status", "statement", "systemIds", "relatedControlIds", "responsibleParty", "vendorId", "componentIds", "commitmentIds", "sourceDocumentIds", "requirementIds", "effectiveOn"],
|
|
738
|
+
component: ["id", "status", "componentKind", "description", "environment", "vendorId", "systemUses", "informationUses", "evidenceSourceKinds", "ownerIds", "evidenceOwnerIds", "criticality", "classificationId", "internetExposed", "continuityObjectives"],
|
|
739
|
+
vendor: ["id", "category", "status", "criticality", "classificationId", "informationTypeIds", "standardAgreement", "agreementDocumentId", "startDate", "endDate"],
|
|
740
|
+
appointment: ["id", "status", "appointmentKind", "holderId", "scopeResourceIds", "startsOn", "endsOn"]
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
function auditScopeRecordValue(record) {
|
|
744
|
+
if (!record) return null;
|
|
745
|
+
const fields = AUDIT_SCOPE_RECORD_FIELDS[record.type] || ["id", "type", "status"];
|
|
746
|
+
return {
|
|
747
|
+
...Object.fromEntries(fields.filter((field) => record[field] !== undefined)
|
|
748
|
+
.map((field) => [field, record[field]])),
|
|
749
|
+
...(["control", "commitment", "complementary-control"].includes(record.type)
|
|
750
|
+
? { unavailable: ["not-applicable", "retired", "superseded"].includes(record.status) }
|
|
751
|
+
: {})
|
|
752
|
+
};
|
|
726
753
|
}
|
|
727
754
|
|
|
728
755
|
function auditSubserviceVendorIds(audit) {
|
package/src/batch-review.js
CHANGED
|
@@ -142,16 +142,23 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
142
142
|
}
|
|
143
143
|
: program;
|
|
144
144
|
const update = reviewedDecisions.flatMap(({ record, result, rationale, reviewedByIds, reviewedOn }) => {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
rationale,
|
|
151
|
-
reviewedByIds,
|
|
152
|
-
reviewedOn,
|
|
153
|
-
scopeRevision
|
|
145
|
+
const next = { ...record };
|
|
146
|
+
if (record.type === "control" && result === "not-applicable" && record.status !== "not-applicable") {
|
|
147
|
+
next.status = "not-applicable";
|
|
148
|
+
if (loaded.model.resources.control.fields.statusTransition) {
|
|
149
|
+
next.statusTransition = { changedByIds: reviewedByIds, changedOn: reviewedOn, reason: rationale };
|
|
154
150
|
}
|
|
151
|
+
}
|
|
152
|
+
if (record.type === "control" && result !== "not-applicable" && record.status === "not-applicable") {
|
|
153
|
+
next.status = "planned";
|
|
154
|
+
delete next.statusTransition;
|
|
155
|
+
}
|
|
156
|
+
next.applicabilityReview = {
|
|
157
|
+
decision: result,
|
|
158
|
+
rationale,
|
|
159
|
+
reviewedByIds,
|
|
160
|
+
reviewedOn,
|
|
161
|
+
scopeRevision: applicabilityScopeRevision(next, reviewedProgram, loaded.resources, loaded.model)
|
|
155
162
|
};
|
|
156
163
|
if (record.type === "requirement") {
|
|
157
164
|
if (modelSupports(loaded.model, "program-scope")) {
|
|
@@ -160,8 +167,6 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
160
167
|
next.applicability = result;
|
|
161
168
|
next.applicabilityRationale = rationale;
|
|
162
169
|
}
|
|
163
|
-
if (record.type === "control" && result === "not-applicable") next.status = "not-applicable";
|
|
164
|
-
if (record.type === "control" && result === "applicable" && record.status === "not-applicable") next.status = "planned";
|
|
165
170
|
return [next];
|
|
166
171
|
});
|
|
167
172
|
if (v4RequirementDecisions.length) {
|
package/src/collection-review.js
CHANGED
|
@@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { modelSupports } from "../model/index.js";
|
|
3
3
|
import {
|
|
4
4
|
collectionRevision,
|
|
5
|
-
|
|
5
|
+
collectionReviewDecisionMatches,
|
|
6
|
+
reviewedControlConflictIds
|
|
6
7
|
} from "./collection-revision.js";
|
|
7
8
|
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords } from "./collection-scope.js";
|
|
8
9
|
import { applyResourceBatch, applyRetentionScheduleReviewBatch, contentRevision, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
|
|
@@ -62,15 +63,17 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
62
63
|
&& review.coverage.on === review.reviewedOn
|
|
63
64
|
&& review.knowledgeCutoffAt
|
|
64
65
|
&& Array.isArray(review.populationResourceIds)
|
|
65
|
-
&&
|
|
66
|
+
&& (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
|
|
67
|
+
|| sameIds(review.populationResourceIds, records.map(({ id }) => id)))
|
|
66
68
|
);
|
|
67
69
|
const allowsEmptyCollection = allowedDecisions.some((decision) => (
|
|
68
70
|
decision === "zero-population" || decision === "externally-managed"
|
|
69
71
|
));
|
|
70
|
-
const revisionMatches =
|
|
72
|
+
const revisionMatches = collectionReviewDecisionMatches(
|
|
71
73
|
loaded,
|
|
72
74
|
resourceType,
|
|
73
|
-
review
|
|
75
|
+
review,
|
|
76
|
+
records,
|
|
74
77
|
{
|
|
75
78
|
programId: program.id,
|
|
76
79
|
authoritativeSourceId,
|
|
@@ -92,11 +95,14 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
92
95
|
: [];
|
|
93
96
|
const recordProposals = collectionRecordProposals(loaded, resourceType, records, program);
|
|
94
97
|
const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready);
|
|
98
|
+
const reviewerConflicts = resourceType === "control" && Number(loaded.model.modelVersion) >= 11 && review
|
|
99
|
+
? reviewedControlConflictIds(loaded, review)
|
|
100
|
+
: reviewerEligibility?.reviewerConflictIds || [];
|
|
95
101
|
const reviewersEligible = !reviewerEligibility || Boolean(
|
|
96
102
|
review?.reviewedByIds?.length
|
|
97
103
|
&& review.reviewedByIds.every((id) => (
|
|
98
104
|
personWasActiveOn(loaded.resources.find((record) => record.id === id), review.reviewedOn)
|
|
99
|
-
&& !
|
|
105
|
+
&& !reviewerConflicts.includes(id)
|
|
100
106
|
))
|
|
101
107
|
);
|
|
102
108
|
const reviewScopeMatches = !workspaceWideRetentionReview
|
|
@@ -108,7 +114,8 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
108
114
|
&& temporalReview
|
|
109
115
|
&& reviewScopeMatches
|
|
110
116
|
&& reviewersEligible
|
|
111
|
-
&&
|
|
117
|
+
&& (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
|
|
118
|
+
|| !incompleteRecordProposals.length)
|
|
112
119
|
&& !approvalIssues.length
|
|
113
120
|
);
|
|
114
121
|
const stale = Boolean(
|
|
@@ -4,14 +4,16 @@ import { modelSupports } from "../model/index.js";
|
|
|
4
4
|
import {
|
|
5
5
|
authoritativeSourceRevisionValue,
|
|
6
6
|
collectionRevisionInputs,
|
|
7
|
-
collectionScopeRevisionFacts
|
|
7
|
+
collectionScopeRevisionFacts,
|
|
8
|
+
scopedCollectionRecords
|
|
8
9
|
} from "./collection-scope.js";
|
|
9
10
|
import { resolveDataPath } from "./paths.js";
|
|
10
11
|
import { getFileAtRevision } from "./git.js";
|
|
11
12
|
import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
|
|
12
13
|
import { resolveProgram } from "./program.js";
|
|
14
|
+
import { currentPartyPeople } from "./parties.js";
|
|
13
15
|
import { markdownEntries } from "./resource-markdown.js";
|
|
14
|
-
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
|
|
16
|
+
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
15
17
|
|
|
16
18
|
export function collectionRevision(loaded, resourceType, options = {}) {
|
|
17
19
|
return calculateCollectionRevision(loaded, resourceType, options, false, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy");
|
|
@@ -44,6 +46,96 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
44
46
|
return historicallyEquivalentCollection(loaded, resourceType, storedRevision, options, currentRevision);
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
// Ordinary Collection Reviews record that setup was completed. Their original
|
|
50
|
+
// population remains evidence of what management reviewed at that time;
|
|
51
|
+
// subsequent operating records are assessed by their own workflows. The Data
|
|
52
|
+
// Retention Schedule is an approval of exact content and keeps its binding.
|
|
53
|
+
export function collectionReviewDecisionMatches(loaded, resourceType, review, records, options = {}) {
|
|
54
|
+
if (!review) return false;
|
|
55
|
+
if (resourceType === "retention-schedule-item" || Number(loaded.model.modelVersion) < 11) {
|
|
56
|
+
return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options);
|
|
57
|
+
}
|
|
58
|
+
if (!revisionDigest("collection", review.collectionRevision) || review.status !== "active") return false;
|
|
59
|
+
if (!originalCollectionReviewMatches(loaded, resourceType, review, options)) return false;
|
|
60
|
+
if (review.decision === "zero-population") return records.length === 0;
|
|
61
|
+
if (review.decision === "complete") return records.length > 0;
|
|
62
|
+
if (review.decision === "externally-managed") {
|
|
63
|
+
const id = review.authoritativeComponentId || review.authoritativeSystemId;
|
|
64
|
+
return loaded.resources.some((record) => record.id === id && record.status === "active");
|
|
65
|
+
}
|
|
66
|
+
return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function originalCollectionReviewMatches(loaded, resourceType, review, options) {
|
|
70
|
+
const currentRevision = options.currentRevision || collectionRevision(loaded, resourceType, options);
|
|
71
|
+
if (revisionsMatch("collection", review.collectionRevision, currentRevision)) {
|
|
72
|
+
if (!review.populationResourceIds) return true;
|
|
73
|
+
const program = options.program || resolveProgram(loaded, options.programId || review.scopeResourceIds?.[0]);
|
|
74
|
+
const currentPopulation = scopedCollectionRecords(loaded, resourceType, program).map(({ id }) => id).sort();
|
|
75
|
+
const reviewedPopulation = [...review.populationResourceIds].sort();
|
|
76
|
+
return currentPopulation.length === reviewedPopulation.length
|
|
77
|
+
&& currentPopulation.every((id, index) => id === reviewedPopulation[index]);
|
|
78
|
+
}
|
|
79
|
+
if (!loaded.root || !review.scopeRevision) {
|
|
80
|
+
return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, { ...options, currentRevision });
|
|
81
|
+
}
|
|
82
|
+
const snapshot = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision);
|
|
83
|
+
if (!snapshot) return false;
|
|
84
|
+
const programId = options.programId || options.program?.id || review.scopeResourceIds?.[0];
|
|
85
|
+
let program;
|
|
86
|
+
try {
|
|
87
|
+
program = resolveProgram(snapshot, programId);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const currentProgram = resolveProgram(loaded, programId);
|
|
92
|
+
const selectionField = {
|
|
93
|
+
framework: "frameworkIds",
|
|
94
|
+
system: "systemIds",
|
|
95
|
+
component: "systemIds",
|
|
96
|
+
control: "controlIds"
|
|
97
|
+
}[resourceType];
|
|
98
|
+
if (selectionField && JSON.stringify([...(program[selectionField] || [])].sort())
|
|
99
|
+
!== JSON.stringify([...(currentProgram[selectionField] || [])].sort())) return false;
|
|
100
|
+
const population = scopedCollectionRecords(snapshot, resourceType, program).map(({ id }) => id).sort();
|
|
101
|
+
const reviewedPopulation = [...(review.populationResourceIds || [])].sort();
|
|
102
|
+
if (review.populationResourceIds && (
|
|
103
|
+
population.length !== reviewedPopulation.length
|
|
104
|
+
|| population.some((id, index) => id !== reviewedPopulation[index])
|
|
105
|
+
)) return false;
|
|
106
|
+
return collectionRevisionMatches(snapshot, resourceType, review.collectionRevision, {
|
|
107
|
+
programId,
|
|
108
|
+
authoritativeSourceId: options.authoritativeSourceId,
|
|
109
|
+
historicalCommit: review.scopeRevision
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function reviewedControlConflictIds(loaded, review) {
|
|
114
|
+
const snapshot = loaded.root && /^[a-f0-9]{40,64}$/i.test(review?.scopeRevision || "")
|
|
115
|
+
? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision)
|
|
116
|
+
: null;
|
|
117
|
+
const source = snapshot || loaded;
|
|
118
|
+
let program;
|
|
119
|
+
try {
|
|
120
|
+
program = resolveProgram(source, review.scopeResourceIds?.[0]);
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const controls = scopedCollectionRecords(source, "control", program);
|
|
125
|
+
const controlIds = new Set(controls.map(({ id }) => id));
|
|
126
|
+
const byId = new Map(source.resources.map((record) => [record.id, record]));
|
|
127
|
+
const conflicts = new Set();
|
|
128
|
+
for (const record of [
|
|
129
|
+
...controls,
|
|
130
|
+
...source.resources.filter((record) => record.type === "obligation"
|
|
131
|
+
&& record.status === "active"
|
|
132
|
+
&& (record.controlIds || []).some((id) => controlIds.has(id)))
|
|
133
|
+
]) {
|
|
134
|
+
for (const id of currentPartyPeople(record.ownerIds || [], byId)) conflicts.add(id);
|
|
135
|
+
}
|
|
136
|
+
return [...conflicts].sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
47
139
|
function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
|
|
48
140
|
if (!loaded.root) return false;
|
|
49
141
|
const context = reviewHistoryContext(loaded.root, loaded);
|
package/src/collection-scope.js
CHANGED
|
@@ -200,6 +200,7 @@ function scopedProgramPeople(loaded, program = {}) {
|
|
|
200
200
|
|
|
201
201
|
export function collectionRevisionInputs(loaded, resourceType, program, options = {}) {
|
|
202
202
|
const legacy = options.legacy === true;
|
|
203
|
+
const currentModel = modelSupports(loaded.model, "retention-schedule-approval");
|
|
203
204
|
const reviewed = scopedCollectionRecords(loaded, resourceType, program);
|
|
204
205
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
205
206
|
return reviewed.map((record) => ({ record, value: record, includeContent: true }));
|
|
@@ -367,11 +368,11 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
367
368
|
&& record.type === "document"
|
|
368
369
|
? retentionScheduleDocumentProposalValue(record)
|
|
369
370
|
: options.historicalReviewMetadata
|
|
370
|
-
? dependencyRevisionValue(resourceType, record, legacy)
|
|
371
|
-
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy)),
|
|
371
|
+
? dependencyRevisionValue(resourceType, record, legacy, true, currentModel)
|
|
372
|
+
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel)),
|
|
372
373
|
includeContent: legacy
|
|
373
374
|
|| reviewedIds.has(record.id)
|
|
374
|
-
|| dependencyContentAffectsRevision(resourceType, record.type)
|
|
375
|
+
|| dependencyContentAffectsRevision(resourceType, record.type, options.historicalReviewMetadata, currentModel)
|
|
375
376
|
}));
|
|
376
377
|
}
|
|
377
378
|
|
|
@@ -379,7 +380,7 @@ function retentionScheduleRowProposalValue(record, historicalReviewMetadata = fa
|
|
|
379
380
|
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
380
381
|
"approvedByIds",
|
|
381
382
|
"approvedOn",
|
|
382
|
-
...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions"])
|
|
383
|
+
...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
|
|
383
384
|
].includes(field)));
|
|
384
385
|
}
|
|
385
386
|
|
|
@@ -591,22 +592,31 @@ const dependencyContentTypes = {
|
|
|
591
592
|
component: new Set(["system"]),
|
|
592
593
|
control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
|
|
593
594
|
"retention-schedule-item": new Set(["document", "policy"]),
|
|
594
|
-
"complementary-control": new Set(["system", "
|
|
595
|
+
"complementary-control": new Set(["system", "document", "component"])
|
|
595
596
|
};
|
|
596
597
|
|
|
597
|
-
function dependencyRevisionValue(resourceType, record, legacy) {
|
|
598
|
-
const
|
|
598
|
+
function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false) {
|
|
599
|
+
const controlDecision = currentModel && !historicalReviewMetadata && record.type === "control"
|
|
600
|
+
&& ["retention-schedule-item", "complementary-control"].includes(resourceType);
|
|
601
|
+
const fields = controlDecision
|
|
602
|
+
? resourceType === "retention-schedule-item"
|
|
603
|
+
? ["id", "type", "statement", "activity", "controlType", "operationMode", "operationPattern", "requirementIds", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"]
|
|
604
|
+
: ["id", "type", "statement", "activity", "controlType", "operationMode", "operationPattern", "requirementIds", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"]
|
|
605
|
+
: legacy
|
|
599
606
|
? legacyDependencyFieldOverrides[resourceType]?.[record.type]
|
|
600
607
|
|| dependencyFields[resourceType]?.[record.type]
|
|
601
608
|
: dependencyFields[resourceType]?.[record.type];
|
|
602
609
|
if (!fields) return { id: record.id, type: record.type };
|
|
603
|
-
|
|
610
|
+
const value = Object.fromEntries(fields
|
|
604
611
|
.filter((field) => record[field] !== undefined)
|
|
605
612
|
.map((field) => [field, record[field]]));
|
|
613
|
+
if (controlDecision) value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
614
|
+
return value;
|
|
606
615
|
}
|
|
607
616
|
|
|
608
|
-
function dependencyContentAffectsRevision(resourceType, dependencyType) {
|
|
609
|
-
return
|
|
617
|
+
function dependencyContentAffectsRevision(resourceType, dependencyType, historicalReviewMetadata = false, currentModel = false) {
|
|
618
|
+
return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependencyType === "control")
|
|
619
|
+
|| dependencyContentTypes[resourceType]?.has(dependencyType) || false;
|
|
610
620
|
}
|
|
611
621
|
|
|
612
622
|
function sorted(values) {
|
package/src/model-docs.js
CHANGED
|
@@ -47,7 +47,7 @@ export function generateModelDocumentation(model) {
|
|
|
47
47
|
"",
|
|
48
48
|
"## Collection review confirmations",
|
|
49
49
|
"",
|
|
50
|
-
"FileGRC derives record issues, but it cannot infer that management
|
|
50
|
+
"FileGRC derives record issues, but it cannot infer that management finished an apparently complete or empty setup collection. Each configured collection review records the conclusion, reviewer, date, scope revision, and reviewed collection revision. Routine operating changes do not reopen a completed setup confirmation. The Data Retention Schedule keeps exact approval bindings.",
|
|
51
51
|
"",
|
|
52
52
|
"| Resource type | Review | Allowed conclusions | What to review |",
|
|
53
53
|
"| --- | --- | --- | --- |",
|
package/src/program-readiness.js
CHANGED
|
@@ -242,6 +242,27 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
242
242
|
...evidenceGateStages,
|
|
243
243
|
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
|
|
244
244
|
];
|
|
245
|
+
for (const assessment of collectionReviews.filter(({ resourceType, complete }) => (
|
|
246
|
+
complete && resourceType !== "retention-schedule-item"
|
|
247
|
+
))) {
|
|
248
|
+
for (const proposal of assessment.incompleteRecordProposals || []) {
|
|
249
|
+
stages.at(-1).items.push(item(
|
|
250
|
+
`operating-${assessment.resourceType}-${proposal.resourceId}`,
|
|
251
|
+
"action",
|
|
252
|
+
`Finish ${proposal.title}`,
|
|
253
|
+
"Finish or retire this new record, check its affected Systems and Controls, and start a change event only if the change is material under an approved Policy.",
|
|
254
|
+
{ type: assessment.resourceType, id: proposal.resourceId, title: proposal.title },
|
|
255
|
+
{
|
|
256
|
+
resourceType: assessment.resourceType,
|
|
257
|
+
destination: { kind: "record", resourceType: assessment.resourceType, resourceId: proposal.resourceId },
|
|
258
|
+
commands: [
|
|
259
|
+
`npx filegrc guide ${assessment.resourceType} --json`,
|
|
260
|
+
`npx filegrc get ${shellArgument(proposal.resourceId)} --mutation`
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
245
266
|
finalizeStage(stages.at(-1));
|
|
246
267
|
const periodStarted = Boolean(
|
|
247
268
|
program?.assuranceGoal === "soc-2-type-2"
|
|
@@ -408,14 +429,17 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
408
429
|
&& String(rationale || "").trim()
|
|
409
430
|
))
|
|
410
431
|
));
|
|
432
|
+
const componentSetupComplete = collectionReviews.find(({ resourceType }) => resourceType === "component")?.complete === true;
|
|
411
433
|
items.push(item(
|
|
412
434
|
"system-components",
|
|
413
|
-
scope.components.length === completeComponents.length ? "complete" : "action",
|
|
414
|
-
"
|
|
415
|
-
|
|
435
|
+
componentSetupComplete || scope.components.length === completeComponents.length ? "complete" : "action",
|
|
436
|
+
"Define scoped Component roles",
|
|
437
|
+
componentSetupComplete
|
|
438
|
+
? "The initial Component scope was confirmed. Complete later Component changes through their own record and event work."
|
|
439
|
+
: scope.components.length
|
|
416
440
|
? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
|
|
417
441
|
: "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.",
|
|
418
|
-
scope.components[0] || { type: "component" },
|
|
442
|
+
scope.components.find((component) => !completeComponents.includes(component)) || scope.components[0] || { type: "component" },
|
|
419
443
|
{
|
|
420
444
|
componentIds: scope.components.map(({ id }) => id),
|
|
421
445
|
progressUnit: false
|
|
@@ -723,7 +747,7 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
|
|
|
723
747
|
}
|
|
724
748
|
|
|
725
749
|
export function collectionReviewReadinessItem(assessment) {
|
|
726
|
-
const firstIncomplete = assessment.incompleteRecordProposals?.[0]
|
|
750
|
+
const firstIncomplete = !assessment.complete && assessment.incompleteRecordProposals?.[0]
|
|
727
751
|
|| assessment.recordProposals?.find(({ complete }) => !complete);
|
|
728
752
|
const needsFirstRecord = assessment.recordCount === 0
|
|
729
753
|
&& !(assessment.configuration.decisions || []).some((decision) => ["zero-population", "externally-managed"].includes(decision));
|
|
@@ -734,7 +758,7 @@ export function collectionReviewReadinessItem(assessment) {
|
|
|
734
758
|
const approvalAction = !proposalAction && !pendingRetentionRow
|
|
735
759
|
? collectionApprovalIssueAction(assessment)
|
|
736
760
|
: null;
|
|
737
|
-
const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
|
|
761
|
+
const proposalUnits = (assessment.complete ? [] : assessment.recordProposals || []).map((proposal) => ({
|
|
738
762
|
id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
|
|
739
763
|
status: proposal.complete ? "complete" : "action",
|
|
740
764
|
title: `${proposal.title} proposal`
|
package/src/retention.js
CHANGED
|
@@ -167,7 +167,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
|
|
|
167
167
|
if (!entry || reviewing.has(id)) return null;
|
|
168
168
|
reviewing.add(id);
|
|
169
169
|
const parts = [reviewSource(loaded, entry, scopeHashInput)];
|
|
170
|
-
for (const markdown of
|
|
170
|
+
for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
|
|
171
171
|
try {
|
|
172
172
|
parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
173
173
|
} catch (error) {
|
|
@@ -200,7 +200,7 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
|
|
|
200
200
|
if (!entry || reviewing.has(id)) return null;
|
|
201
201
|
reviewing.add(id);
|
|
202
202
|
const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
|
|
203
|
-
for (const markdown of
|
|
203
|
+
for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
|
|
204
204
|
try {
|
|
205
205
|
const source = historicalCommit
|
|
206
206
|
? loaded.historicalFiles
|
|
@@ -317,10 +317,52 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
|
|
|
317
317
|
}
|
|
318
318
|
return legacySource
|
|
319
319
|
? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
|
|
320
|
-
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
|
|
320
|
+
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
|
|
321
|
+
JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval")
|
|
322
|
+
)), scopeHashInput);
|
|
321
323
|
}
|
|
322
324
|
|
|
323
|
-
function
|
|
325
|
+
function reviewMarkdownEntries(loaded, entry, legacySource) {
|
|
326
|
+
// Control Markdown records the implementation procedure. Retention and
|
|
327
|
+
// mapping decisions depend on the structured Control design instead.
|
|
328
|
+
if (!legacySource && modelSupports(loaded.model, "retention-schedule-approval")
|
|
329
|
+
&& entry.record.type === "control") return [];
|
|
330
|
+
return markdownEntries(loaded.model, entry.record);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const reviewDecisionFields = {
|
|
334
|
+
control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
|
|
335
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
|
|
336
|
+
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"],
|
|
337
|
+
"source-coverage": ["sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "collectionCadence", "reconciliationMethod", "validFrom", "validThrough", "componentId", "retentionScheduleItemIds"],
|
|
338
|
+
system: ["purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
339
|
+
component: ["componentKind", "description", "criticality", "environment", "vendorId", "systemUses", "informationUses", "evidenceSourceKinds", "internetExposed", "classificationId", "continuityObjectives"],
|
|
340
|
+
vendor: ["category", "criticality", "description", "standardAgreement", "agreementDocumentId", "startDate", "endDate", "informationTypeIds", "classificationId"],
|
|
341
|
+
"information-type": ["classificationId", "description"],
|
|
342
|
+
framework: ["version", "publisher", "description", "sourceReference", "effectiveOn"],
|
|
343
|
+
requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
|
|
344
|
+
policy: ["policyNumber", "policyKind", "version", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"],
|
|
345
|
+
document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"]
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
function substantiveReviewSource(record, currentModel) {
|
|
349
|
+
if (currentModel && reviewDecisionFields[record.type]) {
|
|
350
|
+
const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]];
|
|
351
|
+
const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined)
|
|
352
|
+
.map((field) => [field, record[field]]));
|
|
353
|
+
if (record.type === "control") {
|
|
354
|
+
decision.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
355
|
+
} else if (["policy", "document"].includes(record.type)) {
|
|
356
|
+
decision.governingStatus = ["approved", "active"].includes(record.status) ? "approved" : record.status || null;
|
|
357
|
+
} else {
|
|
358
|
+
decision.status = record.status || null;
|
|
359
|
+
}
|
|
360
|
+
if (["control", "commitment", "complementary-control"].includes(record.type)) {
|
|
361
|
+
decision.nonApplicableDecision = ["not-applicable", "externally-managed", "zero-population"]
|
|
362
|
+
.includes(record.applicabilityReview?.decision) ? record.applicabilityReview.decision : null;
|
|
363
|
+
}
|
|
364
|
+
return decision;
|
|
365
|
+
}
|
|
324
366
|
const source = record.type === "program" ? {
|
|
325
367
|
id: record.id,
|
|
326
368
|
type: record.type,
|
package/src/validate.js
CHANGED
|
@@ -6,7 +6,8 @@ import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
|
6
6
|
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
7
7
|
import {
|
|
8
8
|
collectionRevision,
|
|
9
|
-
|
|
9
|
+
collectionReviewDecisionMatches,
|
|
10
|
+
reviewedControlConflictIds
|
|
10
11
|
} from "./collection-revision.js";
|
|
11
12
|
import { isSafeGitName } from "./git-name.js";
|
|
12
13
|
import { getFileBufferAtRevision, getFileObjectIdAtRevision, getFilePathAtRevision, getGitSummary, getWorkingFileObjectId, hasGitRevision, isDataHistoryAncestor } from "./git.js";
|
|
@@ -1276,10 +1277,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1276
1277
|
? record.authoritativeComponentId || record.authoritativeSystemId
|
|
1277
1278
|
: null
|
|
1278
1279
|
});
|
|
1279
|
-
const current =
|
|
1280
|
+
const current = collectionReviewDecisionMatches(
|
|
1280
1281
|
loaded,
|
|
1281
1282
|
record.resourceType,
|
|
1282
|
-
record
|
|
1283
|
+
record,
|
|
1284
|
+
scopedCollectionRecords(loaded, record.resourceType, program),
|
|
1283
1285
|
{
|
|
1284
1286
|
program,
|
|
1285
1287
|
authoritativeSourceId: record.decision === "externally-managed"
|
|
@@ -1288,6 +1290,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1288
1290
|
currentRevision
|
|
1289
1291
|
}
|
|
1290
1292
|
);
|
|
1293
|
+
const originalPopulationCurrent = current && revisionsMatch("collection", record.collectionRevision, currentRevision);
|
|
1291
1294
|
if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
|
|
1292
1295
|
diagnostics.push(error(
|
|
1293
1296
|
"invalid-retention-schedule-review-scope",
|
|
@@ -1295,7 +1298,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1295
1298
|
"The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
|
|
1296
1299
|
));
|
|
1297
1300
|
}
|
|
1298
|
-
if (
|
|
1301
|
+
if (originalPopulationCurrent && incompleteProposals.length) {
|
|
1299
1302
|
diagnostics.push(error(
|
|
1300
1303
|
"incomplete-collection-review-proposals",
|
|
1301
1304
|
path,
|
|
@@ -1303,19 +1306,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1303
1306
|
));
|
|
1304
1307
|
}
|
|
1305
1308
|
if (current && record.resourceType === "control") {
|
|
1306
|
-
const
|
|
1307
|
-
const controlIds = new Set(controls.map(({ id }) => id));
|
|
1308
|
-
const conflictIds = new Set();
|
|
1309
|
-
for (const control of controls) {
|
|
1310
|
-
for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
|
|
1311
|
-
}
|
|
1312
|
-
for (const obligation of loaded.resources.filter((candidate) => (
|
|
1313
|
-
candidate.type === "obligation"
|
|
1314
|
-
&& candidate.status === "active"
|
|
1315
|
-
&& (candidate.controlIds || []).some((id) => controlIds.has(id))
|
|
1316
|
-
))) {
|
|
1317
|
-
for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
|
|
1318
|
-
}
|
|
1309
|
+
const conflictIds = new Set(reviewedControlConflictIds(loaded, record));
|
|
1319
1310
|
const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
|
|
1320
1311
|
if (conflictedReviewers.length) {
|
|
1321
1312
|
diagnostics.push(error(
|
|
@@ -1369,7 +1360,8 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1369
1360
|
|| record.coverage.on !== record.reviewedOn
|
|
1370
1361
|
|| !isRfc3339Timestamp(record.knowledgeCutoffAt)
|
|
1371
1362
|
|| currentCalendarDate(loaded.workspace.timezone, new Date(record.knowledgeCutoffAt)) !== record.reviewedOn
|
|
1372
|
-
|| (current &&
|
|
1363
|
+
|| (current && (record.resourceType === "retention-schedule-item" || Number(model.modelVersion) < 11)
|
|
1364
|
+
&& JSON.stringify([...record.populationResourceIds].sort()) !== JSON.stringify(currentPopulationIds))
|
|
1373
1365
|
)) {
|
|
1374
1366
|
diagnostics.push(error(
|
|
1375
1367
|
"invalid-current-collection-review",
|
|
@@ -1431,14 +1423,15 @@ function collectionReviewIsCurrent(loaded, resourceType, program) {
|
|
|
1431
1423
|
));
|
|
1432
1424
|
if (!review || !(loaded.model.collectionReviews?.[resourceType]?.decisions || ["complete"]).includes(review.decision)) return false;
|
|
1433
1425
|
const records = scopedCollectionRecords(loaded, resourceType, program);
|
|
1434
|
-
const revisionMatches =
|
|
1426
|
+
const revisionMatches = collectionReviewDecisionMatches(loaded, resourceType, review, records, { program });
|
|
1435
1427
|
if (!modelSupports(loaded.model, "temporal-collection-reviews")) return revisionMatches;
|
|
1436
1428
|
return revisionMatches
|
|
1437
1429
|
&& review.coverage?.kind === "as-of"
|
|
1438
1430
|
&& review.coverage.on === review.reviewedOn
|
|
1439
1431
|
&& Boolean(review.knowledgeCutoffAt)
|
|
1440
1432
|
&& Array.isArray(review.populationResourceIds)
|
|
1441
|
-
&&
|
|
1433
|
+
&& (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
|
|
1434
|
+
|| JSON.stringify([...review.populationResourceIds].sort()) === JSON.stringify(records.map(({ id }) => id).sort()));
|
|
1442
1435
|
}
|
|
1443
1436
|
|
|
1444
1437
|
function validateCollectionReviewSet(resources, pathById, diagnostics) {
|
package/src/web.js
CHANGED
|
@@ -1239,7 +1239,7 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1239
1239
|
const current = assessment.status === "current";
|
|
1240
1240
|
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1241
1241
|
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
1242
|
-
const proposalBlocker = assessment.incompleteRecordProposals?.length
|
|
1242
|
+
const proposalBlocker = !current && assessment.incompleteRecordProposals?.length
|
|
1243
1243
|
? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
|
|
1244
1244
|
: null;
|
|
1245
1245
|
const reviewBlocker = scheduleBlocker || proposalBlocker;
|
|
@@ -1263,7 +1263,7 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1263
1263
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
1264
1264
|
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
|
|
1265
1265
|
const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
1266
|
-
const proposalSummary = assessment.recordProposals?.length
|
|
1266
|
+
const proposalSummary = !current && assessment.recordProposals?.length
|
|
1267
1267
|
? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
|
|
1268
1268
|
: "";
|
|
1269
1269
|
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|