filegrc 0.14.0 → 0.15.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/model/index.js +2 -1
- package/model/v11.json +5 -5
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/collection-review.js +61 -2
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +45 -1
- package/src/program-path.js +59 -14
- package/src/program-readiness.js +16 -4
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +56 -1
- package/src/state.js +19 -22
- package/src/validate.js +46 -0
- package/src/web.js +342 -68
- package/src/workflow-history-integrity.js +5 -1
package/model/index.js
CHANGED
|
@@ -17,7 +17,8 @@ export const MODEL_CAPABILITY_VERSIONS = Object.freeze({
|
|
|
17
17
|
"temporal-collection-reviews": 9,
|
|
18
18
|
"reporting-routes": 9,
|
|
19
19
|
"reporting-route-sets": 10,
|
|
20
|
-
"control-collection-oversight": 11
|
|
20
|
+
"control-collection-oversight": 11,
|
|
21
|
+
"retention-schedule-approval": 11
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
export function modelSupports(modelOrVersion, capability) {
|
package/model/v11.json
CHANGED
|
@@ -643,12 +643,12 @@
|
|
|
643
643
|
]
|
|
644
644
|
},
|
|
645
645
|
"retention-schedule-item": {
|
|
646
|
-
"title": "Retention
|
|
647
|
-
"description": "
|
|
646
|
+
"title": "Data Retention Schedule",
|
|
647
|
+
"description": "Approve the schedule document and its structured rows as one governed schedule. Include only actual information uses and keep undecided organization values planned.",
|
|
648
648
|
"reviewPoints": [
|
|
649
|
-
"
|
|
650
|
-
"Confirm
|
|
651
|
-
"
|
|
649
|
+
"Review the Data Retention Schedule document, its Markdown, and every included structured row as one revision.",
|
|
650
|
+
"Confirm schedule coverage across the Systems, Components, Vendors, source families, logs, backups, audit records, and FileGRC records the organization actually uses.",
|
|
651
|
+
"Confirm every active item has an approved cutoff, retention period, disposition action, owner, approver, and exact reviewed source revisions; keep undecided behavior planned."
|
|
652
652
|
],
|
|
653
653
|
"decisions": [
|
|
654
654
|
"complete",
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -82,7 +82,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
82
82
|
instructions: RESOURCE_INSTRUCTIONS[type] || definition.description,
|
|
83
83
|
use: definition.description,
|
|
84
84
|
purpose: definition.description,
|
|
85
|
-
programStep: resourceProgramContext(type),
|
|
85
|
+
programStep: resourceProgramContext(type, loaded.model),
|
|
86
86
|
policyBasis: definition.guidance.policyBasis,
|
|
87
87
|
cadence: definition.guidance.cadence,
|
|
88
88
|
emptyState: definition.guidance.emptyState ?? null,
|
package/src/collection-review.js
CHANGED
|
@@ -12,6 +12,7 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
12
12
|
import { resolveProgram } from "./program.js";
|
|
13
13
|
import { currentCalendarDate } from "./time.js";
|
|
14
14
|
import { currentPartyPeople } from "./parties.js";
|
|
15
|
+
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
15
16
|
|
|
16
17
|
export { collectionRevision };
|
|
17
18
|
|
|
@@ -66,7 +67,14 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
66
67
|
);
|
|
67
68
|
const reviewerEligibility = resourceType === "control"
|
|
68
69
|
? assessControlReviewers(loaded, records)
|
|
69
|
-
:
|
|
70
|
+
: resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
71
|
+
? assessRetentionScheduleReviewers(loaded, records)
|
|
72
|
+
: null;
|
|
73
|
+
const approvalIssues = resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
74
|
+
? retentionScheduleApprovalIssues(loaded, program, records, {
|
|
75
|
+
informationTypesReviewed: assessCollectionReview(loaded, "information-type", options).complete
|
|
76
|
+
})
|
|
77
|
+
: [];
|
|
70
78
|
const reviewersEligible = !reviewerEligibility || Boolean(
|
|
71
79
|
review?.reviewedByIds?.length
|
|
72
80
|
&& review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
|
|
@@ -77,6 +85,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
77
85
|
&& revisionMatches
|
|
78
86
|
&& temporalReview
|
|
79
87
|
&& reviewersEligible
|
|
88
|
+
&& !approvalIssues.length
|
|
80
89
|
);
|
|
81
90
|
const stale = Boolean(
|
|
82
91
|
review?.status === "active"
|
|
@@ -96,10 +105,15 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
96
105
|
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
97
106
|
complete,
|
|
98
107
|
...(reviewerEligibility || {}),
|
|
108
|
+
...(approvalIssues.length ? { approvalIssues } : {}),
|
|
99
109
|
message: complete
|
|
100
110
|
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
111
|
+
: approvalIssues.length
|
|
112
|
+
? approvalIssues[0].message
|
|
101
113
|
: revisionMatches && review?.status === "active" && !reviewersEligible
|
|
102
|
-
?
|
|
114
|
+
? resourceType === "retention-schedule-item"
|
|
115
|
+
? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
|
|
116
|
+
: "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
|
|
103
117
|
: stale
|
|
104
118
|
? `${configuration.title} changed after the last confirmation. Review the current records again.`
|
|
105
119
|
: !records.length && !allowsEmptyCollection
|
|
@@ -143,6 +157,9 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
143
157
|
reviewedOn: priorReview?.reviewedOn
|
|
144
158
|
? currentCalendarDate(loaded.workspace.timezone, now)
|
|
145
159
|
: null,
|
|
160
|
+
...(resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
161
|
+
? { expectedCollectionRevision: assessment.collectionRevision }
|
|
162
|
+
: {}),
|
|
146
163
|
...(v4
|
|
147
164
|
? { authoritativeComponentId: preservedAuthoritativeSourceId }
|
|
148
165
|
: { authoritativeSystemId: preservedAuthoritativeSourceId })
|
|
@@ -154,7 +171,14 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
154
171
|
const program = resolveProgram(loaded, options.programId);
|
|
155
172
|
const resourceType = requiredType(loaded, options.resourceType);
|
|
156
173
|
const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
|
|
174
|
+
if (options.expectedCollectionRevision && options.expectedCollectionRevision !== assessment.collectionRevision) {
|
|
175
|
+
throw new Error(`${assessment.configuration.title} changed after it was displayed. Reload and review the current revision before approving it.`);
|
|
176
|
+
}
|
|
177
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && !options.expectedCollectionRevision) {
|
|
178
|
+
throw new Error("Data Retention Schedule approval requires the displayed collection revision. Reload or scaffold the current review before approving it.");
|
|
179
|
+
}
|
|
157
180
|
await requireControlReviewReady(loaded, resourceType, program.id);
|
|
181
|
+
await requireRetentionScheduleReady(loaded, resourceType, program);
|
|
158
182
|
const configuration = assessment.configuration;
|
|
159
183
|
const decision = String(options.decision || "").trim();
|
|
160
184
|
const rationale = String(options.rationale || "").trim();
|
|
@@ -199,6 +223,13 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
199
223
|
throw new Error(`Control collection review needs a reviewer who does not own an included Control or its enabled Obligation: ${conflicts.join(", ")}.`);
|
|
200
224
|
}
|
|
201
225
|
}
|
|
226
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
227
|
+
const eligible = new Set(assessment.eligibleReviewerIds || []);
|
|
228
|
+
const conflicts = reviewedByIds.filter((id) => !eligible.has(id));
|
|
229
|
+
if (conflicts.length) {
|
|
230
|
+
throw new Error(`Data Retention Schedule approval needs a reviewer who does not own its governing document or an included schedule row: ${conflicts.join(", ")}.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
202
233
|
if (temporalReviews) {
|
|
203
234
|
if (reviewedOn !== today) {
|
|
204
235
|
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.");
|
|
@@ -308,6 +339,15 @@ async function requireControlReviewReady(loaded, resourceType, programId) {
|
|
|
308
339
|
}
|
|
309
340
|
}
|
|
310
341
|
|
|
342
|
+
async function requireRetentionScheduleReady(loaded, resourceType, program) {
|
|
343
|
+
if (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval")) return;
|
|
344
|
+
const rows = scopedCollectionRecords(loaded, resourceType, program);
|
|
345
|
+
const approvalIssues = retentionScheduleApprovalIssues(loaded, program, rows, {
|
|
346
|
+
informationTypesReviewed: assessCollectionReview(loaded, "information-type", { programId: program.id }).complete
|
|
347
|
+
});
|
|
348
|
+
if (approvalIssues.length) throw new Error(approvalIssues[0].message);
|
|
349
|
+
}
|
|
350
|
+
|
|
311
351
|
function assessControlReviewers(loaded, controls) {
|
|
312
352
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
313
353
|
const controlIds = new Set(controls.map(({ id }) => id));
|
|
@@ -329,6 +369,25 @@ function assessControlReviewers(loaded, controls) {
|
|
|
329
369
|
};
|
|
330
370
|
}
|
|
331
371
|
|
|
372
|
+
function assessRetentionScheduleReviewers(loaded, rows) {
|
|
373
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
374
|
+
const conflictIds = new Set();
|
|
375
|
+
const schedules = loaded.resources.filter((record) => (
|
|
376
|
+
record.type === "document"
|
|
377
|
+
&& record.documentKind === "schedule"
|
|
378
|
+
&& record.workflowScope === "program"
|
|
379
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
380
|
+
));
|
|
381
|
+
for (const record of [...schedules, ...rows]) {
|
|
382
|
+
for (const id of currentPartyPeople(record.ownerIds || [], byId)) conflictIds.add(id);
|
|
383
|
+
}
|
|
384
|
+
const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
|
|
385
|
+
return {
|
|
386
|
+
eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
|
|
387
|
+
reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
332
391
|
function sameIds(left, right) {
|
|
333
392
|
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
334
393
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
3
4
|
import {
|
|
4
5
|
authoritativeSourceRevisionValue,
|
|
5
6
|
collectionRevisionInputs,
|
|
@@ -24,7 +25,8 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
24
25
|
// Version 0.9.2 narrowed this hash basis. Keep unchanged 0.9.1 reviews valid
|
|
25
26
|
// until management records a new review on the current basis.
|
|
26
27
|
return storedRevision === currentRevision
|
|
27
|
-
||
|
|
28
|
+
|| (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval"))
|
|
29
|
+
&& storedRevision === legacyCollectionRevision(loaded, resourceType, options);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
function calculateCollectionRevision(loaded, resourceType, options, legacy) {
|
package/src/collection-scope.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { modelSupports } from "../model/index.js";
|
|
2
2
|
import { programComponents, selectedRequirementIds } from "./program.js";
|
|
3
|
-
import { retentionReviewResourceIds } from "./retention.js";
|
|
3
|
+
import { retentionReviewResourceIds, retentionUses } from "./retention.js";
|
|
4
4
|
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
5
5
|
|
|
6
6
|
export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
7
7
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
8
8
|
return loaded.resources.filter((record) => record.type === resourceType);
|
|
9
9
|
}
|
|
10
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
11
|
+
return loaded.resources.filter((record) => (
|
|
12
|
+
record.type === resourceType && !["retired", "superseded"].includes(record.status)
|
|
13
|
+
));
|
|
14
|
+
}
|
|
10
15
|
if (resourceType === "person") {
|
|
11
16
|
return scopedProgramPeople(loaded, program);
|
|
12
17
|
}
|
|
@@ -225,6 +230,29 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
225
230
|
.filter((record) => record.type === "vendor" && record.status !== "retired")
|
|
226
231
|
.map(({ id }) => id));
|
|
227
232
|
}
|
|
233
|
+
if (resourceType === "retention-schedule-item" && !legacy && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
234
|
+
const uses = retentionUses(loaded, program || {});
|
|
235
|
+
addIds(uses.map(({ resource }) => resource.id));
|
|
236
|
+
addIds(uses.map(({ informationTypeId }) => informationTypeId));
|
|
237
|
+
const schedules = loaded.resources
|
|
238
|
+
.filter((record) => (
|
|
239
|
+
record.type === "document"
|
|
240
|
+
&& record.documentKind === "schedule"
|
|
241
|
+
&& record.workflowScope === "program"
|
|
242
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
243
|
+
))
|
|
244
|
+
addIds(schedules.map(({ id }) => id));
|
|
245
|
+
for (const schedule of schedules) {
|
|
246
|
+
addPartyIds([...(schedule.ownerIds || []), ...(schedule.approverIds || [])]);
|
|
247
|
+
}
|
|
248
|
+
for (const record of reviewed) {
|
|
249
|
+
addIds([record.scheduleDocumentId]);
|
|
250
|
+
addIds(record.informationTypeIds);
|
|
251
|
+
addIds(record.scopeResourceIds);
|
|
252
|
+
addIds(record.sourceResourceIds);
|
|
253
|
+
addPartyIds([...(record.ownerIds || []), ...(record.approvedByIds || [])]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
228
256
|
if (resourceType === "complementary-control") {
|
|
229
257
|
addIds(program?.systemIds);
|
|
230
258
|
addIds(program?.controlIds);
|
|
@@ -351,6 +379,21 @@ const dependencyFields = {
|
|
|
351
379
|
component: ["id", "type", "status", "systemUses", "informationUses"],
|
|
352
380
|
vendor: ["id", "type", "status", "informationTypeIds"]
|
|
353
381
|
},
|
|
382
|
+
"retention-schedule-item": {
|
|
383
|
+
document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
384
|
+
policy: ["id", "type", "status", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
385
|
+
requirement: ["id", "type", "title", "frameworkId", "reference", "description"],
|
|
386
|
+
commitment: ["id", "type", "status", "commitmentKind", "statement", "effectiveOn"],
|
|
387
|
+
control: ["id", "type", "status", "statement", "systemIds", "componentIds"],
|
|
388
|
+
"information-type": ["id", "type", "status", "classificationId", "description"],
|
|
389
|
+
system: ["id", "type", "status", "informationTypeIds"],
|
|
390
|
+
component: ["id", "type", "status", "systemUses", "informationUses"],
|
|
391
|
+
vendor: ["id", "type", "status", "informationTypeIds"],
|
|
392
|
+
"source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "retentionScheduleItemIds", "componentId"],
|
|
393
|
+
person: ["id", "type", "status", "affiliation", "jobTitle"],
|
|
394
|
+
team: ["id", "type", "status", "memberIds", "chairIds"],
|
|
395
|
+
appointment: ["id", "type", "status", "appointmentKind", "scopeResourceIds", "holderId", "startsOn", "endsOn"]
|
|
396
|
+
},
|
|
354
397
|
"complementary-control": {
|
|
355
398
|
system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
356
399
|
control: ["id", "type", "status", "statement", "activity", "systemIds", "componentIds", "evidenceSourceComponentIds"],
|
|
@@ -382,6 +425,7 @@ const dependencyContentTypes = {
|
|
|
382
425
|
vendor: new Set(["document"]),
|
|
383
426
|
component: new Set(["system"]),
|
|
384
427
|
control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
|
|
428
|
+
"retention-schedule-item": new Set(["document", "policy"]),
|
|
385
429
|
"complementary-control": new Set(["system", "control", "document", "component"])
|
|
386
430
|
};
|
|
387
431
|
|
package/src/program-path.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ACTIVE_MODEL_VERSION, modelSupports } from "../model/index.js";
|
|
2
|
+
|
|
1
3
|
export const RESOURCE_INSTRUCTIONS = {
|
|
2
4
|
program: "Define one management compliance or assurance Program with its goal, bounded Systems, selected Frameworks, Requirement applicability decisions, Controls, owners, risk method, and candidate period.",
|
|
3
5
|
person: "Record each person’s actual organizational job title. Keep named program authority, such as CISO, DPO, Policy Owner, or team chair, in dated Appointment records.",
|
|
@@ -105,13 +107,14 @@ export const PROGRAM_PATH = [
|
|
|
105
107
|
id: "policies",
|
|
106
108
|
number: 2,
|
|
107
109
|
title: "Approve Policies",
|
|
108
|
-
description: "
|
|
109
|
-
summary: "Review and independently approve Policies, program Documents, and
|
|
110
|
+
description: "Approve governed content and retention decisions",
|
|
111
|
+
summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
|
|
110
112
|
sections: [
|
|
111
|
-
{ id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/
|
|
113
|
+
{ id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
|
|
114
|
+
{ id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its structured rows as one governed schedule.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Define rows only for Information Types and operational sources the organization actually uses.", "Confirm the complete document-and-row revision with one schedule review."], types: [], utility: "retention-schedule", defaultOpen: true }
|
|
112
115
|
],
|
|
113
116
|
resourceTypes: [],
|
|
114
|
-
supportingResourceTypes: ["policy", "document", "training"],
|
|
117
|
+
supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
|
|
115
118
|
commands: [
|
|
116
119
|
"filegrc guide policy --json",
|
|
117
120
|
"filegrc guide document --json",
|
|
@@ -119,30 +122,29 @@ export const PROGRAM_PATH = [
|
|
|
119
122
|
"filegrc list policy --json",
|
|
120
123
|
"filegrc list document --json",
|
|
121
124
|
"filegrc list training --json",
|
|
122
|
-
"filegrc get POLICY_ID --mutation"
|
|
125
|
+
"filegrc get POLICY_ID --mutation",
|
|
126
|
+
"filegrc guide retention-schedule-item --json",
|
|
127
|
+
"filegrc review-collection retention-schedule-item --scaffold"
|
|
123
128
|
]
|
|
124
129
|
},
|
|
125
130
|
{
|
|
126
131
|
id: "controls",
|
|
127
132
|
number: 3,
|
|
128
133
|
title: "Implement Controls",
|
|
129
|
-
description: "
|
|
130
|
-
summary: "
|
|
134
|
+
description: "Implement and prepare to operate",
|
|
135
|
+
summary: "Implement Controls, configure their operating schedules and evidence sources, then activate the approved program.",
|
|
131
136
|
sections: [
|
|
132
137
|
{ 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
138
|
{ 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
139
|
{ 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
140
|
{ 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 }
|
|
137
141
|
],
|
|
138
|
-
resourceTypes: ["control", "complementary-control", "
|
|
142
|
+
resourceTypes: ["control", "complementary-control", "obligation"],
|
|
139
143
|
commands: [
|
|
140
144
|
"filegrc guide control --json",
|
|
141
145
|
"filegrc list control --json",
|
|
142
146
|
"filegrc get CONTROL_ID --mutation",
|
|
143
147
|
"filegrc guide obligation --json",
|
|
144
|
-
"filegrc guide retention-schedule-item --json",
|
|
145
|
-
"filegrc review-collection retention-schedule-item --scaffold",
|
|
146
148
|
"filegrc list obligation --json",
|
|
147
149
|
"filegrc review-collection component --scaffold",
|
|
148
150
|
"filegrc review-collection complementary-control --scaffold",
|
|
@@ -264,8 +266,51 @@ export const PROGRAM_PATH = [
|
|
|
264
266
|
}
|
|
265
267
|
];
|
|
266
268
|
|
|
267
|
-
export function
|
|
269
|
+
export function programPathForModel(model = ACTIVE_MODEL_VERSION) {
|
|
270
|
+
if (modelSupports(model, "retention-schedule-approval")) return PROGRAM_PATH;
|
|
268
271
|
return PROGRAM_PATH.map((stage) => {
|
|
272
|
+
if (stage.id === "policies") {
|
|
273
|
+
return {
|
|
274
|
+
...stage,
|
|
275
|
+
description: "Review governed content and approvals",
|
|
276
|
+
summary: "Review and independently approve Policies, program Documents, and Training content.",
|
|
277
|
+
sections: [{
|
|
278
|
+
...stage.sections[0],
|
|
279
|
+
relatedLinks: [{ type: "policy", label: "Policies", href: "#/stage/policies" }]
|
|
280
|
+
}],
|
|
281
|
+
supportingResourceTypes: stage.supportingResourceTypes.filter((type) => type !== "retention-schedule-item"),
|
|
282
|
+
commands: stage.commands.filter((command) => !command.includes("retention-schedule-item"))
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (stage.id === "controls") {
|
|
286
|
+
const retention = {
|
|
287
|
+
id: "retention",
|
|
288
|
+
title: "Retention Schedule",
|
|
289
|
+
description: "Review each retention rule against current information uses and approved sources.",
|
|
290
|
+
steps: ["Confirm scope, cutoff, period, disposition, sources, owner, and approval for each rule."],
|
|
291
|
+
types: ["retention-schedule-item"],
|
|
292
|
+
defaultOpen: true
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
...stage,
|
|
296
|
+
description: "Finish controls and their evidence sources",
|
|
297
|
+
summary: "Describe each Control and connect its evidence source.",
|
|
298
|
+
sections: [...stage.sections.slice(0, 3), retention, ...stage.sections.slice(3)],
|
|
299
|
+
resourceTypes: [...stage.resourceTypes.slice(0, 2), "retention-schedule-item", ...stage.resourceTypes.slice(2)],
|
|
300
|
+
commands: [
|
|
301
|
+
...stage.commands.slice(0, 5),
|
|
302
|
+
"filegrc guide retention-schedule-item --json",
|
|
303
|
+
"filegrc review-collection retention-schedule-item --scaffold",
|
|
304
|
+
...stage.commands.slice(5)
|
|
305
|
+
]
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return stage;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function buildAgentProgramPath(model) {
|
|
313
|
+
return programPathForModel(model).map((stage) => {
|
|
269
314
|
const programResourceTypes = [...stage.resourceTypes, ...(stage.supportingResourceTypes || [])]
|
|
270
315
|
.filter((type) => model.resources[type]);
|
|
271
316
|
const resourcePages = programResourceTypes.map((type, index) => {
|
|
@@ -305,8 +350,8 @@ function agentCommand(command) {
|
|
|
305
350
|
return command.startsWith("filegrc ") ? `npx ${command}` : command;
|
|
306
351
|
}
|
|
307
352
|
|
|
308
|
-
export function resourceProgramContext(type) {
|
|
309
|
-
const stage =
|
|
353
|
+
export function resourceProgramContext(type, model) {
|
|
354
|
+
const stage = programPathForModel(model).find((candidate) => (
|
|
310
355
|
candidate.resourceTypes.includes(type) || (candidate.supportingResourceTypes || []).includes(type)
|
|
311
356
|
));
|
|
312
357
|
if (!stage) return null;
|
package/src/program-readiness.js
CHANGED
|
@@ -57,12 +57,19 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
57
57
|
.map(collectionReviewReadinessItem));
|
|
58
58
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
59
59
|
controlStage.items.push(...sourceStage.items);
|
|
60
|
-
|
|
60
|
+
const retentionItems = await assessRetentionReadiness(loaded, program, {
|
|
61
61
|
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
|
|
62
|
-
})
|
|
63
|
-
|
|
62
|
+
});
|
|
63
|
+
const retentionReviewItems = collectionReviews
|
|
64
64
|
.filter(({ resourceType }) => resourceType === "retention-schedule-item")
|
|
65
|
-
.map(collectionReviewReadinessItem)
|
|
65
|
+
.map(collectionReviewReadinessItem);
|
|
66
|
+
if (modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
67
|
+
policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
|
|
68
|
+
controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
|
|
69
|
+
policyStage.items.push(...retentionReviewItems);
|
|
70
|
+
} else {
|
|
71
|
+
controlStage.items.push(...retentionItems, ...retentionReviewItems);
|
|
72
|
+
}
|
|
66
73
|
const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
|
|
67
74
|
const policyActivations = await assessPolicyActivations(
|
|
68
75
|
requiredPolicies(scope, byId),
|
|
@@ -900,6 +907,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
|
|
|
900
907
|
&& (
|
|
901
908
|
linkedDocumentIds.has(record.id)
|
|
902
909
|
|| obligationDocumentIds.has(record.id)
|
|
910
|
+
|| (
|
|
911
|
+
modelSupports(model, "retention-schedule-approval")
|
|
912
|
+
&& record.documentKind === "schedule"
|
|
913
|
+
&& record.workflowScope === "program"
|
|
914
|
+
)
|
|
903
915
|
|| (
|
|
904
916
|
record.programRole === "required"
|
|
905
917
|
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
4
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
5
|
+
import { resolveDataPath } from "./paths.js";
|
|
6
|
+
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
7
|
+
import {
|
|
8
|
+
nearDuplicateInformationTypes,
|
|
9
|
+
resourceReviewRevisionsSync,
|
|
10
|
+
retentionReviewResourceIds,
|
|
11
|
+
retentionRuleIsCurrent,
|
|
12
|
+
retentionUses
|
|
13
|
+
} from "./retention.js";
|
|
14
|
+
|
|
15
|
+
export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) {
|
|
16
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
17
|
+
const issues = [];
|
|
18
|
+
const planned = rows.filter(({ status }) => status === "planned");
|
|
19
|
+
if (planned.length) {
|
|
20
|
+
issues.push(issue("planned-retention-schedule-row", `Complete or retire every planned retention row before approving the schedule revision: ${planned[0].title}.`));
|
|
21
|
+
}
|
|
22
|
+
const schedules = loaded.resources.filter((record) => (
|
|
23
|
+
record.type === "document"
|
|
24
|
+
&& record.documentKind === "schedule"
|
|
25
|
+
&& record.workflowScope === "program"
|
|
26
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
27
|
+
));
|
|
28
|
+
if (!schedules.length) {
|
|
29
|
+
issues.push(issue("missing-retention-schedule-document", "Add and approve the Data Retention Schedule document before approving the schedule revision."));
|
|
30
|
+
}
|
|
31
|
+
if (schedules.length !== 1) {
|
|
32
|
+
if (schedules.length) {
|
|
33
|
+
issues.push(issue("multiple-retention-schedule-documents", "Keep exactly one current program Data Retention Schedule document before approving the schedule revision."));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const schedule = schedules.length === 1 ? schedules[0] : null;
|
|
37
|
+
const scheduleContent = schedule ? primaryMarkdown(loaded, schedule) : "";
|
|
38
|
+
const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
|
|
39
|
+
if (
|
|
40
|
+
schedule
|
|
41
|
+
&& (!["approved", "active"].includes(schedule.status)
|
|
42
|
+
|| !schedule.approvedOn
|
|
43
|
+
|| !schedule.approvedContentRevisions
|
|
44
|
+
|| currentPartyPeople(schedule.ownerIds || [], byId).size === 0
|
|
45
|
+
|| !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
|
|
46
|
+
|| !linkedControl
|
|
47
|
+
|| !substantiveMarkdown(scheduleContent)
|
|
48
|
+
|| openPlaceholderCount(scheduleContent) > 0
|
|
49
|
+
|| !approvalBindingsMatch(loaded, schedule))
|
|
50
|
+
) {
|
|
51
|
+
issues.push(issue("unapproved-retention-schedule-document", "Complete and independently approve the Data Retention Schedule document before approving the complete schedule."));
|
|
52
|
+
}
|
|
53
|
+
const activeRows = rows.filter(({ status }) => status === "active");
|
|
54
|
+
const mismatched = schedule
|
|
55
|
+
? activeRows.filter(({ scheduleDocumentId }) => scheduleDocumentId !== schedule.id)
|
|
56
|
+
: [];
|
|
57
|
+
if (mismatched.length) {
|
|
58
|
+
issues.push(issue("retention-row-wrong-schedule-document", `Link every active retention row to the current Data Retention Schedule document before approval: ${mismatched[0].title}.`));
|
|
59
|
+
}
|
|
60
|
+
const revisions = resourceReviewRevisionsSync(
|
|
61
|
+
loaded,
|
|
62
|
+
activeRows.flatMap((row) => retentionReviewResourceIds(row, loaded))
|
|
63
|
+
);
|
|
64
|
+
const incomplete = activeRows.filter((row) => !retentionRuleIsCurrent(row, revisions, byId, loaded));
|
|
65
|
+
if (incomplete.length) {
|
|
66
|
+
issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
|
|
67
|
+
}
|
|
68
|
+
const usableRows = activeRows.filter((row) => !incomplete.includes(row));
|
|
69
|
+
const uncovered = retentionUses(loaded, program).find((use) => !usableRows.some((row) => (
|
|
70
|
+
(row.informationTypeIds || []).includes(use.informationTypeId)
|
|
71
|
+
&& ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(program.id))
|
|
72
|
+
)));
|
|
73
|
+
if (uncovered) {
|
|
74
|
+
issues.push(issue(
|
|
75
|
+
"uncovered-retention-information-use",
|
|
76
|
+
`${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no active, current retention schedule row covers both the type and scope.`
|
|
77
|
+
));
|
|
78
|
+
}
|
|
79
|
+
if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
|
|
80
|
+
issues.push(issue("unreviewed-similar-information-types", "Review similar Information Types before approving the Data Retention Schedule."));
|
|
81
|
+
}
|
|
82
|
+
return issues;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function issue(code, message) {
|
|
86
|
+
return { code, message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function primaryMarkdown(loaded, record) {
|
|
90
|
+
const definition = loaded.model.resources[record.type];
|
|
91
|
+
const selected = markdownEntries(loaded.model, record).find((entry) => (
|
|
92
|
+
definition.markdown?.[entry.name]?.primary || entry.name === loaded.model.recordContent?.slot
|
|
93
|
+
));
|
|
94
|
+
if (!selected) return "";
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(resolveDataPath(loaded.root, selected.path), "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function approvalBindingsMatch(loaded, record) {
|
|
103
|
+
const actual = {};
|
|
104
|
+
for (const entry of markdownEntries(loaded.model, record)) {
|
|
105
|
+
try {
|
|
106
|
+
actual[entry.path] = createHash("sha256")
|
|
107
|
+
.update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8"))
|
|
108
|
+
.digest("hex");
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== "ENOENT") throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const expected = record.approvedContentRevisions || {};
|
|
114
|
+
const actualKeys = Object.keys(actual).sort();
|
|
115
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
116
|
+
return actualKeys.length === expectedKeys.length
|
|
117
|
+
&& actualKeys.every((key, index) => (
|
|
118
|
+
key === expectedKeys[index]
|
|
119
|
+
&& actual[key] === expected[key]
|
|
120
|
+
));
|
|
121
|
+
}
|
package/src/retention.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { contentRevision } from "./files.js";
|
|
3
4
|
import { resolveDataPath } from "./paths.js";
|
|
4
5
|
import { programComponents } from "./program.js";
|
|
5
6
|
import { markdownEntries } from "./resource-markdown.js";
|
|
7
|
+
import { modelSupports } from "../model/index.js";
|
|
6
8
|
|
|
7
9
|
export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
8
10
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
@@ -113,7 +115,7 @@ export async function resourceReviewRevisions(loaded, ids) {
|
|
|
113
115
|
const entry = entries.get(id);
|
|
114
116
|
if (!entry || reviewing.has(id)) return null;
|
|
115
117
|
reviewing.add(id);
|
|
116
|
-
const parts = [entry
|
|
118
|
+
const parts = [reviewSource(loaded, entry)];
|
|
117
119
|
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
118
120
|
try {
|
|
119
121
|
parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
@@ -136,6 +138,37 @@ export async function resourceReviewRevisions(loaded, ids) {
|
|
|
136
138
|
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
export function resourceReviewRevisionsSync(loaded, ids) {
|
|
142
|
+
const wanted = new Set(ids);
|
|
143
|
+
const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
144
|
+
const revisions = new Map();
|
|
145
|
+
const reviewing = new Set();
|
|
146
|
+
const review = (id) => {
|
|
147
|
+
if (revisions.has(id)) return revisions.get(id);
|
|
148
|
+
const entry = entries.get(id);
|
|
149
|
+
if (!entry || reviewing.has(id)) return null;
|
|
150
|
+
reviewing.add(id);
|
|
151
|
+
const parts = [reviewSource(loaded, entry)];
|
|
152
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
153
|
+
try {
|
|
154
|
+
parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error.code !== "ENOENT") throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
|
|
160
|
+
const revision = review(sourceId);
|
|
161
|
+
if (revision) parts.push(`${sourceId}:${revision}`);
|
|
162
|
+
}
|
|
163
|
+
reviewing.delete(id);
|
|
164
|
+
const revision = contentRevision(parts.join("\n"));
|
|
165
|
+
revisions.set(id, revision);
|
|
166
|
+
return revision;
|
|
167
|
+
};
|
|
168
|
+
for (const id of wanted) review(id);
|
|
169
|
+
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
export function retentionUses(loaded, program) {
|
|
140
173
|
const systemIds = new Set(program.systemIds || []);
|
|
141
174
|
const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
|
|
@@ -157,6 +190,28 @@ export function retentionUses(loaded, program) {
|
|
|
157
190
|
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
|
|
158
191
|
}
|
|
159
192
|
|
|
193
|
+
function reviewSource(loaded, entry) {
|
|
194
|
+
if (
|
|
195
|
+
modelSupports(loaded.model, "retention-schedule-approval")
|
|
196
|
+
&& entry.record.type === "document"
|
|
197
|
+
&& entry.record.documentKind === "schedule"
|
|
198
|
+
&& entry.record.workflowScope === "program"
|
|
199
|
+
) {
|
|
200
|
+
const approved = structuredClone(entry.record);
|
|
201
|
+
if (approved.status === "active") approved.status = "approved";
|
|
202
|
+
for (const field of [
|
|
203
|
+
"activationBasis",
|
|
204
|
+
"activatedByIds",
|
|
205
|
+
"activatedOn",
|
|
206
|
+
"activatedContentRevisions",
|
|
207
|
+
"effectiveOn",
|
|
208
|
+
"proposedEffectiveOn"
|
|
209
|
+
]) delete approved[field];
|
|
210
|
+
return JSON.stringify(approved);
|
|
211
|
+
}
|
|
212
|
+
return entry.source;
|
|
213
|
+
}
|
|
214
|
+
|
|
160
215
|
export function nearDuplicateInformationTypes(records) {
|
|
161
216
|
const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
|
|
162
217
|
const pairs = [];
|