filegrc 0.16.8 → 0.16.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.8",
3
+ "version": "0.16.10",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -117,13 +117,16 @@ export function applicabilityScopeRevision(record, program, resources, model, op
117
117
  policies: resources
118
118
  .filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
119
119
  .sort(compareRecordIds)
120
- .map((policy) => options.legacyPolicyStatus
120
+ .map((policy) => options.legacyPolicyStatus || options.legacyPolicyOverrides
121
121
  ? pick({
122
122
  ...policy,
123
- status: options.legacyPolicyStatus === "approved" && ["in-review", "active"].includes(policy.status)
124
- ? "approved" : policy.status,
125
- effectiveOn: options.legacyPolicyStatus === "approved" && policy.status === "active"
126
- ? undefined : policy.effectiveOn
123
+ status: (options.legacyPolicyOverrides?.[policy.id]?.status || options.legacyPolicyStatus || "current") === "current"
124
+ ? policy.status : options.legacyPolicyOverrides?.[policy.id]?.status || options.legacyPolicyStatus,
125
+ effectiveOn: (options.legacyPolicyOverrides?.[policy.id]?.withoutEffectiveOn
126
+ ?? options.legacyPolicyWithoutEffectiveOn) ? undefined : policy.effectiveOn,
127
+ approvedContentRevisions: (options.legacyPolicyOverrides?.[policy.id]?.withoutApprovalBinding
128
+ ?? options.legacyPolicyWithoutApprovalBinding)
129
+ ? undefined : policy.approvedContentRevisions
127
130
  }, [
128
131
  "id", "status", "policyKind", "version", "programRole", "effectiveOn",
129
132
  "requirementIds", "audience", "acknowledgementRequired",
@@ -168,11 +171,50 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
168
171
  if (!review?.scopeRevision) return false;
169
172
  const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
170
173
  if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
171
- // Reviews written before the scope fix included Policy lifecycle fields. Accept
172
- // that earlier fingerprint only when its material inputs still match.
173
- if (matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "current" }))) return true;
174
- return resources.some(({ type, status }) => type === "policy" && ["in-review", "active"].includes(status))
175
- && matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "approved" }));
174
+ // Older reviews hashed Policy approval metadata alongside scope facts.
175
+ // Reconstruct each Policy independently because other Policies may already
176
+ // have been approved when one Policy changes. Material fields stay current.
177
+ for (const legacyPolicyStatus of ["current", "draft", "in-review", "approved", "active"]) {
178
+ for (const legacyPolicyWithoutApprovalBinding of [false, true]) {
179
+ for (const legacyPolicyWithoutEffectiveOn of [false, true]) {
180
+ if (matches(applicabilityScopeRevision(record, program, resources, model, {
181
+ legacyPolicyStatus,
182
+ legacyPolicyWithoutApprovalBinding,
183
+ legacyPolicyWithoutEffectiveOn
184
+ }))) return true;
185
+ }
186
+ }
187
+ }
188
+ const selectedControlIds = new Set(program?.controlIds || []);
189
+ const selectedPolicyIds = new Set(resources
190
+ .filter(({ type, id }) => type === "control" && selectedControlIds.has(id))
191
+ .flatMap(({ policyIds }) => policyIds || []));
192
+ for (const id of record.policyIds || []) selectedPolicyIds.add(id);
193
+ const policies = resources.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id));
194
+ // One Policy may advance while the others retain different approval states.
195
+ // Reconstruct that Policy's former lifecycle fields without changing any
196
+ // material scope fact or searching combinations of unrelated Policies.
197
+ for (const policy of policies) {
198
+ for (const value of legacyPolicyAlternatives(policy)) {
199
+ if (matches(applicabilityScopeRevision(record, program, resources, model, {
200
+ legacyPolicyOverrides: { [policy.id]: value }
201
+ }))) return true;
202
+ }
203
+ }
204
+ return false;
205
+ }
206
+
207
+ function legacyPolicyAlternatives(policy) {
208
+ const prior = {
209
+ active: ["approved", "in-review", "draft"],
210
+ approved: ["in-review", "draft"],
211
+ "in-review": ["draft"]
212
+ }[policy.status] || [];
213
+ return prior.map((status) => ({
214
+ status,
215
+ withoutApprovalBinding: status === "draft" || status === "in-review",
216
+ withoutEffectiveOn: true
217
+ }));
176
218
  }
