filegrc 0.15.1 → 0.15.3
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 +111 -10
- package/src/cli.js +7 -0
- 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 +2 -1
- package/src/obligation-members.js +31 -0
- package/src/obligations.js +39 -6
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +102 -15
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +59 -20
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +35 -15
- package/src/web.js +80 -39
- package/src/workflow.js +3 -1
package/src/collection-scope.js
CHANGED
|
@@ -65,6 +65,77 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
65
65
|
));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function retentionScheduleReviewScope(loaded) {
|
|
69
|
+
const programs = loaded.resources.filter((record) => (
|
|
70
|
+
record.type === "program" && record.status !== "retired"
|
|
71
|
+
));
|
|
72
|
+
return {
|
|
73
|
+
id: "workspace",
|
|
74
|
+
programIds: programs.map(({ id }) => id).sort(),
|
|
75
|
+
systemIds: [...new Set(programs.flatMap((record) => record.systemIds || []))],
|
|
76
|
+
controlIds: [...new Set(programs.flatMap((record) => record.controlIds || []))],
|
|
77
|
+
frameworkIds: [...new Set(programs.flatMap((record) => record.frameworkIds || []))]
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const RECORD_PROPOSAL_COLLECTIONS = new Set([
|
|
82
|
+
"component",
|
|
83
|
+
"vendor",
|
|
84
|
+
"complementary-control",
|
|
85
|
+
"information-type"
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
export function collectionRecordProposals(loaded, resourceType, records, program) {
|
|
89
|
+
if (!RECORD_PROPOSAL_COLLECTIONS.has(resourceType)) return [];
|
|
90
|
+
const currentRecords = records.filter((record) => collectionRecordIsCurrent(record));
|
|
91
|
+
const scopedIds = new Set(currentRecords.map(({ id }) => id));
|
|
92
|
+
const candidates = new Map(currentRecords.map((record) => [record.id, record]));
|
|
93
|
+
const activePrograms = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
94
|
+
if (activePrograms.length === 1 && activePrograms[0].id === program?.id && ["component", "complementary-control"].includes(resourceType)) {
|
|
95
|
+
for (const record of loaded.resources) {
|
|
96
|
+
if (
|
|
97
|
+
record.type !== resourceType
|
|
98
|
+
|| ["retired", "superseded"].includes(record.status)
|
|
99
|
+
|| collectionRecordProposalComplete(record, loaded)
|
|
100
|
+
) continue;
|
|
101
|
+
candidates.set(record.id, record);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...candidates.values()].map((record) => ({
|
|
105
|
+
resourceId: record.id,
|
|
106
|
+
title: record.title,
|
|
107
|
+
complete: scopedIds.has(record.id) && collectionRecordProposalComplete(record, loaded)
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectionRecordIsCurrent(record) {
|
|
112
|
+
if (record.type === "vendor") return !["deprecated", "terminated"].includes(record.status);
|
|
113
|
+
if (record.type === "information-type") return !["superseded", "retired"].includes(record.status);
|
|
114
|
+
if (record.type === "complementary-control") return !["superseded", "retired"].includes(record.status);
|
|
115
|
+
return record.status !== "retired";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function collectionRecordProposalComplete(record, loaded) {
|
|
119
|
+
if (record.type === "vendor") return record.status !== "evaluating";
|
|
120
|
+
if (record.type === "component") {
|
|
121
|
+
if (record.status === "planned") return false;
|
|
122
|
+
if (record.status === "retired") return true;
|
|
123
|
+
return record.status === "active"
|
|
124
|
+
&& Boolean(String(record.description || "").trim())
|
|
125
|
+
&& (record.ownerIds || []).length > 0
|
|
126
|
+
&& (record.systemUses || []).length > 0
|
|
127
|
+
&& (record.systemUses || []).every(({ roles, rationale }) => (
|
|
128
|
+
(roles || []).length > 0 && Boolean(String(rationale || "").trim())
|
|
129
|
+
));
|
|
130
|
+
}
|
|
131
|
+
if (record.type === "information-type") {
|
|
132
|
+
if (record.status === "retired") return true;
|
|
133
|
+
const classification = loaded.resources.find(({ id, type }) => type === "classification" && id === record.classificationId);
|
|
134
|
+
return record.status === "active" && classification?.status === "active";
|
|
135
|
+
}
|
|
136
|
+
return record.status !== "planned";
|
|
137
|
+
}
|
|
138
|
+
|
|
68
139
|
export function selectScopedCollectionRecords(loaded, selector, program) {
|
|
69
140
|
if (!selector?.resourceType) return [];
|
|
70
141
|
return scopedCollectionRecords(loaded, selector.resourceType, program).filter((record) => (
|
|
@@ -231,7 +302,10 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
231
302
|
.map(({ id }) => id));
|
|
232
303
|
}
|
|
233
304
|
if (resourceType === "retention-schedule-item" && !legacy && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
234
|
-
const
|
|
305
|
+
const programs = program
|
|
306
|
+
? [program]
|
|
307
|
+
: loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
308
|
+
const uses = programs.flatMap((candidate) => retentionUses(loaded, candidate));
|
|
235
309
|
addIds(uses.map(({ resource }) => resource.id));
|
|
236
310
|
addIds(uses.map(({ informationTypeId }) => informationTypeId));
|
|
237
311
|
const schedules = loaded.resources
|
|
@@ -243,14 +317,14 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
243
317
|
))
|
|
244
318
|
addIds(schedules.map(({ id }) => id));
|
|
245
319
|
for (const schedule of schedules) {
|
|
246
|
-
addPartyIds(
|
|
320
|
+
addPartyIds(schedule.ownerIds || []);
|
|
247
321
|
}
|
|
248
322
|
for (const record of reviewed) {
|
|
249
323
|
addIds([record.scheduleDocumentId]);
|
|
250
324
|
addIds(record.informationTypeIds);
|
|
251
325
|
addIds(record.scopeResourceIds);
|
|
252
326
|
addIds(record.sourceResourceIds);
|
|
253
|
-
addPartyIds(
|
|
327
|
+
addPartyIds(record.ownerIds || []);
|
|
254
328
|
}
|
|
255
329
|
}
|
|
256
330
|
if (resourceType === "complementary-control") {
|
|
@@ -267,14 +341,43 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
267
341
|
return [...records.values()].map((record) => ({
|
|
268
342
|
record,
|
|
269
343
|
value: reviewedIds.has(record.id)
|
|
270
|
-
?
|
|
271
|
-
|
|
344
|
+
? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
345
|
+
? retentionScheduleRowProposalValue(record)
|
|
346
|
+
: record
|
|
347
|
+
: resourceType === "retention-schedule-item"
|
|
348
|
+
&& modelSupports(loaded.model, "retention-schedule-approval")
|
|
349
|
+
&& record.type === "document"
|
|
350
|
+
? retentionScheduleDocumentProposalValue(record)
|
|
351
|
+
: dependencyRevisionValue(resourceType, record, legacy),
|
|
272
352
|
includeContent: legacy
|
|
273
353
|
|| reviewedIds.has(record.id)
|
|
274
354
|
|| dependencyContentAffectsRevision(resourceType, record.type)
|
|
275
355
|
}));
|
|
276
356
|
}
|
|
277
357
|
|
|
358
|
+
function retentionScheduleRowProposalValue(record) {
|
|
359
|
+
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
360
|
+
"approvedByIds",
|
|
361
|
+
"approvedOn"
|
|
362
|
+
].includes(field)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function retentionScheduleDocumentProposalValue(record) {
|
|
366
|
+
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
367
|
+
"status",
|
|
368
|
+
"approverIds",
|
|
369
|
+
"approvedOn",
|
|
370
|
+
"approvedContentRevisions",
|
|
371
|
+
"activationBasis",
|
|
372
|
+
"activatedByIds",
|
|
373
|
+
"activatedOn",
|
|
374
|
+
"activatedContentRevisions",
|
|
375
|
+
"effectiveOn",
|
|
376
|
+
"proposedEffectiveOn",
|
|
377
|
+
"statusTransition"
|
|
378
|
+
].includes(field)));
|
|
379
|
+
}
|
|
380
|
+
|
|
278
381
|
export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
279
382
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
280
383
|
const common = { programId: program?.id ?? null };
|
|
@@ -296,6 +399,19 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
296
399
|
}
|
|
297
400
|
return common;
|
|
298
401
|
}
|
|
402
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
403
|
+
const programs = loaded.resources
|
|
404
|
+
.filter((record) => record.type === "program" && record.status !== "retired")
|
|
405
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
406
|
+
return {
|
|
407
|
+
programIds: programs.map(({ id }) => id),
|
|
408
|
+
programs: programs.map((record) => ({
|
|
409
|
+
id: record.id,
|
|
410
|
+
systemIds: sorted(record.systemIds),
|
|
411
|
+
controlIds: sorted(record.controlIds)
|
|
412
|
+
}))
|
|
413
|
+
};
|
|
414
|
+
}
|
|
299
415
|
const common = { programId: program?.id ?? null };
|
|
300
416
|
if (resourceType === "framework") {
|
|
301
417
|
return {
|
package/src/files.js
CHANGED
|
@@ -234,6 +234,12 @@ export async function applyGovernedContentActivationBatch(input, changes) {
|
|
|
234
234
|
));
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
export async function applyRetentionScheduleReviewBatch(input, changes) {
|
|
238
|
+
return serializeWorkspaceMutation(input, (root) => (
|
|
239
|
+
applyResourceBatchUnlocked(root, changes, "retention-schedule-approval")
|
|
240
|
+
));
|
|
241
|
+
}
|
|
242
|
+
|
|
237
243
|
export async function applyModelMigrationBatch(input, changes) {
|
|
238
244
|
return serializeWorkspaceMutation(input, (root) => (
|
|
239
245
|
applyResourceBatchUnlocked(root, changes, "model-migration")
|
|
@@ -1231,7 +1237,13 @@ function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, mode
|
|
|
1231
1237
|
const title = getResourceDefinition(model, nextRecord.type).title;
|
|
1232
1238
|
const approvedStatuses = new Set(["approved", "active", "superseded", "retired"]);
|
|
1233
1239
|
const activatedStatuses = new Set(["active", "superseded", "retired"]);
|
|
1234
|
-
|
|
1240
|
+
const retentionScheduleReapproval = lifecycleOperation === "retention-schedule-approval"
|
|
1241
|
+
&& previousRecord.type === "document"
|
|
1242
|
+
&& previousRecord.documentKind === "schedule"
|
|
1243
|
+
&& previousRecord.workflowScope === "program"
|
|
1244
|
+
&& nextRecord.documentKind === "schedule"
|
|
1245
|
+
&& nextRecord.workflowScope === "program";
|
|
1246
|
+
if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status) && !retentionScheduleReapproval) {
|
|
1235
1247
|
assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
|
|
1236
1248
|
"approverIds",
|
|
1237
1249
|
"approvedOn",
|
package/src/git.js
CHANGED
|
@@ -1774,6 +1774,92 @@ export async function prefetchBrowserRemote(
|
|
|
1774
1774
|
return prefetch;
|
|
1775
1775
|
}
|
|
1776
1776
|
|
|
1777
|
+
export async function synchronizeBrowserStartup(
|
|
1778
|
+
input = process.cwd(),
|
|
1779
|
+
options = {},
|
|
1780
|
+
) {
|
|
1781
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
1782
|
+
const config = await getRepositoryConfig(root);
|
|
1783
|
+
if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
|
|
1784
|
+
return { status: "not-needed", updated: false };
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const before = await requireTrunkPreconditionsAsync(root, config, {
|
|
1788
|
+
allowAhead: true,
|
|
1789
|
+
});
|
|
1790
|
+
await fetchConfiguredRemote(root, config);
|
|
1791
|
+
const synchronized = inspectTrunkRepository(
|
|
1792
|
+
root,
|
|
1793
|
+
config,
|
|
1794
|
+
await getRepositorySnapshot(root, { fresh: true }),
|
|
1795
|
+
);
|
|
1796
|
+
if (synchronized.ahead > 0 || synchronized.behind === 0) {
|
|
1797
|
+
return {
|
|
1798
|
+
status: synchronized.ahead > 0 ? "blocked" : "current",
|
|
1799
|
+
updated: false,
|
|
1800
|
+
ahead: synchronized.ahead,
|
|
1801
|
+
behind: synchronized.behind,
|
|
1802
|
+
commit: synchronized.currentCommit,
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const target = synchronized.upstreamCommit;
|
|
1807
|
+
const incomingCommits = lines(
|
|
1808
|
+
git(root, [
|
|
1809
|
+
"rev-list",
|
|
1810
|
+
"--reverse",
|
|
1811
|
+
`${synchronized.currentCommit}..${target}`,
|
|
1812
|
+
]),
|
|
1813
|
+
);
|
|
1814
|
+
assertCommitsInsideWorkspace(root, incomingCommits);
|
|
1815
|
+
for (const commit of incomingCommits) assertNoCommitWorkspaceContentFilters(root, commit);
|
|
1816
|
+
assertExpectedCheckout(
|
|
1817
|
+
root,
|
|
1818
|
+
`refs/heads/${config.authoritativeBranch}`,
|
|
1819
|
+
before.currentCommit,
|
|
1820
|
+
);
|
|
1821
|
+
await gitForWriteAsync(
|
|
1822
|
+
root,
|
|
1823
|
+
[
|
|
1824
|
+
"-c",
|
|
1825
|
+
"core.hooksPath=/dev/null",
|
|
1826
|
+
"merge",
|
|
1827
|
+
"--ff-only",
|
|
1828
|
+
"--no-edit",
|
|
1829
|
+
"--no-stat",
|
|
1830
|
+
target,
|
|
1831
|
+
],
|
|
1832
|
+
`fast-forward ${config.authoritativeBranch} at server startup`,
|
|
1833
|
+
{
|
|
1834
|
+
expectedCheckout: {
|
|
1835
|
+
expectedRef: `refs/heads/${config.authoritativeBranch}`,
|
|
1836
|
+
expectedCommit: before.currentCommit,
|
|
1837
|
+
},
|
|
1838
|
+
expectedNoOperation: true,
|
|
1839
|
+
},
|
|
1840
|
+
);
|
|
1841
|
+
const after = inspectTrunkRepository(
|
|
1842
|
+
root,
|
|
1843
|
+
config,
|
|
1844
|
+
await getRepositorySnapshot(root, { fresh: true }),
|
|
1845
|
+
);
|
|
1846
|
+
if (after.ahead !== 0 || after.behind !== 0 || after.currentCommit !== target) {
|
|
1847
|
+
throw new Error(
|
|
1848
|
+
"The authoritative branch changed while FileGRC fast-forwarded it. Reconcile the repository with Git, then restart FileGRC.",
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
const synchronizedAt = new Date().toISOString();
|
|
1852
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
1853
|
+
return {
|
|
1854
|
+
status: "updated",
|
|
1855
|
+
updated: true,
|
|
1856
|
+
commit: after.currentCommit,
|
|
1857
|
+
shortCommit: after.currentCommit.slice(0, 8),
|
|
1858
|
+
synchronizedAt,
|
|
1859
|
+
};
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1777
1863
|
async function prefetchBrowserRemoteCoalesced(root, options) {
|
|
1778
1864
|
const prepared = await serializeWorkspaceMutation(root, async () => {
|
|
1779
1865
|
const config = await getRepositoryConfig(root);
|
package/src/index.js
CHANGED
|
@@ -49,7 +49,8 @@ export {
|
|
|
49
49
|
pullWorkspace,
|
|
50
50
|
pushWorkspace,
|
|
51
51
|
retryBrowserSync,
|
|
52
|
-
runBrowserMutation
|
|
52
|
+
runBrowserMutation,
|
|
53
|
+
synchronizeBrowserStartup
|
|
53
54
|
} from "./git.js";
|
|
54
55
|
export { generateModelDocumentation } from "./model-docs.js";
|
|
55
56
|
export { renderMarkdown } from "./markdown.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function occurrenceMemberExceptionIsValid(member, occurrence, byId) {
|
|
2
|
+
const exception = byId.get(member.exceptionId);
|
|
3
|
+
const occurrenceStart = occurrence.coverage?.kind === "as-of"
|
|
4
|
+
? occurrence.coverage.on
|
|
5
|
+
: occurrence.coverage?.startsOn;
|
|
6
|
+
const occurrenceEnd = occurrence.coverage?.kind === "as-of"
|
|
7
|
+
? occurrence.coverage.on
|
|
8
|
+
: occurrence.coverage?.endsOn;
|
|
9
|
+
const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
|
|
10
|
+
|| exception?.scopeResourceIds?.includes(occurrence.obligationId);
|
|
11
|
+
const statusCoveredOccurrence = exception?.status === "approved"
|
|
12
|
+
|| ["revoked", "closed"].includes(exception?.status)
|
|
13
|
+
&& exception.resolution?.resolvedOn > occurrenceEnd;
|
|
14
|
+
return Boolean(
|
|
15
|
+
exception?.type === "exception"
|
|
16
|
+
&& statusCoveredOccurrence
|
|
17
|
+
&& exceptionCoversMember
|
|
18
|
+
&& exception.approval?.approvedOn <= occurrenceStart
|
|
19
|
+
&& exception.approval?.expiresOn >= occurrenceEnd
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function occurrenceMemberIsResolved(member, occurrence, byId, expectedCompletionIsValid = () => false) {
|
|
24
|
+
if (member.disposition === "expected") {
|
|
25
|
+
return member.result === "passed"
|
|
26
|
+
&& (member.completionResourceIds || []).some((id) => expectedCompletionIsValid(id, member));
|
|
27
|
+
}
|
|
28
|
+
if (member.disposition === "exception") return occurrenceMemberExceptionIsValid(member, occurrence, byId);
|
|
29
|
+
if (member.disposition === "not-applicable") return Boolean(String(member.rationale || "").trim());
|
|
30
|
+
return false;
|
|
31
|
+
}
|
package/src/obligations.js
CHANGED
|
@@ -29,6 +29,7 @@ import { serializeWorkspaceMutation } from "./mutation.js";
|
|
|
29
29
|
import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./collection-review-integrity.js";
|
|
30
30
|
import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js";
|
|
31
31
|
import { selectScopedCollectionRecords } from "./collection-scope.js";
|
|
32
|
+
import { occurrenceMemberIsResolved } from "./obligation-members.js";
|
|
32
33
|
|
|
33
34
|
const COMPLETION_DATE_FIELDS = [
|
|
34
35
|
"completedOn",
|
|
@@ -214,8 +215,22 @@ export function planObligations(resources, options = {}) {
|
|
|
214
215
|
const completedMemberIds = new Set((reconciliation?.members || [])
|
|
215
216
|
.filter((member) => member.result === "passed" && member.disposition === "expected")
|
|
216
217
|
.map((member) => member.resourceId));
|
|
218
|
+
const resolvedMemberIds = new Set((reconciliation?.members || [])
|
|
219
|
+
.filter((member) => occurrenceMemberIsResolved(member, reconciliation, byId, (completionId) => {
|
|
220
|
+
const completion = byId.get(completionId);
|
|
221
|
+
return completionTypeMatches(completion, expectedCompletionTypes)
|
|
222
|
+
&& completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId)
|
|
223
|
+
&& completionFallsInWindow(completion, window, rule?.timezone || workspace?.timezone || "UTC")
|
|
224
|
+
&& completionPassesActivity(completion, activity);
|
|
225
|
+
}))
|
|
226
|
+
.map((member) => member.resourceId));
|
|
227
|
+
const resolutionsComplete = membershipFinal
|
|
228
|
+
&& expectedMemberIds.every((resourceId) => resolvedMemberIds.has(resourceId));
|
|
229
|
+
const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation?.conclusion)
|
|
230
|
+
&& resolutionsComplete;
|
|
217
231
|
const successful = reconciliation
|
|
218
|
-
? reconciliation.status === "reconciled"
|
|
232
|
+
? reconciliation.status === "reconciled"
|
|
233
|
+
&& conclusionValid
|
|
219
234
|
: legacyCompletions.length > 0;
|
|
220
235
|
const timingStatus = occurrenceStatus(window, asOf, successful);
|
|
221
236
|
const status = timingStatus === "complete" || programStatus === "accepted" ? timingStatus : "proposed";
|
|
@@ -244,6 +259,9 @@ export function planObligations(resources, options = {}) {
|
|
|
244
259
|
completionResourceIds,
|
|
245
260
|
expectedMemberIds,
|
|
246
261
|
completedMemberIds: [...completedMemberIds],
|
|
262
|
+
resolvedMemberIds: [...resolvedMemberIds],
|
|
263
|
+
resolutionsComplete,
|
|
264
|
+
conclusionValid,
|
|
247
265
|
expectedCount: reconciliation?.status === "open" && !membershipFinal
|
|
248
266
|
? expectedMemberIds.length
|
|
249
267
|
: reconciliation?.expectedCount ?? expectedMemberIds.length,
|
|
@@ -282,8 +300,20 @@ export function planObligations(resources, options = {}) {
|
|
|
282
300
|
const completedMemberIds = (occurrence.members || [])
|
|
283
301
|
.filter(({ disposition, result }) => disposition === "expected" && result === "passed")
|
|
284
302
|
.map(({ resourceId }) => resourceId);
|
|
303
|
+
const resolvedMemberIds = (occurrence.members || [])
|
|
304
|
+
.filter((member) => occurrenceMemberIsResolved(member, occurrence, byId, (completionId) => {
|
|
305
|
+
const completion = byId.get(completionId);
|
|
306
|
+
return completionTypeMatches(completion, activity.completionResourceTypes)
|
|
307
|
+
&& completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId)
|
|
308
|
+
&& completionFallsInWindow(completion, window, rule.timezone || workspace?.timezone || "UTC")
|
|
309
|
+
&& completionPassesActivity(completion, activity);
|
|
310
|
+
}))
|
|
311
|
+
.map(({ resourceId }) => resourceId);
|
|
312
|
+
const resolutionsComplete = (occurrence.members || []).every(({ resourceId }) => resolvedMemberIds.includes(resourceId));
|
|
313
|
+
const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion)
|
|
314
|
+
&& resolutionsComplete;
|
|
285
315
|
const successful = occurrence.status === "reconciled"
|
|
286
|
-
&&
|
|
316
|
+
&& conclusionValid;
|
|
287
317
|
const timingStatus = occurrenceStatus(window, asOf, successful);
|
|
288
318
|
if (timingStatus === "complete" && !options.includeComplete) continue;
|
|
289
319
|
calendarItems.push({
|
|
@@ -307,6 +337,9 @@ export function planObligations(resources, options = {}) {
|
|
|
307
337
|
completionResourceIds: [...new Set((occurrence.members || []).flatMap(({ completionResourceIds = [] }) => completionResourceIds))],
|
|
308
338
|
expectedMemberIds: (occurrence.members || []).map(({ resourceId }) => resourceId),
|
|
309
339
|
completedMemberIds,
|
|
340
|
+
resolvedMemberIds,
|
|
341
|
+
resolutionsComplete,
|
|
342
|
+
conclusionValid,
|
|
310
343
|
expectedCount: occurrence.expectedCount,
|
|
311
344
|
completedCount: occurrence.completedCount,
|
|
312
345
|
membershipFinal: true,
|
|
@@ -1750,8 +1783,8 @@ function plannedCompletionWindow(window) {
|
|
|
1750
1783
|
};
|
|
1751
1784
|
}
|
|
1752
1785
|
|
|
1753
|
-
function completionFallsInWindow(record, window) {
|
|
1754
|
-
const date = completionDate(record);
|
|
1786
|
+
function completionFallsInWindow(record, window, timezone = "UTC") {
|
|
1787
|
+
const date = completionDate(record, timezone);
|
|
1755
1788
|
return Boolean(date && date >= window.dueWindowStart && date <= window.dueWindowEnd);
|
|
1756
1789
|
}
|
|
1757
1790
|
|
|
@@ -1765,11 +1798,11 @@ function completionTypeMatches(record, expectedTypes = []) {
|
|
|
1765
1798
|
return Boolean(record && (expectedTypes.length === 0 || expectedTypes.includes(record.type)));
|
|
1766
1799
|
}
|
|
1767
1800
|
|
|
1768
|
-
function completionDate(record) {
|
|
1801
|
+
function completionDate(record, timezone = "UTC") {
|
|
1769
1802
|
const coverageDate = coverageEnd(record.coverage);
|
|
1770
1803
|
if (parseCalendarDate(coverageDate)) return coverageDate;
|
|
1771
1804
|
for (const field of COMPLETION_TIMESTAMP_FIELDS) {
|
|
1772
|
-
if (isRfc3339Timestamp(record[field])) return record[field]
|
|
1805
|
+
if (isRfc3339Timestamp(record[field])) return currentCalendarDate(timezone, new Date(record[field]));
|
|
1773
1806
|
}
|
|
1774
1807
|
for (const field of COMPLETION_DATE_FIELDS) {
|
|
1775
1808
|
if (parseCalendarDate(record[field])) return record[field];
|
package/src/program-path.js
CHANGED
|
@@ -111,7 +111,7 @@ export const PROGRAM_PATH = [
|
|
|
111
111
|
summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
|
|
112
112
|
sections: [
|
|
113
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: "
|
|
114
|
+
{ id: "retention", title: "Data Retention Schedule", description: "Complete each row proposal, then approve the document and all proposed rows once.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Have the owner finish each row for the Information Types and operational sources the organization actually uses.", "Trigger one independent approval only after the document and every row proposal are complete."], types: [], utility: "retention-schedule", defaultOpen: true }
|
|
115
115
|
],
|
|
116
116
|
resourceTypes: [],
|
|
117
117
|
supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
|
package/src/program-readiness.js
CHANGED
|
@@ -98,11 +98,17 @@ function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
|
|
|
98
98
|
const units = new Map();
|
|
99
99
|
for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
|
|
100
100
|
for (const current of currentStage.items || []) {
|
|
101
|
-
if (
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
if (current.progressUnit === false) continue;
|
|
102
|
+
const candidates = current.progressUnits?.length
|
|
103
|
+
? current.progressUnits
|
|
104
|
+
: [current];
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (["info", "later"].includes(candidate.status) || candidate.progressUnit === false) continue;
|
|
107
|
+
const key = `${currentStage.id}:${candidate.progressUnitId || current.progressUnitId || candidate.id || current.id}`;
|
|
108
|
+
const existing = units.get(key);
|
|
109
|
+
if (!existing || existing.status === "complete" && candidate.status !== "complete") {
|
|
110
|
+
units.set(key, candidate);
|
|
111
|
+
}
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
}
|
|
@@ -154,13 +160,24 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
154
160
|
.map(collectionReviewReadinessItem));
|
|
155
161
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
156
162
|
controlStage.items.push(...sourceStage.items);
|
|
163
|
+
const retentionScheduleReview = collectionReviews.find(({ resourceType }) => resourceType === "retention-schedule-item");
|
|
157
164
|
const retentionItems = await assessRetentionReadiness(loaded, program, {
|
|
158
|
-
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
|
|
165
|
+
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true,
|
|
166
|
+
scheduleApproved: retentionScheduleReview?.complete === true
|
|
159
167
|
});
|
|
160
|
-
const retentionReviewItems =
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
const retentionReviewItems = retentionScheduleReview
|
|
169
|
+
? [collectionReviewReadinessItem(retentionScheduleReview)]
|
|
170
|
+
: [];
|
|
163
171
|
if (modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
172
|
+
const scheduleDocumentIds = new Set(records.filter((record) => (
|
|
173
|
+
record.type === "document"
|
|
174
|
+
&& record.documentKind === "schedule"
|
|
175
|
+
&& record.workflowScope === "program"
|
|
176
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
177
|
+
)).map(({ id }) => id));
|
|
178
|
+
policyStage.items = policyStage.items.filter(({ id }) => (
|
|
179
|
+
![...scheduleDocumentIds].some((documentId) => id === `document-approval-${documentId}`)
|
|
180
|
+
));
|
|
164
181
|
policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
|
|
165
182
|
controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
|
|
166
183
|
policyStage.items.push(...retentionReviewItems);
|
|
@@ -395,7 +412,10 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
395
412
|
? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
|
|
396
413
|
: "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
|
|
397
414
|
scope.components[0] || { type: "component" },
|
|
398
|
-
{
|
|
415
|
+
{
|
|
416
|
+
componentIds: scope.components.map(({ id }) => id),
|
|
417
|
+
progressUnit: false
|
|
418
|
+
}
|
|
399
419
|
));
|
|
400
420
|
}
|
|
401
421
|
|
|
@@ -562,7 +582,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
562
582
|
return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
563
583
|
}
|
|
564
584
|
|
|
565
|
-
function reportingRouteSetItem(assessment) {
|
|
585
|
+
export function reportingRouteSetItem(assessment) {
|
|
566
586
|
const current = assessment.routeSets.find(({ effective, canceled }) => effective && !canceled);
|
|
567
587
|
const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
|
|
568
588
|
const required = assessment.requirements.length > 0;
|
|
@@ -571,13 +591,18 @@ function reportingRouteSetItem(assessment) {
|
|
|
571
591
|
.map(({ purposeKey }) => purposeKey)
|
|
572
592
|
.filter(Boolean))];
|
|
573
593
|
const preparedPurposeKeys = new Set(assessment.routeSets
|
|
574
|
-
.filter(({ record, committed, canceled, proposedRequirementIssues }) => (
|
|
575
|
-
["proposed", "approved"].includes(record.status)
|
|
594
|
+
.filter(({ record, committed, effective, canceled, proposedRequirementIssues }) => (
|
|
595
|
+
(["proposed", "approved"].includes(record.status) || effective)
|
|
576
596
|
&& committed
|
|
577
597
|
&& !canceled
|
|
578
598
|
&& proposedRequirementIssues.length === 0
|
|
579
599
|
))
|
|
580
600
|
.map(({ record }) => record.purposeKey));
|
|
601
|
+
const requiredPurposeKeys = new Set(assessment.requirements.map(({ purposeKey }) => purposeKey).filter(Boolean));
|
|
602
|
+
const purposeKeys = [...new Set([
|
|
603
|
+
...requiredPurposeKeys,
|
|
604
|
+
...proposedPurposeKeys
|
|
605
|
+
])];
|
|
581
606
|
const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
|
|
582
607
|
const ready = assessment.issues.length === 0
|
|
583
608
|
&& (!required || Boolean(current))
|
|
@@ -612,6 +637,42 @@ function reportingRouteSetItem(assessment) {
|
|
|
612
637
|
proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
|
|
613
638
|
unpreparedPurposeKeys,
|
|
614
639
|
issues: assessment.issues,
|
|
640
|
+
progressUnits: purposeKeys.flatMap((purposeKey) => {
|
|
641
|
+
const purposeResourceIds = new Set([
|
|
642
|
+
...assessment.routeSets.filter(({ record }) => record.purposeKey === purposeKey).map(({ record }) => record.id),
|
|
643
|
+
...assessment.requirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId),
|
|
644
|
+
...assessment.proposedRequirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId)
|
|
645
|
+
].filter(Boolean));
|
|
646
|
+
const purposeIssues = assessment.issues.filter(({ resourceId }) => (
|
|
647
|
+
resourceId === assessment.programId || purposeResourceIds.has(resourceId)
|
|
648
|
+
));
|
|
649
|
+
const approvedSet = assessment.routeSets.find(({ record, effective, canceled }) => (
|
|
650
|
+
record.purposeKey === purposeKey && effective && !canceled
|
|
651
|
+
));
|
|
652
|
+
const proposedSet = assessment.routeSets.find(({ record, effective, canceled }) => (
|
|
653
|
+
record.purposeKey === purposeKey
|
|
654
|
+
&& !canceled
|
|
655
|
+
&& (effective || ["proposed", "approved"].includes(record.status))
|
|
656
|
+
));
|
|
657
|
+
const proposalComplete = Boolean(
|
|
658
|
+
(approvedSet || proposedSet?.committed)
|
|
659
|
+
&& proposedSet?.proposedRequirementIssues.length === 0
|
|
660
|
+
);
|
|
661
|
+
return [
|
|
662
|
+
{
|
|
663
|
+
id: `reporting-route-proposal-${purposeKey}`,
|
|
664
|
+
status: proposalComplete ? "complete" : "action",
|
|
665
|
+
title: `${proposedSet?.record.title || purposeKey} proposal`
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
id: `reporting-route-approval-${purposeKey}`,
|
|
669
|
+
status: approvedSet && purposeIssues.length === 0
|
|
670
|
+
? "complete"
|
|
671
|
+
: requiredPurposeKeys.has(purposeKey) ? "action" : "later",
|
|
672
|
+
title: `${proposedSet?.record.title || purposeKey} approval`
|
|
673
|
+
}
|
|
674
|
+
];
|
|
675
|
+
}),
|
|
615
676
|
commands: [
|
|
616
677
|
"npx filegrc reporting-route-sets --json",
|
|
617
678
|
"npx filegrc reporting-route-set --scaffold"
|
|
@@ -646,10 +707,34 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
|
|
|
646
707
|
);
|
|
647
708
|
}
|
|
648
709
|
|
|
649
|
-
function collectionReviewReadinessItem(assessment) {
|
|
710
|
+
export function collectionReviewReadinessItem(assessment) {
|
|
711
|
+
const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
|
|
712
|
+
id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
|
|
713
|
+
status: proposal.complete ? "complete" : "action",
|
|
714
|
+
title: `${proposal.title} proposal`
|
|
715
|
+
}));
|
|
716
|
+
const proposalBatchComplete = proposalUnits.every(({ status }) => status === "complete");
|
|
717
|
+
const scheduleApprovalBlocked = assessment.resourceType === "retention-schedule-item"
|
|
718
|
+
&& proposalUnits.some(({ status }) => status !== "complete");
|
|
719
|
+
const batchProgressUnits = assessment.resourceType !== "retention-schedule-item" && proposalUnits.length
|
|
720
|
+
? [
|
|
721
|
+
{
|
|
722
|
+
id: `${assessment.resourceType}-proposal-batch`,
|
|
723
|
+
status: proposalBatchComplete ? "complete" : "action",
|
|
724
|
+
title: `${assessment.configuration.title} proposals`
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
id: `${assessment.resourceType}-collection-review`,
|
|
728
|
+
status: proposalBatchComplete
|
|
729
|
+
? assessment.complete ? "complete" : "action"
|
|
730
|
+
: "blocked",
|
|
731
|
+
title: `${assessment.configuration.title} collection review`
|
|
732
|
+
}
|
|
733
|
+
]
|
|
734
|
+
: null;
|
|
650
735
|
return item(
|
|
651
736
|
`collection-review-${assessment.resourceType}`,
|
|
652
|
-
assessment.complete ? "complete" : "action",
|
|
737
|
+
assessment.complete ? "complete" : scheduleApprovalBlocked ? "blocked" : "action",
|
|
653
738
|
assessment.status === "stale"
|
|
654
739
|
? `Review ${assessment.configuration.title.toLowerCase()} again`
|
|
655
740
|
: `Review ${assessment.configuration.title.toLowerCase()}`,
|
|
@@ -658,6 +743,7 @@ function collectionReviewReadinessItem(assessment) {
|
|
|
658
743
|
{
|
|
659
744
|
resourceType: assessment.resourceType,
|
|
660
745
|
reviewPoints: assessment.configuration.reviewPoints,
|
|
746
|
+
...(batchProgressUnits ? { progressUnits: batchProgressUnits } : {}),
|
|
661
747
|
commands: [
|
|
662
748
|
`npx filegrc review-collection ${assessment.resourceType} --scaffold`,
|
|
663
749
|
`npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
|
|
@@ -1568,6 +1654,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1568
1654
|
: `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative ${componentSources ? "Component" : "System"} with the required source role, access owners, and extraction instructions.`,
|
|
1569
1655
|
completeSources[0] || selectedSources[0] || { type: sourceType },
|
|
1570
1656
|
{
|
|
1657
|
+
progressUnit: false,
|
|
1571
1658
|
familyId: family.id,
|
|
1572
1659
|
sourceKinds: family.sourceKinds,
|
|
1573
1660
|
controlIds: family.controls.map((control) => control.id),
|