filegrc 0.13.5 → 0.14.0
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/README.md +4 -4
- package/model/index.js +6 -5
- package/model/v11.json +12200 -0
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/collection-review.js +59 -0
- package/src/collection-scope.js +106 -3
- package/src/document-activation.js +28 -4
- package/src/model-migration.js +70 -2
- package/src/policy-activation.js +16 -2
- package/src/program-path.js +6 -1
- package/src/program-readiness.js +21 -10
- package/src/validate.js +24 -1
- package/src/web.js +90 -10
- package/src/workflow.js +1 -3
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -553,7 +553,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
553
553
|
: record.type === "requirement-mapping"
|
|
554
554
|
? [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]
|
|
555
555
|
: null;
|
|
556
|
-
if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule
|
|
556
|
+
if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule records and Requirement Mappings.");
|
|
557
557
|
const revisions = await resourceReviewRevisions(loaded, dependencyIds);
|
|
558
558
|
const result = {
|
|
559
559
|
resource: { type: record.type, id: record.id, title: record.title },
|
package/src/collection-review.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createResourceId } from "./id.js";
|
|
|
11
11
|
import { loadWorkspace } from "./workspace.js";
|
|
12
12
|
import { resolveProgram } from "./program.js";
|
|
13
13
|
import { currentCalendarDate } from "./time.js";
|
|
14
|
+
import { currentPartyPeople } from "./parties.js";
|
|
14
15
|
|
|
15
16
|
export { collectionRevision };
|
|
16
17
|
|
|
@@ -63,11 +64,19 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
63
64
|
currentRevision
|
|
64
65
|
}
|
|
65
66
|
);
|
|
67
|
+
const reviewerEligibility = resourceType === "control"
|
|
68
|
+
? assessControlReviewers(loaded, records)
|
|
69
|
+
: null;
|
|
70
|
+
const reviewersEligible = !reviewerEligibility || Boolean(
|
|
71
|
+
review?.reviewedByIds?.length
|
|
72
|
+
&& review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
|
|
73
|
+
);
|
|
66
74
|
const complete = Boolean(
|
|
67
75
|
review?.status === "active"
|
|
68
76
|
&& allowedDecisions.includes(review.decision)
|
|
69
77
|
&& revisionMatches
|
|
70
78
|
&& temporalReview
|
|
79
|
+
&& reviewersEligible
|
|
71
80
|
);
|
|
72
81
|
const stale = Boolean(
|
|
73
82
|
review?.status === "active"
|
|
@@ -86,8 +95,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
86
95
|
collectionRevision: currentRevision,
|
|
87
96
|
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
88
97
|
complete,
|
|
98
|
+
...(reviewerEligibility || {}),
|
|
89
99
|
message: complete
|
|
90
100
|
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
101
|
+
: revisionMatches && review?.status === "active" && !reviewersEligible
|
|
102
|
+
? "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
|
|
91
103
|
: stale
|
|
92
104
|
? `${configuration.title} changed after the last confirmation. Review the current records again.`
|
|
93
105
|
: !records.length && !allowsEmptyCollection
|
|
@@ -101,6 +113,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
101
113
|
const program = resolveProgram(loaded, options.programId);
|
|
102
114
|
const resourceType = requiredType(loaded, options.resourceType);
|
|
103
115
|
const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
|
|
116
|
+
await requireControlReviewReady(loaded, resourceType, program.id);
|
|
104
117
|
const allowedDecisions = assessment.configuration.decisions || ["complete"];
|
|
105
118
|
const now = options.now ? new Date(options.now) : new Date();
|
|
106
119
|
if (Number.isNaN(now.getTime())) throw new Error("A valid Collection Review time is required.");
|
|
@@ -141,6 +154,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
141
154
|
const program = resolveProgram(loaded, options.programId);
|
|
142
155
|
const resourceType = requiredType(loaded, options.resourceType);
|
|
143
156
|
const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
|
|
157
|
+
await requireControlReviewReady(loaded, resourceType, program.id);
|
|
144
158
|
const configuration = assessment.configuration;
|
|
145
159
|
const decision = String(options.decision || "").trim();
|
|
146
160
|
const rationale = String(options.rationale || "").trim();
|
|
@@ -178,6 +192,13 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
178
192
|
if (!rationale || !reviewedByIds.length || !reviewedOn) {
|
|
179
193
|
throw new Error(`${configuration.title} review needs review notes, a reviewer, and a review date.`);
|
|
180
194
|
}
|
|
195
|
+
if (resourceType === "control") {
|
|
196
|
+
const eligible = new Set(assessment.eligibleReviewerIds || []);
|
|
197
|
+
const conflicts = reviewedByIds.filter((id) => !eligible.has(id));
|
|
198
|
+
if (conflicts.length) {
|
|
199
|
+
throw new Error(`Control collection review needs a reviewer who does not own an included Control or its enabled Obligation: ${conflicts.join(", ")}.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
181
202
|
if (temporalReviews) {
|
|
182
203
|
if (reviewedOn !== today) {
|
|
183
204
|
throw new Error("A current Collection Review must use today's workspace-local review date. Reconstruct an older population from Git history instead of backdating today's files.");
|
|
@@ -270,6 +291,44 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
270
291
|
};
|
|
271
292
|
}
|
|
272
293
|
|
|
294
|
+
async function requireControlReviewReady(loaded, resourceType, programId) {
|
|
295
|
+
if (resourceType !== "control") return;
|
|
296
|
+
const { assessProgramReadiness } = await import("./program-readiness.js");
|
|
297
|
+
const readiness = await assessProgramReadiness(loaded, { programId });
|
|
298
|
+
const earlierItems = readiness.stages
|
|
299
|
+
.find(({ id }) => id === "controls")
|
|
300
|
+
?.items.filter(({ id }) => (
|
|
301
|
+
id !== "collection-review-control"
|
|
302
|
+
&& !id.startsWith("document-activation-")
|
|
303
|
+
&& !id.startsWith("training-activation-")
|
|
304
|
+
&& !id.startsWith("policy-activation-")
|
|
305
|
+
)) || [];
|
|
306
|
+
if (!earlierItems.length || earlierItems.some(({ status }) => status !== "complete")) {
|
|
307
|
+
throw new Error("Complete the Step 3 implementation work before recording the Control collection review. Program content activation follows this review.");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function assessControlReviewers(loaded, controls) {
|
|
312
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
313
|
+
const controlIds = new Set(controls.map(({ id }) => id));
|
|
314
|
+
const conflictIds = new Set();
|
|
315
|
+
for (const control of controls) {
|
|
316
|
+
for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
|
|
317
|
+
}
|
|
318
|
+
for (const obligation of loaded.resources.filter((record) => (
|
|
319
|
+
record.type === "obligation"
|
|
320
|
+
&& record.status === "active"
|
|
321
|
+
&& (record.controlIds || []).some((id) => controlIds.has(id))
|
|
322
|
+
))) {
|
|
323
|
+
for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
|
|
324
|
+
}
|
|
325
|
+
const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
|
|
326
|
+
return {
|
|
327
|
+
eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
|
|
328
|
+
reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
273
332
|
function sameIds(left, right) {
|
|
274
333
|
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
275
334
|
}
|
package/src/collection-scope.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { modelSupports } from "../model/index.js";
|
|
2
2
|
import { programComponents, selectedRequirementIds } from "./program.js";
|
|
3
|
+
import { retentionReviewResourceIds } from "./retention.js";
|
|
4
|
+
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
3
5
|
|
|
4
6
|
export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
5
7
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
@@ -35,6 +37,7 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
35
37
|
const selected = {
|
|
36
38
|
system: systemIds,
|
|
37
39
|
component: componentIds,
|
|
40
|
+
control: controlIds,
|
|
38
41
|
framework: new Set(scopedProgram.frameworkIds || []),
|
|
39
42
|
"complementary-control": new Set(loaded.resources.filter((record) => (
|
|
40
43
|
record.type === "complementary-control"
|
|
@@ -51,7 +54,9 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
51
54
|
)).map(({ id }) => id))
|
|
52
55
|
}[resourceType];
|
|
53
56
|
return loaded.resources.filter((record) => (
|
|
54
|
-
record.type === resourceType
|
|
57
|
+
record.type === resourceType
|
|
58
|
+
&& (!selected || selected.has(record.id))
|
|
59
|
+
&& (resourceType !== "control" || record.status === "implemented")
|
|
55
60
|
));
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -114,6 +119,20 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
114
119
|
if (record) records.set(record.id, record);
|
|
115
120
|
}
|
|
116
121
|
};
|
|
122
|
+
const addPartyIds = (ids) => {
|
|
123
|
+
const pending = [...(ids || [])];
|
|
124
|
+
const visited = new Set();
|
|
125
|
+
while (pending.length) {
|
|
126
|
+
const id = pending.shift();
|
|
127
|
+
if (!id || visited.has(id)) continue;
|
|
128
|
+
visited.add(id);
|
|
129
|
+
const record = byId.get(id);
|
|
130
|
+
if (!record) continue;
|
|
131
|
+
records.set(record.id, record);
|
|
132
|
+
if (record.type === "team") pending.push(...(record.memberIds || []), ...(record.chairIds || []));
|
|
133
|
+
if (record.type === "appointment") pending.push(record.holderId);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
117
136
|
|
|
118
137
|
if (resourceType === "framework") {
|
|
119
138
|
addIds(program?.systemIds);
|
|
@@ -139,6 +158,66 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
139
158
|
addIds((record.informationUses || []).map(({ informationTypeId }) => informationTypeId));
|
|
140
159
|
}
|
|
141
160
|
}
|
|
161
|
+
if (resourceType === "control") {
|
|
162
|
+
const obligations = loaded.resources.filter((record) => (
|
|
163
|
+
record.type === "obligation"
|
|
164
|
+
&& record.status === "active"
|
|
165
|
+
&& (record.controlIds || []).some((id) => reviewedIds.has(id))
|
|
166
|
+
));
|
|
167
|
+
const controlCodes = new Set(reviewed.map(({ code }) => code).filter(Boolean));
|
|
168
|
+
const sourceFamilyIds = new Set((loaded.model.evidenceSourceFamilies || [])
|
|
169
|
+
.filter((family) => (family.controlCodes || []).some((code) => controlCodes.has(code)))
|
|
170
|
+
.map(({ id }) => id));
|
|
171
|
+
const sourceCoverage = loaded.resources.filter((record) => (
|
|
172
|
+
record.type === "source-coverage"
|
|
173
|
+
&& record.status !== "retired"
|
|
174
|
+
&& sourceFamilyIds.has(record.sourceFamilyId)
|
|
175
|
+
));
|
|
176
|
+
const policyIds = new Set(reviewed.flatMap((record) => record.policyIds || []));
|
|
177
|
+
const policies = loaded.resources.filter((record) => (
|
|
178
|
+
record.type === "policy"
|
|
179
|
+
&& policyIds.has(record.id)
|
|
180
|
+
&& record.programRole !== "conditional"
|
|
181
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
182
|
+
));
|
|
183
|
+
const governedIds = new Set([
|
|
184
|
+
...policies.flatMap((record) => record.relatedDocumentIds || []),
|
|
185
|
+
...obligations.flatMap((record) => [
|
|
186
|
+
...(record.scopeResourceIds || []),
|
|
187
|
+
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
188
|
+
])
|
|
189
|
+
]);
|
|
190
|
+
const governedContent = loaded.resources.filter((record) => (
|
|
191
|
+
["document", "training"].includes(record.type)
|
|
192
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
193
|
+
&& (governedIds.has(record.id) || (record.controlIds || []).some((id) => reviewedIds.has(id)))
|
|
194
|
+
&& (record.type !== "document" || !documentIsAuditSpecific(record, loaded.model))
|
|
195
|
+
));
|
|
196
|
+
addIds(program?.systemIds);
|
|
197
|
+
addIds(obligations.map(({ id }) => id));
|
|
198
|
+
addIds(sourceCoverage.map(({ id }) => id));
|
|
199
|
+
addIds(policies.map(({ id }) => id));
|
|
200
|
+
addIds(governedContent.map(({ id }) => id));
|
|
201
|
+
for (const record of reviewed) {
|
|
202
|
+
addIds(record.componentIds);
|
|
203
|
+
addIds(record.evidenceSourceComponentIds);
|
|
204
|
+
addPartyIds(record.ownerIds);
|
|
205
|
+
}
|
|
206
|
+
for (const obligation of obligations) addPartyIds(obligation.ownerIds);
|
|
207
|
+
for (const record of [...policies, ...governedContent]) {
|
|
208
|
+
addPartyIds([...(record.ownerIds || []), ...(record.approverIds || []), ...(record.approvedByIds || [])]);
|
|
209
|
+
}
|
|
210
|
+
for (const coverage of sourceCoverage) {
|
|
211
|
+
addIds([coverage.componentId]);
|
|
212
|
+
addIds(coverage.readinessTestEvidenceIds);
|
|
213
|
+
addIds(coverage.retentionScheduleItemIds);
|
|
214
|
+
addPartyIds([...(coverage.ownerIds || []), ...(coverage.retrieverIds || [])]);
|
|
215
|
+
for (const rule of (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean)) {
|
|
216
|
+
addIds(retentionReviewResourceIds(rule, loaded));
|
|
217
|
+
addPartyIds([...(rule.ownerIds || []), ...(rule.approvedByIds || [])]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
142
221
|
if (resourceType === "information-type" && !legacy) {
|
|
143
222
|
addIds(program?.systemIds);
|
|
144
223
|
addIds(programComponents(loaded, program || {}).map(({ id }) => id));
|
|
@@ -202,12 +281,20 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
202
281
|
if (resourceType === "system") {
|
|
203
282
|
return { ...common, systemIds: sorted(program?.systemIds) };
|
|
204
283
|
}
|
|
205
|
-
if (resourceType === "component" || resourceType === "complementary-control") {
|
|
206
|
-
|
|
284
|
+
if (resourceType === "component" || resourceType === "complementary-control" || resourceType === "control") {
|
|
285
|
+
const scope = {
|
|
207
286
|
...common,
|
|
208
287
|
systemIds: sorted(program?.systemIds),
|
|
209
288
|
controlIds: sorted(program?.controlIds)
|
|
210
289
|
};
|
|
290
|
+
return resourceType === "control" ? {
|
|
291
|
+
...scope,
|
|
292
|
+
assuranceGoal: program?.assuranceGoal ?? null,
|
|
293
|
+
frameworkIds: sorted(program?.frameworkIds),
|
|
294
|
+
requirementIds: sorted(selectedRequirementIds(program || {}, loaded.model)),
|
|
295
|
+
requirementApplicability: program?.requirementApplicability || [],
|
|
296
|
+
riskMethodology: program?.riskMethodology || null
|
|
297
|
+
} : scope;
|
|
211
298
|
}
|
|
212
299
|
return common;
|
|
213
300
|
}
|
|
@@ -244,6 +331,21 @@ const dependencyFields = {
|
|
|
244
331
|
classification: ["id", "type", "status", "rank", "description", "handlingRequirements"],
|
|
245
332
|
"information-type": ["id", "type", "status", "classificationId", "description"]
|
|
246
333
|
},
|
|
334
|
+
control: {
|
|
335
|
+
system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
336
|
+
component: ["id", "type", "status", "componentKind", "description", "ownerIds", "systemUses", "evidenceSourceKinds", "evidenceOwnerIds"],
|
|
337
|
+
obligation: ["id", "type", "status", "activityType", "scheduleMode", "ownerIds", "activeRuleId", "policyIds", "controlIds", "ruleIds"],
|
|
338
|
+
"source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "retrieverIds", "collectionCadence", "retentionScheduleItemIds", "reconciliationMethod", "validFrom", "validThrough", "applicabilityReview", "readinessTestEvidenceIds", "ownerIds", "componentId"],
|
|
339
|
+
evidence: ["id", "type", "status", "artifactKind", "readinessTest", "retrievalResult", "accessConfirmed", "coveredSourceFamilyIds", "sourceComponentId", "componentIds", "systemIds", "collectedOn", "collectorIds", "verifierIds", "verifiedOn"],
|
|
340
|
+
"retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
|
|
341
|
+
document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
342
|
+
policy: ["id", "type", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "relatedDocumentIds", "controlIds"],
|
|
343
|
+
training: ["id", "type", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
344
|
+
"information-type": ["id", "type", "status", "classificationId", "description"],
|
|
345
|
+
person: ["id", "type", "status", "affiliation", "jobTitle"],
|
|
346
|
+
team: ["id", "type", "status", "memberIds", "chairIds"],
|
|
347
|
+
appointment: ["id", "type", "status", "appointmentKind", "scopeResourceIds", "holderId", "startsOn", "endsOn"]
|
|
348
|
+
},
|
|
247
349
|
"information-type": {
|
|
248
350
|
system: ["id", "type", "status", "informationTypeIds"],
|
|
249
351
|
component: ["id", "type", "status", "systemUses", "informationUses"],
|
|
@@ -279,6 +381,7 @@ const dependencyContentTypes = {
|
|
|
279
381
|
framework: new Set(["system"]),
|
|
280
382
|
vendor: new Set(["document"]),
|
|
281
383
|
component: new Set(["system"]),
|
|
384
|
+
control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
|
|
282
385
|
"complementary-control": new Set(["system", "control", "document", "component"])
|
|
283
386
|
};
|
|
284
387
|
|
|
@@ -10,12 +10,18 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
10
10
|
export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) {
|
|
11
11
|
const loaded = await loadWorkspace(input);
|
|
12
12
|
requireDocumentLifecycle(loaded);
|
|
13
|
-
const
|
|
13
|
+
const readiness = options.auditId ? null : await assessProgramReadiness(loaded, { programId: options.programId });
|
|
14
|
+
const oversightReady = options.auditId || controlOversightComplete(loaded, readiness);
|
|
15
|
+
const candidates = oversightReady
|
|
16
|
+
? await activationCandidates(loaded, { ...options, documentsOnly: true })
|
|
17
|
+
: [];
|
|
14
18
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
15
19
|
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
16
20
|
return {
|
|
17
21
|
available: candidates.length > 0,
|
|
18
|
-
message:
|
|
22
|
+
message: !oversightReady
|
|
23
|
+
? "Complete the current Control collection review before Program Content Activation."
|
|
24
|
+
: candidates.length
|
|
19
25
|
? `${candidates.length} approved ${candidates.length === 1 ? "Document is" : "Documents are"} ready to activate.`
|
|
20
26
|
: `No ${options.auditId ? "engagement Document" : "program Document"} is ready to activate. Review the current readiness actions first.`,
|
|
21
27
|
nextCommand: candidates.length ? null : options.auditId
|
|
@@ -34,6 +40,12 @@ export async function scaffoldDocumentActivation(input = process.cwd(), options
|
|
|
34
40
|
export async function planDocumentActivation(input = process.cwd(), options = {}) {
|
|
35
41
|
const loaded = await loadWorkspace(input);
|
|
36
42
|
requireDocumentLifecycle(loaded);
|
|
43
|
+
if (!options.auditId && modelSupports(loaded.model, "control-collection-oversight")) {
|
|
44
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
45
|
+
if (!controlOversightComplete(loaded, readiness)) {
|
|
46
|
+
throw new Error("Complete the current Control collection review before Program Content Activation.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
const resourceIds = [...new Set((options.resourceIds || options.documentIds || []).map(String))];
|
|
38
50
|
if (!resourceIds.length) throw new Error("Governed-content activation needs at least one approved program Document or Training record.");
|
|
39
51
|
const activatedByIds = [...new Set((options.activatedByIds || []).map(String))];
|
|
@@ -118,6 +130,12 @@ export async function planDocumentActivation(input = process.cwd(), options = {}
|
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
function controlOversightComplete(loaded, readiness) {
|
|
134
|
+
if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true;
|
|
135
|
+
return readiness.stages.find(({ id }) => id === "controls")?.items
|
|
136
|
+
.find(({ id }) => id === "collection-review-control")?.status === "complete";
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
async function activationCandidates(loaded, options) {
|
|
122
140
|
if (options.auditId) {
|
|
123
141
|
return (await assessAuditDocumentActivations(loaded, {
|
|
@@ -150,12 +168,18 @@ export async function scaffoldGovernedContentActivation(input = process.cwd(), o
|
|
|
150
168
|
if (!modelSupports(loaded.model, "governed-training-activation")) {
|
|
151
169
|
throw new Error("Unified governed-content activation requires a model v6 or later workspace.");
|
|
152
170
|
}
|
|
153
|
-
const
|
|
171
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
172
|
+
const oversightReady = controlOversightComplete(loaded, readiness);
|
|
173
|
+
const candidates = oversightReady
|
|
174
|
+
? await activationCandidates(loaded, { ...options, auditId: undefined })
|
|
175
|
+
: [];
|
|
154
176
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
155
177
|
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
156
178
|
return {
|
|
157
179
|
available: candidates.length > 0,
|
|
158
|
-
message:
|
|
180
|
+
message: !oversightReady
|
|
181
|
+
? "Complete the current Control collection review before Program Content Activation."
|
|
182
|
+
: candidates.length
|
|
159
183
|
? `${candidates.length} approved governed-content ${candidates.length === 1 ? "record is" : "records are"} ready to activate.`
|
|
160
184
|
: "No program Document or Training record is ready to activate. Review the current readiness actions first.",
|
|
161
185
|
nextCommand: candidates.length ? null : "npx filegrc program-path --next --json",
|
package/src/model-migration.js
CHANGED
|
@@ -901,7 +901,8 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
901
901
|
: sourceVersion === "6" ? "7"
|
|
902
902
|
: sourceVersion === "7" ? "8"
|
|
903
903
|
: sourceVersion === "8" ? "9"
|
|
904
|
-
:
|
|
904
|
+
: sourceVersion === "9" ? "10"
|
|
905
|
+
: ACTIVE_MODEL_VERSION;
|
|
905
906
|
if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
|
|
906
907
|
if (sourceVersion === "1" && requestedTarget === "2") {
|
|
907
908
|
return planV1ToV2Migration(input, options);
|
|
@@ -927,9 +928,12 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
927
928
|
if (sourceVersion === "8" && requestedTarget === "9") {
|
|
928
929
|
return planV8ToV9Migration(loaded);
|
|
929
930
|
}
|
|
930
|
-
if (sourceVersion === "9" && requestedTarget ===
|
|
931
|
+
if (sourceVersion === "9" && requestedTarget === "10") {
|
|
931
932
|
return planV9ToV10Migration(loaded);
|
|
932
933
|
}
|
|
934
|
+
if (sourceVersion === "10" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
935
|
+
return planV10ToV11Migration(loaded);
|
|
936
|
+
}
|
|
933
937
|
if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
934
938
|
throw new Error(
|
|
935
939
|
"Model v1 workspaces must migrate to model v2 first. "
|
|
@@ -2406,6 +2410,70 @@ async function planV9ToV10Migration(loaded) {
|
|
|
2406
2410
|
};
|
|
2407
2411
|
}
|
|
2408
2412
|
|
|
2413
|
+
async function planV10ToV11Migration(loaded) {
|
|
2414
|
+
if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
|
|
2415
|
+
const targetModel = loadModel("11");
|
|
2416
|
+
const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
2417
|
+
const automatic = [];
|
|
2418
|
+
const reviewRequired = [];
|
|
2419
|
+
const unsupported = [];
|
|
2420
|
+
const missing = [];
|
|
2421
|
+
const manualActions = [];
|
|
2422
|
+
const updates = [];
|
|
2423
|
+
for (const original of loaded.resources) {
|
|
2424
|
+
const record = structuredClone(original);
|
|
2425
|
+
let changed = false;
|
|
2426
|
+
if (record.type === "workspace") {
|
|
2427
|
+
record.dataModelVersion = "11";
|
|
2428
|
+
automatic.push(classifiedChange("automatic", record.id, "dataModelVersion", "Select model v11."));
|
|
2429
|
+
changed = true;
|
|
2430
|
+
}
|
|
2431
|
+
if (record.type === "control") {
|
|
2432
|
+
for (const field of ["implementationReviewedByIds", "implementationReviewedOn"]) {
|
|
2433
|
+
if (!Object.hasOwn(record, field)) continue;
|
|
2434
|
+
delete record[field];
|
|
2435
|
+
automatic.push(classifiedChange("automatic", record.id, field, "Remove the obsolete per-Control implementation review field. Control oversight now uses a periodic Collection Review."));
|
|
2436
|
+
changed = true;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
if (changed) updates.push(record);
|
|
2440
|
+
}
|
|
2441
|
+
const updatedById = new Map(updates.map((record) => [record.id, record]));
|
|
2442
|
+
const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
|
|
2443
|
+
await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
|
|
2444
|
+
for (const item of [...missing, ...manualActions]) {
|
|
2445
|
+
unsupported.push(classifiedChange("unsupported", item.resourceId, item.field, item.message));
|
|
2446
|
+
}
|
|
2447
|
+
return {
|
|
2448
|
+
schemaVersion: 2,
|
|
2449
|
+
sourceModelVersion: "10",
|
|
2450
|
+
targetModelVersion: "11",
|
|
2451
|
+
ready: unsupported.length === 0,
|
|
2452
|
+
missing,
|
|
2453
|
+
conflicts: [],
|
|
2454
|
+
manualActions,
|
|
2455
|
+
classifications: { automatic, reviewRequired, unsupported },
|
|
2456
|
+
notes: [
|
|
2457
|
+
"Control owners can mark Controls implemented without a separate per-Control implementation reviewer.",
|
|
2458
|
+
"Periodic Control oversight is recorded as a Git-bound Collection Review over the Program's selected Controls.",
|
|
2459
|
+
"Policy, Document, and Training approval independence remains unchanged."
|
|
2460
|
+
],
|
|
2461
|
+
migrationReport: {},
|
|
2462
|
+
summary: { create: 0, update: updates.length, automatic: automatic.length, reviewRequired: 0, unsupported: unsupported.length },
|
|
2463
|
+
fileDiff: {
|
|
2464
|
+
create: [],
|
|
2465
|
+
update: updates.map((record) => ({ type: record.type, id: record.id, before: loaded.resources.find(({ id }) => id === record.id), after: record }))
|
|
2466
|
+
},
|
|
2467
|
+
changes: {
|
|
2468
|
+
create: [],
|
|
2469
|
+
update: updates,
|
|
2470
|
+
expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
|
|
2471
|
+
validateWholeWorkspace: true,
|
|
2472
|
+
targetModelVersion: "11"
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2409
2477
|
function normalizeDocumentScopeDecision(value) {
|
|
2410
2478
|
const candidate = typeof value === "object" && value
|
|
2411
2479
|
? value.workflowScope || value.scope
|
package/src/policy-activation.js
CHANGED
|
@@ -3,17 +3,21 @@ import { serializeWorkspaceMutation } from "./mutation.js";
|
|
|
3
3
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
4
4
|
import { currentCalendarDate } from "./time.js";
|
|
5
5
|
import { loadWorkspace } from "./workspace.js";
|
|
6
|
+
import { modelSupports } from "../model/index.js";
|
|
6
7
|
|
|
7
8
|
export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) {
|
|
8
9
|
const loaded = await loadWorkspace(input);
|
|
9
10
|
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
10
|
-
const
|
|
11
|
+
const oversightReady = controlOversightComplete(loaded, readiness);
|
|
12
|
+
const approved = (oversightReady ? readiness.policyActivations : []).filter(({ state }) => (
|
|
11
13
|
["approved-implementation-pending", "ready-to-activate"].includes(state)
|
|
12
14
|
));
|
|
13
15
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
14
16
|
return {
|
|
15
17
|
available: approved.length > 0,
|
|
16
|
-
message:
|
|
18
|
+
message: !oversightReady
|
|
19
|
+
? "Complete the current Control collection review before Program Content Activation."
|
|
20
|
+
: approved.length
|
|
17
21
|
? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
|
|
18
22
|
: "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
|
|
19
23
|
nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
|
|
@@ -26,6 +30,10 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
|
|
|
26
30
|
|
|
27
31
|
export async function planPolicyActivation(input = process.cwd(), options = {}) {
|
|
28
32
|
const loaded = await loadWorkspace(input);
|
|
33
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
34
|
+
if (!controlOversightComplete(loaded, readiness)) {
|
|
35
|
+
throw new Error("Complete the current Control collection review before Program Content Activation.");
|
|
36
|
+
}
|
|
29
37
|
const policyIds = [...new Set((options.policyIds || []).map(String))];
|
|
30
38
|
if (!policyIds.length) throw new Error("Policy activation needs at least one approved Policy.");
|
|
31
39
|
const effectiveOn = String(options.effectiveOn || "").trim();
|
|
@@ -67,6 +75,12 @@ export async function planPolicyActivation(input = process.cwd(), options = {})
|
|
|
67
75
|
};
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
function controlOversightComplete(loaded, readiness) {
|
|
79
|
+
if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true;
|
|
80
|
+
return readiness.stages.find(({ id }) => id === "controls")?.items
|
|
81
|
+
.find(({ id }) => id === "collection-review-control")?.status === "complete";
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
export async function activatePolicies(input = process.cwd(), options = {}) {
|
|
71
85
|
if (options.confirmed !== true) throw new Error("Review the Policy activation cutover and confirm the write.");
|
|
72
86
|
return serializeWorkspaceMutation(input, async (root) => {
|
package/src/program-path.js
CHANGED
|
@@ -129,7 +129,11 @@ export const PROGRAM_PATH = [
|
|
|
129
129
|
description: "Finish controls and their evidence sources",
|
|
130
130
|
summary: "Describe each Control and connect its evidence source.",
|
|
131
131
|
sections: [
|
|
132
|
-
{ id: "
|
|
132
|
+
{ id: "controls", title: "Controls", description: "Implement the selected Controls and connect their scope, procedures, mappings, and operation patterns.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components."], types: ["control"], defaultOpen: true },
|
|
133
|
+
{ id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Review whether an in-scope Control depends on a customer or carved-out provider action."], types: ["complementary-control"], defaultOpen: true },
|
|
134
|
+
{ id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["Confirm each source Component is active, has the right evidence-source role, names current access owners, and includes repeatable retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
|
|
135
|
+
{ id: "retention", title: "Retention Schedule", description: "Review each retention rule against current information uses and approved sources.", steps: ["Confirm scope, cutoff, period, disposition, sources, owner, and approval for each rule."], types: ["retention-schedule-item"], defaultOpen: true },
|
|
136
|
+
{ id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Review and enable every required calendar and event schedule."], types: ["obligation"], defaultOpen: true }
|
|
133
137
|
],
|
|
134
138
|
resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
|
|
135
139
|
commands: [
|
|
@@ -142,6 +146,7 @@ export const PROGRAM_PATH = [
|
|
|
142
146
|
"filegrc list obligation --json",
|
|
143
147
|
"filegrc review-collection component --scaffold",
|
|
144
148
|
"filegrc review-collection complementary-control --scaffold",
|
|
149
|
+
"filegrc review-collection control --scaffold",
|
|
145
150
|
"filegrc activate-content --scaffold",
|
|
146
151
|
"filegrc activate-policies --scaffold",
|
|
147
152
|
"filegrc evidence-map --json",
|
package/src/program-readiness.js
CHANGED
|
@@ -64,7 +64,6 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
64
64
|
.filter(({ resourceType }) => resourceType === "retention-schedule-item")
|
|
65
65
|
.map(collectionReviewReadinessItem));
|
|
66
66
|
const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
|
|
67
|
-
controlStage.items.push(...governedContent.items);
|
|
68
67
|
const policyActivations = await assessPolicyActivations(
|
|
69
68
|
requiredPolicies(scope, byId),
|
|
70
69
|
scope.controls,
|
|
@@ -74,8 +73,22 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
74
73
|
asOf,
|
|
75
74
|
loaded.model
|
|
76
75
|
);
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
const controlOversight = collectionReviews.find(({ resourceType }) => resourceType === "control");
|
|
77
|
+
const oversightEligible = Boolean(controlOversight && controlOversightEligible(controlStage.items));
|
|
78
|
+
if (oversightEligible) {
|
|
79
|
+
controlStage.items.push(collectionReviewReadinessItem(controlOversight));
|
|
80
|
+
}
|
|
81
|
+
const oversightCurrent = oversightEligible && controlOversight.complete === true;
|
|
82
|
+
controlStage.items.push(...governedContent.items.map((item) => oversightCurrent || item.status === "complete"
|
|
83
|
+
? item
|
|
84
|
+
: { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` }));
|
|
85
|
+
controlStage.items.push(...policyActivations.map((assessment) => {
|
|
86
|
+
const item = policyActivationItem(assessment);
|
|
87
|
+
return oversightCurrent || item.status === "complete"
|
|
88
|
+
? item
|
|
89
|
+
: { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
|
|
90
|
+
}));
|
|
91
|
+
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
|
|
79
92
|
const scopeReadinessStage = scopeStage(
|
|
80
93
|
program,
|
|
81
94
|
scope,
|
|
@@ -166,6 +179,10 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
166
179
|
};
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
export function controlOversightEligible(items) {
|
|
183
|
+
return items.length > 0 && items.every(({ status }) => status === "complete");
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
export async function assessEvidenceMap(input, options = {}) {
|
|
170
187
|
const readiness = await assessProgramReadiness(input, options);
|
|
171
188
|
const evidenceItems = readiness.stages
|
|
@@ -1300,12 +1317,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
1300
1317
|
implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
|
|
1301
1318
|
...(model.resources.control?.fields?.procedureRevision ? {
|
|
1302
1319
|
procedureRevision: Boolean(control.procedureRevision),
|
|
1303
|
-
procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf)
|
|
1304
|
-
implementationReview: Boolean(
|
|
1305
|
-
control.implementationReviewedOn
|
|
1306
|
-
&& control.implementationReviewedOn <= asOf
|
|
1307
|
-
&& partiesIndependent(control.ownerIds, control.implementationReviewedByIds, byId)
|
|
1308
|
-
)
|
|
1320
|
+
procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf)
|
|
1309
1321
|
} : {}),
|
|
1310
1322
|
policyMapping: (control.policyIds || []).length > 0,
|
|
1311
1323
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
@@ -1699,7 +1711,6 @@ function controlCheckLabel(name) {
|
|
|
1699
1711
|
implementationDate: "implementation date",
|
|
1700
1712
|
procedureRevision: "effective procedure revision",
|
|
1701
1713
|
procedureEffective: "procedure effective date",
|
|
1702
|
-
implementationReview: "independent implementation review",
|
|
1703
1714
|
policyMapping: "policy mapping",
|
|
1704
1715
|
criteriaMapping: "criteria mapping",
|
|
1705
1716
|
workQueue: "running Obligation schedules"
|
package/src/validate.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./recurrence.js";
|
|
21
21
|
import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
22
22
|
import { obligationIsEnabled } from "./program-lifecycle.js";
|
|
23
|
-
import { partyPeople } from "./parties.js";
|
|
23
|
+
import { currentPartyPeople, partyPeople } from "./parties.js";
|
|
24
24
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
25
25
|
import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
|
|
26
26
|
import { recordTiming } from "./timing.js";
|
|
@@ -1273,6 +1273,29 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1273
1273
|
currentRevision
|
|
1274
1274
|
}
|
|
1275
1275
|
);
|
|
1276
|
+
if (current && record.resourceType === "control") {
|
|
1277
|
+
const controls = scopedCollectionRecords(loaded, "control", program);
|
|
1278
|
+
const controlIds = new Set(controls.map(({ id }) => id));
|
|
1279
|
+
const conflictIds = new Set();
|
|
1280
|
+
for (const control of controls) {
|
|
1281
|
+
for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
|
|
1282
|
+
}
|
|
1283
|
+
for (const obligation of loaded.resources.filter((candidate) => (
|
|
1284
|
+
candidate.type === "obligation"
|
|
1285
|
+
&& candidate.status === "active"
|
|
1286
|
+
&& (candidate.controlIds || []).some((id) => controlIds.has(id))
|
|
1287
|
+
))) {
|
|
1288
|
+
for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
|
|
1289
|
+
}
|
|
1290
|
+
const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
|
|
1291
|
+
if (conflictedReviewers.length) {
|
|
1292
|
+
diagnostics.push(error(
|
|
1293
|
+
"conflicted-control-collection-reviewer",
|
|
1294
|
+
path,
|
|
1295
|
+
`A Control collection reviewer cannot own an included Control or its enabled Obligation: ${conflictedReviewers.join(", ")}.`
|
|
1296
|
+
));
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1276
1299
|
if (modelSupports(model, "temporal-collection-reviews")) {
|
|
1277
1300
|
const temporalValues = [record.coverage, record.knowledgeCutoffAt, record.populationResourceIds];
|
|
1278
1301
|
const hasTemporalBinding = temporalValues.every((value) => value !== undefined && value !== null);
|