177
219
 
178
220
  function pick(record, fields, model, objectType = null) {
@@ -1831,6 +1831,9 @@ function item(id, status, title, message, resource = {}, options = {}) {
1831
1831
  message,
1832
1832
  ...(resource.type ? { resourceType: resource.type } : {}),
1833
1833
  ...(resource.id ? { resourceId: resource.id } : {}),
1834
+ ...(resource.type ? { destination: resource.id
1835
+ ? { kind: "record", resourceType: resource.type, resourceId: resource.id }
1836
+ : { kind: "collection", resourceType: resource.type } } : {}),
1834
1837
  ...(options.commands?.length ? { commands: options.commands } : {}),
1835
1838
  ...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
1836
1839
  ...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
@@ -774,9 +774,23 @@ export function collectionReviewReadinessItem(assessment) {
774
774
  ? { id: firstIncomplete.resourceId, type: assessment.resourceType }
775
775
  : needsFirstRecord ? { type: assessment.resourceType }
776
776
  : approvalAction ? { type: approvalAction.resourceType, ...(approvalAction.resourceId ? { id: approvalAction.resourceId } : {}) }
777
- : assessment.review || { type: assessment.resourceType },
777
+ : { type: assessment.resourceType },
778
778
  {
779
779
  resourceType: approvalAction?.resourceType || assessment.resourceType,
780
+ ...(!proposalAction && !needsFirstRecord && !approvalAction && assessment.review?.id
781
+ ? { subject: { type: "collection-review", id: assessment.review.id } }
782
+ : {}),
783
+ destination: proposalAction
784
+ ? { kind: "record", resourceType: assessment.resourceType, resourceId: firstIncomplete.resourceId }
785
+ : needsFirstRecord
786
+ ? { kind: "collection", resourceType: assessment.resourceType, create: true }
787
+ : approvalAction
788
+ ? approvalAction.reviewCollection
789
+ ? { kind: "collection-review", resourceType: approvalAction.resourceType }
790
+ : approvalAction.resourceId
791
+ ? { kind: "record", resourceType: approvalAction.resourceType, resourceId: approvalAction.resourceId }
792
+ : { kind: "collection", resourceType: approvalAction.resourceType, ...(approvalAction.createResource ? { create: true, scheduleDocument: true } : {}) }
793
+ : { kind: "collection-review", resourceType: assessment.resourceType },
780
794
  reviewPoints: assessment.configuration.reviewPoints,
781
795
  ...(approvalAction?.createResource ? { createResource: true } : {}),
782
796
  ...(pendingRetentionRow && !proposalAction ? { unresolvedAssignments: [{
@@ -842,6 +856,7 @@ function collectionApprovalIssueAction(assessment) {
842
856
  if (issue.code === "unreviewed-similar-information-types") return {
843
857
  title: "Review similar Information Types",
844
858
  resourceType: "information-type",
859
+ reviewCollection: true,
845
860
  commands: ["npx filegrc list information-type --workflow --json", "npx filegrc review-collection information-type --scaffold > REVIEW.json", "npx filegrc review-collection information-type REVIEW.json --preview --json", "npx filegrc review-collection information-type REVIEW.json --yes --json"]
846
861
  };
847
862
  return {
package/src/web.js CHANGED
@@ -53,6 +53,38 @@ export function pageActionIdentity(href = "", fallback = "") {
53
53
  return route.startsWith("#/resource/") ? route : fallback || href;
54
54
  }
55
55
 
56
+ export function resolveWorkflowDestination(destination, options = {}) {
57
+ if (!destination?.resourceType) return "";
58
+ if (options.resourceTypes && !options.resourceTypes[destination.resourceType]) return "";
59
+ const type = encodeURIComponent(destination.resourceType);
60
+ if (destination.kind === "record") {
61
+ const exists = !options.resources || options.resources.some((entry) => {
62
+ const record = entry.record || entry;
63
+ return record.type === destination.resourceType && record.id === destination.resourceId;
64
+ });
65
+ return destination.resourceId && exists
66
+ ? `#/resource/${type}/${encodeURIComponent(destination.resourceId)}${destination.edit ? "?edit=1" : ""}`
67
+ : resolveWorkflowDestination({ kind: "collection", resourceType: destination.resourceType }, options);
68
+ }
69
+ if (destination.kind === "collection-review") {
70
+ if (destination.resourceType === "retention-schedule-item" && options.retentionScheduleApproval) {
71
+ return "#/retention-schedule";
72
+ }
73
+ return `#/resources/${type}?review-collection=1`;
74
+ }
75
+ if (destination.kind === "collection") {
76
+ const create = destination.create && !options.readOnly;
77
+ if (destination.resourceType === "retention-schedule-item" && options.retentionScheduleApproval) {
78
+ return create ? "#/retention-schedule?new=1" : "#/retention-schedule";
79
+ }
80
+ if (destination.resourceType === "document" && create && destination.scheduleDocument) {
81
+ return "#/resources/document?new=1&documentScope=program&documentKind=schedule&programRole=required&title=Data%20Retention%20Schedule";
82
+ }
83
+ return `#/resources/${type}${create ? "?new=1" : ""}`;
84
+ }
85
+ return "";
86
+ }
87
+
56
88
  export function renderIndex(state = null) {
57
89
  const snapshot = state
58
90
  ? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
@@ -100,6 +132,7 @@ const RESOURCE_GUIDE_OUTPUTS = ${JSON.stringify(RESOURCE_OUTPUTS)};
100
132
  const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
101
133
  const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
102
134
  const pageActionIdentity = ${pageActionIdentity.toString()};
135
+ const resolveWorkflowDestination = ${resolveWorkflowDestination.toString()};
103
136
  const selectDefaultAudit = ${selectDefaultAudit.toString()};
104
137
  const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
105
138
  ...RESOURCE_PAGE_SUMMARIES,
@@ -786,11 +819,7 @@ function renderRetentionScheduleIssues() {
786
819
  ));
787
820
  if (!actions.length) return "";
788
821
  return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
789
- const href = item.href || (item.id?.startsWith("retention-use-")
790
- ? retentionScheduleItemHref(item)
791
- : item.resourceId && item.resourceType
792
- ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
793
- : '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item"));
822
+ const href = item.href || workflowItemHref(item) || "#/retention-schedule";
794
823
  return '<a href="' + href + '"><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span><b>Review</b></a>';
795
824
  }).join("") + '</div></section>';
796
825
  }
@@ -1286,9 +1315,7 @@ function renderFinishStepThree() {
1286
1315
  ));
1287
1316
  const next = implementationItems.find(({ status }) => status !== "complete");
1288
1317
  if (!oversightItem) {
1289
- const href = next?.id?.startsWith("collection-review-") && next.resourceType
1290
- ? "#/resources/" + encodeURIComponent(next.resourceType) + "?review-collection=1"
1291
- : next?.id?.startsWith("source-family-")
1318
+ const href = next?.id?.startsWith("source-family-")
1292
1319
  ? "#/evidence-sources"
1293
1320
  : next ? workflowItemHref({ ...next, state: next.status }) || "#/resources/control" : "#/resources/control";
1294
1321
  return '<section class="finish-step panel blocked"><div><p class="kicker">Finish Step 3</p><h2>Complete implementation first</h2><p>' + esc(next?.message || "Finish the Step 3 work areas before the final review becomes available.") + '</p></div><a class="button primary" href="' + esc(href) + '">Continue implementation</a></section>';
@@ -1490,6 +1517,16 @@ function openCollectionReviewDialog(type) {
1490
1517
  }
1491
1518
 
1492
1519
  function workflowItemHref(item) {
1520
+ const destinationOptions = {
1521
+ retentionScheduleApproval: modelSupports("retention-schedule-approval"),
1522
+ readOnly: state.readOnly,
1523
+ resourceTypes: state.model.resources,
1524
+ resources: state.resources
1525
+ };
1526
+ const destinationHref = (destination) => resolveWorkflowDestination(destination, destinationOptions);
1527
+ if (item.destination) {
1528
+ return destinationHref(item.destination);
1529
+ }
1493
1530
  const source = item.source?.id && item.source?.type && item.source.type !== "unknown"
1494
1531
  ? item.source
1495
1532
  : null;
@@ -1518,19 +1555,18 @@ function workflowItemHref(item) {
1518
1555
  && item.subject?.id
1519
1556
  && state.resources.some(({ record }) => record.type === applicabilityType && record.id === item.subject.id)
1520
1557
  ) {
1521
- return "#/resource/" + encodeURIComponent(applicabilityType) + "/" + encodeURIComponent(item.subject.id);
1558
+ return destinationHref({ kind: "record", resourceType: applicabilityType, resourceId: item.subject.id });
1522
1559
  }
1523
1560
  if (commands.some((command) => command.includes(" scaffold retention-schedule-item"))) {
1524
1561
  return retentionScheduleItemHref(item);
1525
1562
  }
1526
1563
  if (applicabilityType && state.model.resources[applicabilityType]) {
1527
- return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
1564
+ return destinationHref({ kind: "collection", resourceType: applicabilityType }) + "?review=1";
1528
1565
  }
1529
1566
  const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
1530
1567
  const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
1531
1568
  if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
1532
- if (collectionReviewType === "retention-schedule-item" && modelSupports("retention-schedule-approval")) return "#/retention-schedule";
1533
- return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
1569
+ return destinationHref({ kind: "collection-review", resourceType: collectionReviewType });
1534
1570
  }
1535
1571
  if (
1536
1572
  commands.some((command) => command.includes(" get ") && command.includes(" --mutation"))
@@ -1538,25 +1574,25 @@ function workflowItemHref(item) {
1538
1574
  && item.subject?.type
1539
1575
  && state.model.resources[item.subject.type]
1540
1576
  ) {
1541
- return "#/resource/" + encodeURIComponent(item.subject.type) + "/" + encodeURIComponent(item.subject.id) + "?edit=1";
1577
+ return destinationHref({ kind: "record", resourceType: item.subject.type, resourceId: item.subject.id, edit: true });
1542
1578
  }
1543
1579
  if (commands.some((command) => command.includes(" external-reviewer-setup"))) {
1544
1580
  const reviewer = resourcesOfType("appointment")
1545
1581
  .find(({ record }) => record.appointmentKind === "independent-policy-reviewer");
1546
- if (reviewer) return "#/resource/appointment/" + encodeURIComponent(reviewer.record.id);
1582
+ if (reviewer) return destinationHref({ kind: "record", resourceType: "appointment", resourceId: reviewer.record.id });
1547
1583
  }
1548
1584
  if (commands.some((command) => command.includes(" evidence-map"))) return "#/stage/controls";
1549
1585
  const reference = item.subject?.id && item.subject?.type
1550
1586
  ? item.subject
1551
1587
  : source;
1552
1588
  if (reference && reference.type !== "unknown") {
1553
- return "#/resource/" + encodeURIComponent(reference.type) + "/" + encodeURIComponent(reference.id);
1589
+ return destinationHref({ kind: "record", resourceType: reference.type, resourceId: reference.id });
1554
1590
  }
1555
1591
  const missingType = item.subject?.type && state.model.resources[item.subject.type]
1556
1592
  ? item.subject.type
1557
1593
  : null;
1558
1594
  if (missingType) {
1559
- return "#/resources/" + encodeURIComponent(missingType) + (state.readOnly ? "" : "?new=1");
1595
+ return destinationHref({ kind: "collection", resourceType: missingType, create: !state.readOnly });
1560
1596
  }
1561
1597
  if (item.auditId) return "#/audit-packet?auditId=" + encodeURIComponent(item.auditId);
1562
1598
  const stage = {
@@ -1714,13 +1750,9 @@ function retentionSchedulePageItems() {
1714
1750
  ...item,
1715
1751
  key: item.id,
1716
1752
  state: "ready",
1717
- href: item.id === "collection-review-retention-schedule-item"
1718
- ? retentionCollectionActionHref(item)
1719
- : item.id?.startsWith("retention-use-")
1753
+ href: item.id?.startsWith("retention-use-")
1720
1754
  ? retentionScheduleItemHref(item)
1721
- : item.resourceId && item.resourceType
1722
- ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
1723
- : workflowItemHref(item) || "#/retention-schedule",
1755
+ : workflowItemHref(item) || "#/retention-schedule",
1724
1756
  subject: item.resourceId && item.resourceType
1725
1757
  ? { type: item.resourceType, id: item.resourceId }
1726
1758
  : item.subject
@@ -1728,19 +1760,6 @@ function retentionSchedulePageItems() {
1728
1760
  ]);
1729
1761
  }
1730
1762
 
1731
- function retentionCollectionActionHref(item) {
1732
- if (item.resourceType === "document") return item.resourceId
1733
- ? '#/resource/document/' + encodeURIComponent(item.resourceId) + '?stage=policies'
1734
- : item.createResource
1735
- ? '#/resources/document?new=1&documentScope=program&documentKind=schedule&programRole=required&title=Data%20Retention%20Schedule'
1736
- : '#/resources/document';
1737
- if (item.resourceType === "information-type") return '#/resources/information-type?review-collection=1';
1738
- if (item.resourceType === "retention-schedule-item" && item.resourceId && item.resourceId !== item.id) {
1739
- return '#/resource/retention-schedule-item/' + encodeURIComponent(item.resourceId) + '?stage=policies';
1740
- }
1741
- return "#/retention-schedule";
1742
- }
1743
-
1744
1763
  function distinctPageActionItems(items) {
1745
1764
  const distinct = new Map();
1746
1765
  for (const item of items) {
@@ -3017,11 +3036,7 @@ function renderAuditPreparation(preparation) {
3017
3036
  const status = preparation.status === "management-ready" ? "Management work complete" : preparation.counts.action + " actions remaining";
3018
3037
  const stages = preparation.stages.map((stage) => {
3019
3038
  const items = stage.items.map((item) => {
3020
- const href = item.resourceId
3021
- ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
3022
- : item.resourceType
3023
- ? '#/resources/' + encodeURIComponent(item.resourceType)
3024
- : "";
3039
+ const href = workflowItemHref(item);
3025
3040
  const content = '<span class="preparation-status ' + esc(item.status) + '" aria-hidden="true">' + preparationStatusMark(item.status) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span>';
3026
3041
  return href ? '<a href="' + href + '">' + content + '</a>' : '<div>' + content + '</div>';
3027
3042
  }).join("");
package/src/workflow.js CHANGED
@@ -1115,9 +1115,9 @@ export function packetDeliveryIssue(delivery) {
1115
1115
 
1116
1116
  function normalizeFinding(code, item, context) {
1117
1117
  const state = findingState(item.status);
1118
- const subject = item.resourceType
1118
+ const subject = item.subject || (item.resourceType
1119
1119
  ? { type: item.resourceType, ...(item.resourceId ? { id: item.resourceId } : {}) }
1120
- : null;
1120
+ : null);
1121
1121
  const dependencies = (item.unresolvedAssignments || []).map((assignment) => ({
1122
1122
  type: assignment.resourceType,
1123
1123
  id: assignment.resourceId,
@@ -1138,6 +1138,7 @@ function normalizeFinding(code, item, context) {
1138
1138
  .filter(([, passed]) => passed === false)
1139
1139
  .map(([name]) => name) } : {}),
1140
1140
  ...(item.resourceId ? { resourceId: item.resourceId } : {}),
1141
+ ...(item.destination ? { destination: item.destination } : {}),
1141
1142
  ...(item.createResourceType ? { createResourceType: item.createResourceType } : {}),
1142
1143
  ...(item.sourceResourceIds ? { sourceResourceIds: item.sourceResourceIds } : {}),
1143
1144
  ...(item.informationTypeId ? { informationTypeId: item.informationTypeId } : {}),