filegrc 0.15.2 → 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/package.json +1 -1
- package/src/audit-preparation.js +18 -12
- package/src/obligation-members.js +31 -0
- package/src/obligations.js +39 -6
- package/src/program-readiness.js +23 -14
- package/src/retention.js +1 -0
- package/src/validate.js +2 -12
- package/src/web.js +9 -2
package/package.json
CHANGED
package/src/audit-preparation.js
CHANGED
|
@@ -997,24 +997,30 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
|
|
|
997
997
|
export function occurrenceProgressUnits(plan, { selectedControlIds, periodStart, periodThrough }) {
|
|
998
998
|
return [
|
|
999
999
|
...plan.calendarItems.flatMap((occurrence) => {
|
|
1000
|
-
const
|
|
1000
|
+
const resolvedMemberIds = new Set(occurrence.resolvedMemberIds || occurrence.completedMemberIds || []);
|
|
1001
|
+
const membersComplete = occurrence.membershipFinal && (occurrence.expectedMemberIds || [])
|
|
1002
|
+
.every((resourceId) => resolvedMemberIds.has(resourceId));
|
|
1001
1003
|
const conclusionComplete = occurrence.reconciliationStatus === "reconciled"
|
|
1002
|
-
&& ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.operatingResult)
|
|
1004
|
+
&& ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.operatingResult)
|
|
1005
|
+
&& membersComplete
|
|
1006
|
+
&& occurrence.conclusionValid !== false;
|
|
1003
1007
|
const occurrenceComplete = conclusionComplete || occurrence.legacySchedule && occurrence.status === "complete";
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
return
|
|
1011
|
-
|
|
1008
|
+
const workComplete = occurrenceComplete || membersComplete;
|
|
1009
|
+
const workUnit = {
|
|
1010
|
+
id: `occurrence-${occurrence.occurrenceKey}-work`,
|
|
1011
|
+
status: workComplete ? "complete" : "action",
|
|
1012
|
+
title: `${occurrence.title} occurrence work`
|
|
1013
|
+
};
|
|
1014
|
+
return occurrence.legacySchedule
|
|
1015
|
+
? [workUnit]
|
|
1016
|
+
: [
|
|
1017
|
+
workUnit,
|
|
1012
1018
|
{
|
|
1013
1019
|
id: `occurrence-${occurrence.occurrenceKey}-reconciliation`,
|
|
1014
|
-
status: occurrenceComplete ? "complete" :
|
|
1020
|
+
status: occurrenceComplete ? "complete" : workComplete && occurrence.membershipFinal ? "action" : "blocked",
|
|
1015
1021
|
title: `${occurrence.title} reconciliation`
|
|
1016
1022
|
}
|
|
1017
|
-
|
|
1023
|
+
];
|
|
1018
1024
|
}),
|
|
1019
1025
|
...plan.eventRuns
|
|
1020
1026
|
.filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
|
|
@@ -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-readiness.js
CHANGED
|
@@ -707,15 +707,34 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
|
|
|
707
707
|
);
|
|
708
708
|
}
|
|
709
709
|
|
|
710
|
-
function collectionReviewReadinessItem(assessment) {
|
|
710
|
+
export function collectionReviewReadinessItem(assessment) {
|
|
711
711
|
const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
|
|
712
712
|
id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
|
|
713
713
|
status: proposal.complete ? "complete" : "action",
|
|
714
714
|
title: `${proposal.title} proposal`
|
|
715
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;
|
|
716
735
|
return item(
|
|
717
736
|
`collection-review-${assessment.resourceType}`,
|
|
718
|
-
assessment.complete ? "complete" : "action",
|
|
737
|
+
assessment.complete ? "complete" : scheduleApprovalBlocked ? "blocked" : "action",
|
|
719
738
|
assessment.status === "stale"
|
|
720
739
|
? `Review ${assessment.configuration.title.toLowerCase()} again`
|
|
721
740
|
: `Review ${assessment.configuration.title.toLowerCase()}`,
|
|
@@ -724,18 +743,7 @@ function collectionReviewReadinessItem(assessment) {
|
|
|
724
743
|
{
|
|
725
744
|
resourceType: assessment.resourceType,
|
|
726
745
|
reviewPoints: assessment.configuration.reviewPoints,
|
|
727
|
-
...(
|
|
728
|
-
progressUnits: [
|
|
729
|
-
...proposalUnits,
|
|
730
|
-
{
|
|
731
|
-
id: `${assessment.resourceType}-collection-review`,
|
|
732
|
-
status: proposalUnits.every(({ status }) => status === "complete")
|
|
733
|
-
? assessment.complete ? "complete" : "action"
|
|
734
|
-
: "blocked",
|
|
735
|
-
title: `${assessment.configuration.title} collection review`
|
|
736
|
-
}
|
|
737
|
-
]
|
|
738
|
-
} : {}),
|
|
746
|
+
...(batchProgressUnits ? { progressUnits: batchProgressUnits } : {}),
|
|
739
747
|
commands: [
|
|
740
748
|
`npx filegrc review-collection ${assessment.resourceType} --scaffold`,
|
|
741
749
|
`npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
|
|
@@ -1646,6 +1654,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1646
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.`,
|
|
1647
1655
|
completeSources[0] || selectedSources[0] || { type: sourceType },
|
|
1648
1656
|
{
|
|
1657
|
+
progressUnit: false,
|
|
1649
1658
|
familyId: family.id,
|
|
1650
1659
|
sourceKinds: family.sourceKinds,
|
|
1651
1660
|
controlIds: family.controls.map((control) => control.id),
|
package/src/retention.js
CHANGED
|
@@ -137,6 +137,7 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
137
137
|
: `${duplicates.length} similar pair${duplicates.length === 1 ? " needs" : "s need"} management review. FileGRC will not merge records or rewrite relationships automatically.`,
|
|
138
138
|
{ type: "information-type" },
|
|
139
139
|
{
|
|
140
|
+
progressUnit: false,
|
|
140
141
|
candidates: duplicates,
|
|
141
142
|
commands: [
|
|
142
143
|
"npx filegrc list information-type --workflow --json",
|
package/src/validate.js
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
recordsAtRevision
|
|
45
45
|
} from "./reporting-route-integrity.js";
|
|
46
46
|
import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
|
|
47
|
+
import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
|
|
47
48
|
|
|
48
49
|
let fingerprintFileReadObserver = null;
|
|
49
50
|
|
|
@@ -667,18 +668,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w
|
|
|
667
668
|
));
|
|
668
669
|
}
|
|
669
670
|
if (member.disposition === "exception") {
|
|
670
|
-
|
|
671
|
-
const occurrenceStart = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.startsOn;
|
|
672
|
-
const occurrenceEnd = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.endsOn;
|
|
673
|
-
const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
|
|
674
|
-
|| exception?.scopeResourceIds?.includes(record.obligationId);
|
|
675
|
-
if (
|
|
676
|
-
exception?.type !== "exception"
|
|
677
|
-
|| exception.status !== "approved"
|
|
678
|
-
|| !exceptionCoversMember
|
|
679
|
-
|| exception.approval?.approvedOn > occurrenceStart
|
|
680
|
-
|| exception.approval?.expiresOn < occurrenceEnd
|
|
681
|
-
) {
|
|
671
|
+
if (!occurrenceMemberExceptionIsValid(member, record, byId)) {
|
|
682
672
|
diagnostics.push(error(
|
|
683
673
|
"invalid-member-exception",
|
|
684
674
|
path,
|
package/src/web.js
CHANGED
|
@@ -670,7 +670,8 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
670
670
|
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About Data Retention Schedule" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
671
671
|
const initialFilter = params.get("q") || "";
|
|
672
672
|
const showHistory = params.get("history") === "1";
|
|
673
|
-
const
|
|
673
|
+
const retiredRows = rows.filter(({ record }) => ["superseded", "retired"].includes(record.status)).length;
|
|
674
|
+
const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label>' + (retiredRows ? '<label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show ' + retiredRows + ' retired ' + pluralize("row", retiredRows) + '</label>' : '<input type="checkbox" data-retention-history hidden>') + '</div>';
|
|
674
675
|
main.innerHTML = '<div class="page retention-schedule-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Data Retention Schedule</span></nav>' +
|
|
675
676
|
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
|
|
676
677
|
'<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + '</strong><small>Final approval follows every row proposal</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
|
|
@@ -746,7 +747,13 @@ function wireRetentionScheduleTable(total, initialPage = 1) {
|
|
|
746
747
|
noResults.hidden = filtered.length !== 0 || total === 0;
|
|
747
748
|
const firstVisible = filtered.length ? start + 1 : 0;
|
|
748
749
|
const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
|
|
749
|
-
|
|
750
|
+
const hiddenByHistory = historyToggle.checked ? 0 : [...table.querySelectorAll('[data-retention-row][data-history="true"]')]
|
|
751
|
+
.filter((row) => !query || row.textContent.toLowerCase().includes(query)).length;
|
|
752
|
+
const matching = filtered.length + hiddenByHistory;
|
|
753
|
+
count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + (historyToggle.checked ? " shown " : " active ") + pluralize("row", filtered.length)
|
|
754
|
+
+ (hiddenByHistory ? " · " + hiddenByHistory + " retired hidden" : "")
|
|
755
|
+
+ (query ? " · " + matching + " matching" : "")
|
|
756
|
+
+ " · " + total + " total";
|
|
750
757
|
pagination.hidden = totalPages === 1;
|
|
751
758
|
pageStatus.textContent = "Page " + pageNumber + " of " + totalPages;
|
|
752
759
|
previous.disabled = pageNumber === 1;
|