filegrc 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/v11.json +13 -20
- package/package.json +1 -1
- package/src/audit-preparation.js +121 -14
- package/src/audit-selection.js +33 -0
- package/src/cli.js +13 -3
- package/src/collection-review.js +124 -31
- package/src/collection-revision.js +6 -1
- package/src/collection-scope.js +121 -5
- package/src/files.js +13 -1
- package/src/git.js +86 -0
- package/src/index.js +3 -2
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +287 -34
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +81 -23
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +33 -3
- package/src/web.js +264 -134
- package/src/workflow.js +5 -1
package/src/retention.js
CHANGED
|
@@ -10,18 +10,26 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
10
10
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
11
11
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
12
12
|
const rules = loaded.resources.filter((record) => (
|
|
13
|
-
record.type === "retention-schedule-item"
|
|
13
|
+
record.type === "retention-schedule-item"
|
|
14
|
+
&& !["retired", "superseded"].includes(record.status)
|
|
14
15
|
));
|
|
15
|
-
const
|
|
16
|
-
const
|
|
16
|
+
const activeRules = rules.filter(({ status }) => status === "active");
|
|
17
|
+
const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
|
|
18
|
+
const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
|
|
19
|
+
const proposedRules = rules.filter(retentionRuleHasCoverageProposal);
|
|
17
20
|
const uses = retentionUses(loaded, program);
|
|
18
|
-
const items = rules.
|
|
21
|
+
const items = rules.map((rule) => readinessItem(
|
|
19
22
|
`retention-rule-${rule.id}`,
|
|
20
|
-
"action",
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
+
usableRules.includes(rule) ? "complete" : "action",
|
|
24
|
+
usableRules.includes(rule) ? `${rule.title} proposal is ready` : `Finish ${rule.title}`,
|
|
25
|
+
usableRules.includes(rule)
|
|
26
|
+
? "The owner completed this row and bound it to its current source revisions. It will become part of the authoritative schedule only when the complete schedule is approved."
|
|
27
|
+
: rule.status === "planned"
|
|
28
|
+
? "Finish the type, scope, cutoff, period, disposition, sources, and owner review for this proposed row before requesting schedule approval."
|
|
29
|
+
: "This row is incomplete or is not bound to every current source revision. Review it before requesting schedule approval.",
|
|
23
30
|
rule,
|
|
24
31
|
{
|
|
32
|
+
coverageState: usableRules.includes(rule) ? "proposed" : "incomplete",
|
|
25
33
|
sourceResourceIds: retentionReviewResourceIds(rule, loaded),
|
|
26
34
|
commands: [
|
|
27
35
|
`npx filegrc get ${rule.id} --mutation`,
|
|
@@ -32,22 +40,52 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
32
40
|
]
|
|
33
41
|
}
|
|
34
42
|
));
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
const usesByInformationType = new Map();
|
|
44
|
+
for (const use of uses) {
|
|
45
|
+
const grouped = usesByInformationType.get(use.informationTypeId) || [];
|
|
46
|
+
grouped.push(use);
|
|
47
|
+
usesByInformationType.set(use.informationTypeId, grouped);
|
|
48
|
+
}
|
|
49
|
+
items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
|
|
50
|
+
const absentUses = informationTypeUses.filter((use) => (
|
|
51
|
+
!proposedRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
52
|
+
));
|
|
53
|
+
const pendingUses = informationTypeUses.filter((use) => (
|
|
54
|
+
!usableRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
55
|
+
));
|
|
56
|
+
const approvalPending = !options.scheduleApproved;
|
|
57
|
+
const matches = usableRules.filter((rule) => (
|
|
58
|
+
informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
|
|
59
|
+
));
|
|
60
|
+
const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
|
|
61
|
+
const affectedTitles = absentUses.map(({ resource }) => resource.title);
|
|
62
|
+
const affectedSummary = affectedTitles.length > 3
|
|
63
|
+
? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
|
|
64
|
+
: affectedTitles.join(", ");
|
|
37
65
|
return readinessItem(
|
|
38
|
-
`retention-use-${
|
|
39
|
-
|
|
40
|
-
`Decide retention for ${
|
|
41
|
-
|
|
42
|
-
? `${
|
|
43
|
-
:
|
|
44
|
-
|
|
66
|
+
`retention-use-${informationTypeId}`,
|
|
67
|
+
absentUses.length ? "action" : pendingUses.length || approvalPending ? "info" : "complete",
|
|
68
|
+
`Decide retention for ${informationTypeTitle}`,
|
|
69
|
+
absentUses.length
|
|
70
|
+
? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
|
|
71
|
+
: pendingUses.length
|
|
72
|
+
? `Proposed rows cover every in-scope type-and-scope pair. Finish the row review, then approve the complete schedule once instead of approving these coverage checks separately.`
|
|
73
|
+
: approvalPending
|
|
74
|
+
? "Completed row proposals cover every in-scope type-and-scope pair. Approve the complete schedule once; these derived checks are not separate approval work."
|
|
75
|
+
: `The approved schedule covers all ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use" : "uses"}.`,
|
|
76
|
+
{ type: "retention-schedule-item" },
|
|
45
77
|
{
|
|
46
|
-
|
|
78
|
+
progressUnit: false,
|
|
79
|
+
coverageState: absentUses.length ? "absent" : pendingUses.length || approvalPending ? "proposed" : "approved",
|
|
80
|
+
informationTypeId,
|
|
81
|
+
affectedResourceIds: absentUses.map(({ resource }) => resource.id),
|
|
82
|
+
retentionScopeResourceIds: absentUses.length === informationTypeUses.length
|
|
83
|
+
? [program.id]
|
|
84
|
+
: absentUses.map(({ resource }) => resource.id),
|
|
47
85
|
retentionScheduleItemIds: matches.map(({ id }) => id),
|
|
48
86
|
commands: [
|
|
49
87
|
"npx filegrc guide retention-schedule-item --json",
|
|
50
|
-
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${
|
|
88
|
+
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}`
|
|
51
89
|
]
|
|
52
90
|
}
|
|
53
91
|
);
|
|
@@ -55,6 +93,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
55
93
|
|
|
56
94
|
for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
|
|
57
95
|
const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
96
|
+
const proposed = linked.filter((rule) => (
|
|
97
|
+
rule.type === "retention-schedule-item"
|
|
98
|
+
&& proposedRules.includes(rule)
|
|
99
|
+
&& (rule.scopeResourceIds || []).includes(coverage.id)
|
|
100
|
+
));
|
|
58
101
|
const matching = linked.filter((rule) => (
|
|
59
102
|
rule.type === "retention-schedule-item"
|
|
60
103
|
&& rule.status === "active"
|
|
@@ -63,13 +106,17 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
63
106
|
));
|
|
64
107
|
items.push(readinessItem(
|
|
65
108
|
`retention-source-coverage-${coverage.id}`,
|
|
66
|
-
matching.length ? "complete" : "action",
|
|
109
|
+
matching.length && options.scheduleApproved ? "complete" : proposed.length ? "info" : "action",
|
|
67
110
|
`Confirm retained evidence for ${coverage.title}`,
|
|
68
|
-
matching.length
|
|
111
|
+
matching.length && options.scheduleApproved
|
|
69
112
|
? `The source-coverage record references a current schedule item scoped to this population.`
|
|
70
|
-
:
|
|
113
|
+
: proposed.length
|
|
114
|
+
? "A proposed schedule row covers this evidence source. Finish that row and approve the complete schedule before relying on it."
|
|
115
|
+
: `No proposed schedule row covers ${coverage.id}. Add the genuine type-and-scope decision before schedule approval.`,
|
|
71
116
|
coverage,
|
|
72
117
|
{
|
|
118
|
+
progressUnit: false,
|
|
119
|
+
coverageState: matching.length && options.scheduleApproved ? "approved" : proposed.length ? "proposed" : "absent",
|
|
73
120
|
retentionScheduleItemIds: matching.map(({ id }) => id),
|
|
74
121
|
commands: [
|
|
75
122
|
`npx filegrc get ${coverage.id} --mutation`,
|
|
@@ -198,8 +245,11 @@ function reviewSource(loaded, entry) {
|
|
|
198
245
|
&& entry.record.workflowScope === "program"
|
|
199
246
|
) {
|
|
200
247
|
const approved = structuredClone(entry.record);
|
|
201
|
-
if (approved.status === "active") approved.status = "approved";
|
|
202
248
|
for (const field of [
|
|
249
|
+
"status",
|
|
250
|
+
"approverIds",
|
|
251
|
+
"approvedOn",
|
|
252
|
+
"approvedContentRevisions",
|
|
203
253
|
"activationBasis",
|
|
204
254
|
"activatedByIds",
|
|
205
255
|
"activatedOn",
|
|
@@ -232,6 +282,14 @@ function ruleCoversUse(rule, use, program) {
|
|
|
232
282
|
return scope.has(use.resource.id) || scope.has(program.id);
|
|
233
283
|
}
|
|
234
284
|
|
|
285
|
+
function retentionRuleHasCoverageProposal(rule) {
|
|
286
|
+
return Boolean(
|
|
287
|
+
(rule.informationTypeIds || []).length
|
|
288
|
+
&& (rule.scopeResourceIds || []).length
|
|
289
|
+
&& rule.scheduleDocumentId
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
235
293
|
export function retentionReviewResourceIds(rule, loaded) {
|
|
236
294
|
const programUseIds = [];
|
|
237
295
|
if (loaded) {
|
|
@@ -259,7 +317,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
|
|
|
259
317
|
if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
|
|
260
318
|
const schedule = byId.get(rule.scheduleDocumentId);
|
|
261
319
|
if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
|
|
262
|
-
if (!(rule.ownerIds || []).length
|
|
320
|
+
if (!(rule.ownerIds || []).length) return false;
|
|
263
321
|
if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
|
|
264
322
|
if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
|
|
265
323
|
if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
pushWorkspace,
|
|
34
34
|
retryBrowserSync,
|
|
35
35
|
runBrowserMutation,
|
|
36
|
+
synchronizeBrowserStartup,
|
|
36
37
|
withGitCommandCache,
|
|
37
38
|
withGitCommandDeadline
|
|
38
39
|
} from "./git.js";
|
|
@@ -777,6 +778,18 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
777
778
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
778
779
|
throw new Error("The server port must be an integer from 0 through 65535.");
|
|
779
780
|
}
|
|
781
|
+
let startupSynchronization;
|
|
782
|
+
try {
|
|
783
|
+
startupSynchronization = await synchronizeBrowserStartup(input, {
|
|
784
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
|
|
785
|
+
});
|
|
786
|
+
} catch (error) {
|
|
787
|
+
startupSynchronization = {
|
|
788
|
+
status: "failed",
|
|
789
|
+
updated: false,
|
|
790
|
+
message: error.message
|
|
791
|
+
};
|
|
792
|
+
}
|
|
780
793
|
const loaded = await loadWorkspace(input);
|
|
781
794
|
getResourceDefinition(loaded.model, "workspace");
|
|
782
795
|
const server = createFilegrcServer(loaded.root, {
|
|
@@ -806,7 +819,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
806
819
|
address: server.address(),
|
|
807
820
|
url: `http://${urlHost(host)}:${server.address().port}`,
|
|
808
821
|
requestedPort: port,
|
|
809
|
-
usedFallbackPort
|
|
822
|
+
usedFallbackPort,
|
|
823
|
+
startupSynchronization
|
|
810
824
|
};
|
|
811
825
|
}
|
|
812
826
|
|
package/src/source-coverage.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
2
|
+
import { retentionScheduleIsAuthoritative } from "./collection-review.js";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { resolveProgram } from "./program.js";
|
|
2
5
|
|
|
3
6
|
export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
|
|
4
7
|
const structuredRetention = loaded.model.resources["retention-schedule-item"];
|
|
@@ -6,6 +9,10 @@ export async function sourceCoverageComplete(record, loaded, program = loaded.wo
|
|
|
6
9
|
return false;
|
|
7
10
|
}
|
|
8
11
|
if (structuredRetention) {
|
|
12
|
+
const retentionProgram = modelSupports(loaded.model, "program-scope")
|
|
13
|
+
? resolveProgram(loaded, program?.type === "program" ? program.id : undefined)
|
|
14
|
+
: program;
|
|
15
|
+
if (!retentionScheduleIsAuthoritative(loaded, retentionProgram)) return false;
|
|
9
16
|
const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
|
|
10
17
|
loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
|
|
11
18
|
));
|
package/src/state.js
CHANGED
|
@@ -347,6 +347,8 @@ function serializeCollectionReviewAssessment(assessment) {
|
|
|
347
347
|
status: assessment.status,
|
|
348
348
|
complete: assessment.complete,
|
|
349
349
|
message: assessment.message,
|
|
350
|
+
...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
|
|
351
|
+
...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
|
|
350
352
|
...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
|
|
351
353
|
...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
|
|
352
354
|
...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
|
package/src/validate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
|
|
|
3
3
|
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
5
|
import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
6
|
-
import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
6
|
+
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
7
7
|
import {
|
|
8
8
|
collectionRevision,
|
|
9
9
|
collectionRevisionMatches
|
|
@@ -1224,6 +1224,10 @@ function sameRouteProposal(proposal, finalized) {
|
|
|
1224
1224
|
return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
+
function sameStringIds(left = [], right = []) {
|
|
1228
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1227
1231
|
function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
|
|
1228
1232
|
if (!value || !timezone) return;
|
|
1229
1233
|
const source = String(value);
|
|
@@ -1252,10 +1256,19 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1252
1256
|
const program = modelSupports(model, "program-scope")
|
|
1253
1257
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
1254
1258
|
: null;
|
|
1259
|
+
const workspaceWideRetentionReview = record.resourceType === "retention-schedule-item"
|
|
1260
|
+
&& modelSupports(model, "retention-schedule-approval");
|
|
1261
|
+
const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
|
|
1255
1262
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
1256
1263
|
const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
|
|
1257
1264
|
.map(({ id }) => id)
|
|
1258
1265
|
.sort();
|
|
1266
|
+
const incompleteProposals = collectionRecordProposals(
|
|
1267
|
+
loaded,
|
|
1268
|
+
record.resourceType,
|
|
1269
|
+
scopedCollectionRecords(loaded, record.resourceType, program),
|
|
1270
|
+
program
|
|
1271
|
+
).filter(({ complete }) => !complete);
|
|
1259
1272
|
const currentRevision = collectionRevision(loaded, record.resourceType, {
|
|
1260
1273
|
program,
|
|
1261
1274
|
authoritativeSourceId: record.decision === "externally-managed"
|
|
@@ -1274,6 +1287,20 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1274
1287
|
currentRevision
|
|
1275
1288
|
}
|
|
1276
1289
|
);
|
|
1290
|
+
if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
|
|
1291
|
+
diagnostics.push(error(
|
|
1292
|
+
"invalid-retention-schedule-review-scope",
|
|
1293
|
+
path,
|
|
1294
|
+
"The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
|
|
1295
|
+
));
|
|
1296
|
+
}
|
|
1297
|
+
if (current && incompleteProposals.length) {
|
|
1298
|
+
diagnostics.push(error(
|
|
1299
|
+
"incomplete-collection-review-proposals",
|
|
1300
|
+
path,
|
|
1301
|
+
`Complete every ${configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incompleteProposals.map(({ title }) => title).join(", ")}.`
|
|
1302
|
+
));
|
|
1303
|
+
}
|
|
1277
1304
|
if (current && record.resourceType === "control") {
|
|
1278
1305
|
const controls = scopedCollectionRecords(loaded, "control", program);
|
|
1279
1306
|
const controlIds = new Set(controls.map(({ id }) => id));
|
|
@@ -1299,8 +1326,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1299
1326
|
}
|
|
1300
1327
|
if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
|
|
1301
1328
|
const rows = scopedCollectionRecords(loaded, record.resourceType, program);
|
|
1302
|
-
for (const issue of retentionScheduleApprovalIssues(loaded,
|
|
1303
|
-
informationTypesReviewed:
|
|
1329
|
+
for (const issue of retentionScheduleApprovalIssues(loaded, retentionScope, rows, {
|
|
1330
|
+
informationTypesReviewed: retentionScope.programIds.every((id) => (
|
|
1331
|
+
collectionReviewIsCurrent(loaded, "information-type", byId.get(id))
|
|
1332
|
+
)),
|
|
1333
|
+
approvalReview: record
|
|
1304
1334
|
})) {
|
|
1305
1335
|
diagnostics.push(error(issue.code, path, issue.message));
|
|
1306
1336
|
}
|