filegrc 0.9.2 → 0.11.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 +8 -5
- package/model/v7.json +10359 -0
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/applicability-scope.js +211 -0
- package/src/audit-preparation.js +156 -20
- package/src/batch-review.js +40 -24
- package/src/cli.js +85 -7
- package/src/collection-review.js +16 -3
- package/src/collection-revision.js +23 -3
- package/src/collection-scope.js +94 -7
- package/src/document-activation.js +13 -1
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +381 -35
- package/src/obligations.js +142 -39
- package/src/policy-activation.js +5 -0
- package/src/policy-library/data-retention-schedule-v2.md +25 -0
- package/src/policy-library.js +52 -24
- package/src/program-amendment.js +222 -0
- package/src/program-lifecycle.js +1 -1
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +43 -13
- package/src/reconciliation.js +15 -3
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +76 -2
- package/src/setup.js +1 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +171 -2
- package/src/validate.js +106 -11
- package/src/web.js +441 -70
- package/src/workflow.js +33 -13
package/src/obligations.js
CHANGED
|
@@ -37,6 +37,11 @@ const COMPLETION_TIMESTAMP_FIELDS = [
|
|
|
37
37
|
"deprovisionedOn"
|
|
38
38
|
];
|
|
39
39
|
const MAX_PLANNED_ITEMS = 10_000;
|
|
40
|
+
const SCAFFOLDED_COMPLETION_TYPES = new Set([
|
|
41
|
+
"access-review", "attestation", "backup-test", "control-activity", "control-test",
|
|
42
|
+
"evidence", "exercise", "meeting", "penetration-test", "policy-review",
|
|
43
|
+
"risk-assessment", "vendor-review", "vulnerability-scan"
|
|
44
|
+
]);
|
|
40
45
|
|
|
41
46
|
export function planObligations(resources, options = {}) {
|
|
42
47
|
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
@@ -73,7 +78,7 @@ export function planObligations(resources, options = {}) {
|
|
|
73
78
|
};
|
|
74
79
|
|
|
75
80
|
for (const obligation of obligations) {
|
|
76
|
-
const activity = obligationActivity(model, obligation
|
|
81
|
+
const activity = obligationActivity(model, obligation);
|
|
77
82
|
const expectedCompletionTypes = activity.completionResourceTypes;
|
|
78
83
|
const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
|
|
79
84
|
if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
|
|
@@ -101,7 +106,7 @@ export function planObligations(resources, options = {}) {
|
|
|
101
106
|
eventRiskLevels: obligation.eventRiskLevels || [],
|
|
102
107
|
templateResourceId: obligation.templateResourceId || null,
|
|
103
108
|
completionResourceTypes: expectedCompletionTypes,
|
|
104
|
-
completionType: activity
|
|
109
|
+
completionType: preferredCompletionType(activity, obligation, byId),
|
|
105
110
|
completionProfile: activity.completionProfile || null,
|
|
106
111
|
programStatus,
|
|
107
112
|
window: normalizedEventWindow(obligation.window)
|
|
@@ -155,7 +160,7 @@ export function planObligations(resources, options = {}) {
|
|
|
155
160
|
controlIds: obligation.controlIds || [],
|
|
156
161
|
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
157
162
|
completionResourceTypes: expectedCompletionTypes,
|
|
158
|
-
completionType: activity
|
|
163
|
+
completionType: preferredCompletionType(activity, obligation, byId),
|
|
159
164
|
completionProfile: activity.completionProfile || null,
|
|
160
165
|
completionResourceIds: completions.map((record) => record.id),
|
|
161
166
|
status,
|
|
@@ -313,6 +318,7 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
313
318
|
));
|
|
314
319
|
if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
|
|
315
320
|
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
321
|
+
assertAttestationCompletionScope(obligation, options.record, loaded.resources);
|
|
316
322
|
return createResourceAndLink(loaded.root, options.record, {
|
|
317
323
|
type: "obligation",
|
|
318
324
|
id: obligation.id,
|
|
@@ -342,8 +348,11 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
342
348
|
const item = action
|
|
343
349
|
? plannedActionForScaffold(loaded, action, completedOn)
|
|
344
350
|
: plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
|
|
345
|
-
const activity = obligationActivity(loaded.model, obligation
|
|
346
|
-
const type =
|
|
351
|
+
const activity = obligationActivity(loaded.model, obligation);
|
|
352
|
+
const type = item.completionType || preferredCompletionType(activity, {
|
|
353
|
+
...obligation,
|
|
354
|
+
subjectResourceIds: item.subjectResourceIds || []
|
|
355
|
+
}, new Map(loaded.resources.map((record) => [record.id, record])));
|
|
347
356
|
if (!type) throw new Error(`Obligation "${obligation.id}" has no configured completion resource type.`);
|
|
348
357
|
|
|
349
358
|
const mutation = scaffoldResourceMutation(
|
|
@@ -369,10 +378,14 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
369
378
|
activityType: obligation.activityType,
|
|
370
379
|
completionResourceType: type,
|
|
371
380
|
completionProfile: activity.completionProfile || null,
|
|
381
|
+
workItemStatus: item.status,
|
|
382
|
+
programStatus: item.programStatus || null,
|
|
372
383
|
requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [],
|
|
373
384
|
dueWindowStart: item.dueWindowStart || null,
|
|
374
385
|
dueWindowEnd: item.dueWindowEnd || null,
|
|
375
|
-
instructions:
|
|
386
|
+
instructions: item.status === "proposed" || item.programStatus === "proposed"
|
|
387
|
+
? "This work is still a proposal. Resolve its governing Policy, Control, owner, and completion profile before recording completion."
|
|
388
|
+
: "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
|
|
376
389
|
}
|
|
377
390
|
};
|
|
378
391
|
}
|
|
@@ -394,6 +407,7 @@ export async function completeObligationAction(input, options) {
|
|
|
394
407
|
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
395
408
|
const completedOn = requireDate(options?.completedOn, "completion date");
|
|
396
409
|
const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
|
|
410
|
+
assertAttestationCompletionScope(obligation, options.record, loaded.resources, event);
|
|
397
411
|
if (event?.occurredOn && completedOn < event.occurredOn) {
|
|
398
412
|
throw new Error("The action completion date cannot be before its policy event date.");
|
|
399
413
|
}
|
|
@@ -448,7 +462,7 @@ function assertExpectedCompletionType(obligation, record, model) {
|
|
|
448
462
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
449
463
|
throw new Error("A completion resource record is required.");
|
|
450
464
|
}
|
|
451
|
-
const expected = obligationActivity(model, obligation
|
|
465
|
+
const expected = obligationActivity(model, obligation).completionResourceTypes;
|
|
452
466
|
if (expected.length && !expected.includes(record.type)) {
|
|
453
467
|
throw new Error(
|
|
454
468
|
`Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
|
|
@@ -456,6 +470,28 @@ function assertExpectedCompletionType(obligation, record, model) {
|
|
|
456
470
|
}
|
|
457
471
|
}
|
|
458
472
|
|
|
473
|
+
function assertAttestationCompletionScope(obligation, record, resources, event = null) {
|
|
474
|
+
if (record?.type !== "attestation") return;
|
|
475
|
+
const byId = new Map(resources.map((candidate) => [candidate.id, candidate]));
|
|
476
|
+
const eventPeople = (event?.subjectResourceIds || []).filter((id) => byId.get(id)?.type === "person");
|
|
477
|
+
const expectedPeople = eventPeople.length
|
|
478
|
+
? eventPeople
|
|
479
|
+
: (obligation.scopeResourceIds || []).filter((id) => byId.get(id)?.type === "person");
|
|
480
|
+
if (expectedPeople.length && !expectedPeople.includes(record.personId)) {
|
|
481
|
+
throw new Error(`Attestation personId must name the Person in scope for Obligation "${obligation.id}".`);
|
|
482
|
+
}
|
|
483
|
+
const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])]
|
|
484
|
+
.filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type));
|
|
485
|
+
const allowedSubjects = new Set(primarySubjects.length ? primarySubjects : obligation.policyIds || []);
|
|
486
|
+
const actualSubjects = new Set(record.subjectResourceIds || []);
|
|
487
|
+
if (
|
|
488
|
+
actualSubjects.size !== allowedSubjects.size
|
|
489
|
+
|| [...actualSubjects].some((id) => !allowedSubjects.has(id))
|
|
490
|
+
) {
|
|
491
|
+
throw new Error(`Attestation subjects must name the exact authored content in scope for Obligation "${obligation.id}".`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
459
495
|
function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
|
|
460
496
|
const start = requireDate(windowStart, "occurrence window start");
|
|
461
497
|
const plan = planObligations(loaded.resources, {
|
|
@@ -489,6 +525,7 @@ function plannedActionForScaffold(loaded, action, completedOn) {
|
|
|
489
525
|
function applyCompletionScaffoldDefaults(record, context) {
|
|
490
526
|
const { loaded, item, obligation, completedOn, activity } = context;
|
|
491
527
|
const program = resolveProgram(loaded);
|
|
528
|
+
const byId = new Map(loaded.resources.map((candidate) => [candidate.id, candidate]));
|
|
492
529
|
const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
|
|
493
530
|
if (!responsiblePeople.length) {
|
|
494
531
|
throw new Error(`Obligation "${obligation.id}" needs an active owner whose Appointment or Team resolves to a current Person.`);
|
|
@@ -554,19 +591,27 @@ function applyCompletionScaffoldDefaults(record, context) {
|
|
|
554
591
|
evidenceIds: [],
|
|
555
592
|
approvedOn: completedOn
|
|
556
593
|
}),
|
|
557
|
-
attestation: () =>
|
|
558
|
-
|
|
559
|
-
|
|
594
|
+
attestation: () => {
|
|
595
|
+
const personId = (item.subjectResourceIds || []).find((id) => byId.get(id)?.type === "person")
|
|
596
|
+
|| (obligation.scopeResourceIds || []).find((id) => byId.get(id)?.type === "person");
|
|
597
|
+
const primarySubjectIds = [...new Set([
|
|
560
598
|
obligation.templateResourceId,
|
|
561
599
|
...(obligation.scopeResourceIds || [])
|
|
562
|
-
].filter(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
600
|
+
].filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type)))];
|
|
601
|
+
const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
|
|
602
|
+
if (!personId) throw new Error("An Attestation completion needs the Person who made the acknowledgement or completed the training.");
|
|
603
|
+
if (!subjectResourceIds.length) throw new Error("An Attestation completion needs the exact Policy, Document, Training, or Action Item content acknowledged.");
|
|
604
|
+
return {
|
|
605
|
+
status: "completed",
|
|
606
|
+
subjectResourceIds,
|
|
607
|
+
personId,
|
|
608
|
+
attestationKind: obligation.activityType || "completion",
|
|
609
|
+
assignedOn: item.dueWindowStart || completedOn,
|
|
610
|
+
dueOn: item.dueWindowEnd || completedOn,
|
|
611
|
+
completedOn,
|
|
612
|
+
attestationMethod: "git-approval"
|
|
613
|
+
};
|
|
614
|
+
},
|
|
570
615
|
"access-review": () => {
|
|
571
616
|
if (!systemIds.length) throw new Error("An Access Review completion needs an active in-scope System.");
|
|
572
617
|
return {
|
|
@@ -621,22 +666,32 @@ function applyCompletionScaffoldDefaults(record, context) {
|
|
|
621
666
|
evidenceIds: [],
|
|
622
667
|
coverage
|
|
623
668
|
}),
|
|
624
|
-
"control-activity": () =>
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
669
|
+
"control-activity": () => {
|
|
670
|
+
const allowedScopeTypes = new Set(
|
|
671
|
+
loaded.model.resources["control-activity"].fields.scopeResourceIds.relation || []
|
|
672
|
+
);
|
|
673
|
+
const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
|
|
674
|
+
const validScopeIds = requestedScopeIds.filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
|
|
675
|
+
const fallbackScopeIds = systemIds.length
|
|
676
|
+
? systemIds
|
|
677
|
+
: (item.controlIds || obligation.controlIds || []).filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
|
|
678
|
+
return {
|
|
679
|
+
...common,
|
|
680
|
+
profileId: activity.completionProfile || obligation.activityType,
|
|
681
|
+
obligationId: obligation.id,
|
|
682
|
+
controlIds: item.controlIds || obligation.controlIds || [],
|
|
683
|
+
scopeResourceIds: validScopeIds.length
|
|
684
|
+
? validScopeIds
|
|
685
|
+
: fallbackScopeIds.length ? fallbackScopeIds : [loaded.workspace.id],
|
|
686
|
+
performerIds: responsiblePeople,
|
|
687
|
+
completedAt: timestamp,
|
|
688
|
+
method: "",
|
|
689
|
+
result: "",
|
|
690
|
+
reviewerIds,
|
|
691
|
+
reviewedOn: completedOn,
|
|
692
|
+
ownerIds: item.ownerIds || obligation.ownerIds || []
|
|
693
|
+
};
|
|
694
|
+
},
|
|
640
695
|
exercise: () => ({
|
|
641
696
|
...common,
|
|
642
697
|
exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
|
|
@@ -787,10 +842,16 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
787
842
|
.map((record) => {
|
|
788
843
|
const obligation = byId.get(record.obligationId);
|
|
789
844
|
const expectedCompletionTypes = obligation?.type === "obligation"
|
|
790
|
-
? obligationActivity(model, obligation
|
|
845
|
+
? obligationActivity(model, obligation).completionResourceTypes
|
|
791
846
|
: [];
|
|
792
847
|
const completionProfile = obligation?.type === "obligation"
|
|
793
|
-
? obligationActivity(model, obligation
|
|
848
|
+
? obligationActivity(model, obligation).completionProfile || null
|
|
849
|
+
: null;
|
|
850
|
+
const completionType = obligation?.type === "obligation"
|
|
851
|
+
? preferredCompletionType(obligationActivity(model, obligation), {
|
|
852
|
+
...obligation,
|
|
853
|
+
subjectResourceIds: event.subjectResourceIds || []
|
|
854
|
+
}, byId)
|
|
794
855
|
: null;
|
|
795
856
|
const completionIds = modelSupports(model, "guided-workflow")
|
|
796
857
|
? record.completionResourceIds || []
|
|
@@ -801,6 +862,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
801
862
|
|| matchingCompletionIds.length > 0;
|
|
802
863
|
const complete = record.status === "done" && completionSatisfied;
|
|
803
864
|
const window = plannedCompletionWindow(record.completionWindow);
|
|
865
|
+
const lateCompletion = complete && completionWasLate(record, matchingCompletionIds, byId, window);
|
|
804
866
|
const timingStatus = complete
|
|
805
867
|
? "complete"
|
|
806
868
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
@@ -824,14 +886,17 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
824
886
|
ownerIds: record.assigneeIds || [],
|
|
825
887
|
policyIds: obligation?.policyIds || [],
|
|
826
888
|
controlIds: obligation?.controlIds || [],
|
|
889
|
+
subjectResourceIds: event.subjectResourceIds || [],
|
|
827
890
|
scopeResourceIds: obligation?.scopeResourceIds || [],
|
|
828
891
|
templateResourceId: obligation?.templateResourceId || null,
|
|
829
892
|
completionResourceIds: record.completionResourceIds || [],
|
|
830
893
|
evidenceIds: record.evidenceIds || [],
|
|
831
894
|
expectedCompletionTypes,
|
|
832
895
|
completionProfile,
|
|
896
|
+
completionType,
|
|
833
897
|
matchingCompletionIds,
|
|
834
898
|
missingCompletion: record.status === "done" && !completionSatisfied,
|
|
899
|
+
lateCompletion,
|
|
835
900
|
canceledAction: record.status === "canceled",
|
|
836
901
|
recordedStatus: record.status,
|
|
837
902
|
completedOn: record.completedOn || null,
|
|
@@ -870,6 +935,38 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
870
935
|
};
|
|
871
936
|
}
|
|
872
937
|
|
|
938
|
+
function completionWasLate(action, completionIds, byId, window) {
|
|
939
|
+
if (window.dueWindowEndAt) {
|
|
940
|
+
const completedAt = completionIds
|
|
941
|
+
.map((id) => completionTimestamp(byId.get(id)))
|
|
942
|
+
.filter(Boolean)
|
|
943
|
+
.sort()[0];
|
|
944
|
+
if (completedAt) return new Date(completedAt) > new Date(window.dueWindowEndAt);
|
|
945
|
+
return !action.completedOn || action.completedOn >= window.dueWindowEndAt.slice(0, 10);
|
|
946
|
+
}
|
|
947
|
+
return Boolean(window.dueWindowEnd && action.completedOn && action.completedOn > window.dueWindowEnd);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function completionTimestamp(record) {
|
|
951
|
+
if (!record) return null;
|
|
952
|
+
return record.completedAt || record.occurredAt || record.collectedAt || record.verifiedAt || null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function preferredCompletionType(activity, item, byId) {
|
|
956
|
+
const primary = activity.completionType;
|
|
957
|
+
const hasPersonSubject = [...(item.subjectResourceIds || []), ...(item.scopeResourceIds || [])]
|
|
958
|
+
.some((id) => byId.get(id)?.type === "person");
|
|
959
|
+
const hasAuthoredSubject = [item.templateResourceId, ...(item.scopeResourceIds || [])]
|
|
960
|
+
.some((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type))
|
|
961
|
+
|| (item.policyIds || []).some((id) => byId.get(id)?.type === "policy");
|
|
962
|
+
if (primary === "attestation" && (!hasPersonSubject || !hasAuthoredSubject) && activity.completionResourceTypes.includes("evidence")) {
|
|
963
|
+
return "evidence";
|
|
964
|
+
}
|
|
965
|
+
if (SCAFFOLDED_COMPLETION_TYPES.has(primary)) return primary;
|
|
966
|
+
if (activity.completionResourceTypes.includes("evidence")) return "evidence";
|
|
967
|
+
return primary;
|
|
968
|
+
}
|
|
969
|
+
|
|
873
970
|
function planStandaloneAction(record, byId, asOf, now) {
|
|
874
971
|
const source = byId.get(record.sourceResourceId);
|
|
875
972
|
const window = plannedCompletionWindow(record.completionWindow);
|
|
@@ -1038,17 +1135,23 @@ function comparePlannedItems(a, b) {
|
|
|
1038
1135
|
function eventActionDescription(obligation, eventType, model) {
|
|
1039
1136
|
const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
|
|
1040
1137
|
const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
|
|
1041
|
-
const expected = obligationActivity(model, obligation
|
|
1138
|
+
const expected = obligationActivity(model, obligation).completionResourceTypes;
|
|
1042
1139
|
const completion = expected.length
|
|
1043
1140
|
? ` Link completion records of type ${expected.join(", ")} and any evidence before marking this done.`
|
|
1044
1141
|
: " Link the completion record and evidence before marking this done.";
|
|
1045
1142
|
return `Triggered by ${eventType}.${policy}${scope}${completion}`;
|
|
1046
1143
|
}
|
|
1047
1144
|
|
|
1048
|
-
function obligationActivity(model,
|
|
1145
|
+
function obligationActivity(model, obligation) {
|
|
1146
|
+
const activityType = typeof obligation === "string" ? obligation : obligation?.activityType;
|
|
1049
1147
|
const activity = model.obligationActivities?.[activityType];
|
|
1050
1148
|
if (!activity) throw new Error(`Unknown obligation activity type "${activityType ?? ""}".`);
|
|
1051
|
-
return activity;
|
|
1149
|
+
if (activityType !== "custom") return activity;
|
|
1150
|
+
return {
|
|
1151
|
+
...activity,
|
|
1152
|
+
...(obligation?.customActivity || {}),
|
|
1153
|
+
completionType: obligation?.customActivity?.completionResourceTypes?.[0] || activity.completionType
|
|
1154
|
+
};
|
|
1052
1155
|
}
|
|
1053
1156
|
|
|
1054
1157
|
function requireDate(value, label) {
|
package/src/policy-activation.js
CHANGED
|
@@ -12,6 +12,11 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
|
|
|
12
12
|
));
|
|
13
13
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
14
14
|
return {
|
|
15
|
+
available: approved.length > 0,
|
|
16
|
+
message: approved.length
|
|
17
|
+
? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
|
|
18
|
+
: "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
|
|
19
|
+
nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
|
|
15
20
|
policyIds: approved.map(({ policyId }) => policyId),
|
|
16
21
|
effectiveOn: currentCalendarDate(loaded.workspace.timezone),
|
|
17
22
|
expectedRevisions: Object.fromEntries(approved.map(({ policyId }) => [policyId, revisionById.get(policyId)])),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Data Retention Schedule
|
|
2
|
+
|
|
3
|
+
## Use
|
|
4
|
+
|
|
5
|
+
This schedule records how long {{company_name}} keeps important record classes and what happens when each period ends. The policy owner and data owners must complete the organization-specific rows before approval.
|
|
6
|
+
|
|
7
|
+
Retention periods may come from law, contract, tax, audit, security, or a documented business need. Use the longest applicable period, but do not keep data indefinitely without a reason.
|
|
8
|
+
|
|
9
|
+
## Schedule
|
|
10
|
+
|
|
11
|
+
The structured Retention Schedule Items linked to this document are its schedule rows. Each approved item must name the covered Information Types and operational scope, owner, cutoff, period, disposition action, instructions, and authority. Planned items are review prompts and are not approved retention behavior.
|
|
12
|
+
|
|
13
|
+
Management must cover important information used by Systems, Components, and Vendors, including security logs, backups or alternate recovery copies, governance records, audit evidence, customer and service records, and incident records when those classes exist. No starter period or disposition action is an approved organization value.
|
|
14
|
+
|
|
15
|
+
## Holds and exceptions
|
|
16
|
+
|
|
17
|
+
An approved legal hold, investigation, or preservation duty suspends normal deletion for the affected records. Record the authority, scope, owner, start date, and release decision in controlled legal-hold records.
|
|
18
|
+
|
|
19
|
+
Any retention exception needs a reason, owner, approval, compensating safeguards, and expiration or next review date.
|
|
20
|
+
|
|
21
|
+
## Review and disposal evidence
|
|
22
|
+
|
|
23
|
+
Review this schedule at least annually and within 30 days after a material change to systems, data use, vendors, contracts, or applicable duties. The approver must be separate from the owner.
|
|
24
|
+
|
|
25
|
+
For material disposal work, retain a record of the record class, source, date range, method, completion date, responsible person, exceptions, and verification.
|
package/src/policy-library.js
CHANGED
|
@@ -42,14 +42,18 @@ const DOCUMENT_CONTENT_UPDATES = [
|
|
|
42
42
|
id: "document-data-retention-schedule",
|
|
43
43
|
path: "documents/document-data-retention-schedule.md",
|
|
44
44
|
priorRevision: "45a408e8139bd57f42dda5ca5ae5c8cd4480b4e7bf08834f60058148a3a63475",
|
|
45
|
-
additionalPriorRevisions: new Set([
|
|
46
|
-
|
|
45
|
+
additionalPriorRevisions: new Set([
|
|
46
|
+
"d80b99ce53d1012cc169bbbc2afab8d0597bfbe9f30ac0812a8d5bbeb2ed9f90",
|
|
47
|
+
"dd11857ae7d881f176bd93947ef3031c33c75ee41e3c0435198fd60c67a94cf7"
|
|
48
|
+
]),
|
|
49
|
+
currentRevision: "4a48c15a4e20e4f29028cf2ff8597315eb51878814120125b5268356b923c9db",
|
|
50
|
+
currentSourcePath: "./policy-library/data-retention-schedule-v2.md",
|
|
47
51
|
replacements: [
|
|
48
52
|
["FileGRC detects the bracketed prompts as approval blockers. Remove each prompt only after replacing it with a reviewed fact.", "Remove each bracketed prompt only after replacing it with a reviewed fact."],
|
|
49
53
|
["Record the authority, scope, owner, start date, and release decision outside this public template.", "Record the authority, scope, owner, start date, and release decision in controlled legal-hold records."],
|
|
50
54
|
["| Production backups or alternate recovery copies | [Complete before approval: Systems or Components] | [Complete before approval: owner] | Backup or recovery-copy creation | [Confirm or replace proposed default before approval: 30 days, adjusted to approved System recovery objectives] | [Complete before approval: expiration or disposal action] | [Complete before approval: continuity objective or risk decision] |", "| Production backups or alternate recovery copies | [Complete before approval: Systems or Components] | [Complete before approval: owner] | Backup or recovery-copy creation | [Confirm or replace proposed default before approval: 30 days, adjusted to approved System recovery needs] | [Complete before approval: expiration or disposal action] | [Complete before approval: recovery need, commitment, or risk decision] |"]
|
|
51
55
|
],
|
|
52
|
-
summary: "
|
|
56
|
+
summary: "Move schedule rows into structured records and keep organization-specific periods and disposition choices under management review."
|
|
53
57
|
},
|
|
54
58
|
{
|
|
55
59
|
id: "document-security-incident-recovery-plan",
|
|
@@ -592,31 +596,51 @@ async function buildPolicyLibraryPlan(loaded) {
|
|
|
592
596
|
}
|
|
593
597
|
if (sourceRevision === documentUpdate.currentRevision) {
|
|
594
598
|
skipped.push(skippedItem(documentUpdate.id, "current", "The governed Document already contains the current standalone starter language."));
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
if (sourceRevision !== documentUpdate.priorRevision
|
|
599
|
+
} else if (sourceRevision !== documentUpdate.priorRevision
|
|
598
600
|
&& !documentUpdate.additionalPriorRevisions?.has(sourceRevision)) {
|
|
599
601
|
skipped.push(skippedItem(documentUpdate.id, "customized", "The governed Document differs from the recognized prior starter, so FileGRC will not rewrite it."));
|
|
600
|
-
|
|
602
|
+
} else {
|
|
603
|
+
const nextSource = documentUpdate.currentSourcePath
|
|
604
|
+
? materializeDocument(
|
|
605
|
+
await readFile(new URL(documentUpdate.currentSourcePath, import.meta.url), "utf8"),
|
|
606
|
+
loaded.workspace?.organizationName
|
|
607
|
+
)
|
|
608
|
+
: documentUpdate.replacements.reduce((current, [prior, next]) => current.replace(prior, next), source);
|
|
609
|
+
if (normalizedDocumentRevision(nextSource, loaded.workspace?.organizationName) !== documentUpdate.currentRevision) {
|
|
610
|
+
throw new Error(`The ${documentUpdate.id} starter update does not produce the current governed Document.`);
|
|
611
|
+
}
|
|
612
|
+
proposalChanges.push({
|
|
613
|
+
resourceType: "document",
|
|
614
|
+
resourceId: documentUpdate.id,
|
|
615
|
+
path: displayPath,
|
|
616
|
+
summary: documentUpdate.summary,
|
|
617
|
+
diff: fullReplacementDiff(displayPath, source, nextSource)
|
|
618
|
+
});
|
|
619
|
+
contentUpdates[documentUpdate.id] = { content: nextSource };
|
|
620
|
+
expectedContentRevisions[documentUpdate.id] = {
|
|
621
|
+
[documentUpdate.path]: rawSourceRevision
|
|
622
|
+
};
|
|
601
623
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
624
|
+
if (documentUpdate.id === "document-data-retention-schedule") {
|
|
625
|
+
const expectedControlIds = ["control-logging-monitoring", "control-backup-restoration"];
|
|
626
|
+
const requiredControlIds = expectedControlIds.filter((id) => byId.get(id)?.record.type === "control");
|
|
627
|
+
for (const id of expectedControlIds.filter((id) => !requiredControlIds.includes(id))) {
|
|
628
|
+
skipped.push(skippedItem(id, "missing", `The starter ${id} Control is not present, so FileGRC did not add a dangling schedule relationship.`));
|
|
629
|
+
}
|
|
630
|
+
const nextControlIds = [...new Set([...(entry.record.controlIds || []), ...requiredControlIds])];
|
|
631
|
+
if (!sameValue(entry.record.controlIds || [], nextControlIds)) {
|
|
632
|
+
updates.push({ ...entry.record, controlIds: nextControlIds });
|
|
633
|
+
expectedRevisions[entry.record.id] = entry.revision;
|
|
634
|
+
const jsonPath = "data/documents/document-data-retention-schedule.json";
|
|
635
|
+
proposalChanges.push({
|
|
636
|
+
resourceType: "document",
|
|
637
|
+
resourceId: entry.record.id,
|
|
638
|
+
path: jsonPath,
|
|
639
|
+
summary: "Link the schedule to logging and backup Controls while preserving existing Control relationships.",
|
|
640
|
+
diff: replacementDiff(jsonPath, [["controlIds", entry.record.controlIds || [], nextControlIds]])
|
|
641
|
+
});
|
|
642
|
+
}
|
|
608
643
|
}
|
|
609
|
-
proposalChanges.push({
|
|
610
|
-
resourceType: "document",
|
|
611
|
-
resourceId: documentUpdate.id,
|
|
612
|
-
path: displayPath,
|
|
613
|
-
summary: documentUpdate.summary,
|
|
614
|
-
diff: fullReplacementDiff(displayPath, source, nextSource)
|
|
615
|
-
});
|
|
616
|
-
contentUpdates[documentUpdate.id] = { content: nextSource };
|
|
617
|
-
expectedContentRevisions[documentUpdate.id] = {
|
|
618
|
-
[documentUpdate.path]: rawSourceRevision
|
|
619
|
-
};
|
|
620
644
|
}
|
|
621
645
|
|
|
622
646
|
for (const controlUpdate of CONTROL_UPDATES) {
|
|
@@ -816,6 +840,10 @@ function materializePolicy(source, organizationName, securityContact) {
|
|
|
816
840
|
.replaceAll("{{security_contact_email}}", securityContact || "security@example.com");
|
|
817
841
|
}
|
|
818
842
|
|
|
843
|
+
function materializeDocument(source, organizationName) {
|
|
844
|
+
return source.replaceAll("{{company_name}}", organizationName || "Organization");
|
|
845
|
+
}
|
|
846
|
+
|
|
819
847
|
function securityContactFromPolicy(source) {
|
|
820
848
|
return source.match(/through the primary route at ([^\r\n]+?) or the usable alternate route documented/)?.[1];
|
|
821
849
|
}
|