filegrc 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/index.js +8 -5
- package/model/v7.json +10359 -0
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/applicability-scope.js +211 -0
- package/src/audit-preparation.js +156 -20
- package/src/batch-review.js +40 -24
- package/src/cli.js +85 -7
- package/src/collection-review.js +16 -3
- package/src/collection-revision.js +23 -3
- package/src/collection-scope.js +94 -7
- package/src/document-activation.js +13 -1
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +381 -35
- package/src/obligations.js +142 -39
- package/src/policy-activation.js +5 -0
- package/src/policy-library/data-retention-schedule-v2.md +25 -0
- package/src/policy-library.js +52 -24
- package/src/program-amendment.js +222 -0
- package/src/program-lifecycle.js +1 -1
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +43 -13
- package/src/reconciliation.js +15 -3
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +76 -2
- package/src/setup.js +1 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +171 -2
- package/src/validate.js +106 -11
- package/src/web.js +441 -70
- package/src/workflow.js +33 -13
package/src/retention.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { contentRevision } from "./files.js";
|
|
3
|
+
import { resolveDataPath } from "./paths.js";
|
|
4
|
+
import { programComponents } from "./program.js";
|
|
5
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
6
|
+
|
|
7
|
+
export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
8
|
+
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
9
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
10
|
+
const rules = loaded.resources.filter((record) => (
|
|
11
|
+
record.type === "retention-schedule-item" && record.status === "active"
|
|
12
|
+
));
|
|
13
|
+
const revisions = await resourceReviewRevisions(loaded, rules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
|
|
14
|
+
const usableRules = rules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
|
|
15
|
+
const uses = retentionUses(loaded, program);
|
|
16
|
+
const items = rules.filter((rule) => !usableRules.includes(rule)).map((rule) => readinessItem(
|
|
17
|
+
`retention-rule-${rule.id}`,
|
|
18
|
+
"action",
|
|
19
|
+
`Review ${rule.title}`,
|
|
20
|
+
"This active retention schedule item is incomplete or is not bound to every current source revision. Review it before relying on its period or disposition behavior.",
|
|
21
|
+
rule,
|
|
22
|
+
{
|
|
23
|
+
sourceResourceIds: retentionReviewResourceIds(rule, loaded),
|
|
24
|
+
commands: [
|
|
25
|
+
`npx filegrc get ${rule.id} --mutation`,
|
|
26
|
+
`npx filegrc review-bindings ${rule.id} --json`,
|
|
27
|
+
...(rule.sourceResourceIds || [])
|
|
28
|
+
.filter((id) => ["policy", "document", "framework", "requirement", "commitment"].includes(byId.get(id)?.type))
|
|
29
|
+
.map((id) => `npx filegrc program-amendment ${id} --json`)
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
));
|
|
33
|
+
items.push(...uses.map((use) => {
|
|
34
|
+
const matches = usableRules.filter((rule) => ruleCoversUse(rule, use, program));
|
|
35
|
+
return readinessItem(
|
|
36
|
+
`retention-use-${use.resource.id}-${use.informationTypeId}`,
|
|
37
|
+
matches.length ? "complete" : "action",
|
|
38
|
+
`Decide retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`,
|
|
39
|
+
matches.length
|
|
40
|
+
? `${use.resource.title} is covered by ${matches.map(({ title }) => title).join(", ")}.`
|
|
41
|
+
: `${use.resource.title} uses this Information Type, but no active, current retention schedule item covers both the type and scope. Management must choose the cutoff, period, and disposition.`,
|
|
42
|
+
use.resource,
|
|
43
|
+
{
|
|
44
|
+
informationTypeId: use.informationTypeId,
|
|
45
|
+
retentionScheduleItemIds: matches.map(({ id }) => id),
|
|
46
|
+
commands: [
|
|
47
|
+
"npx filegrc guide retention-schedule-item --json",
|
|
48
|
+
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`)}`
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
|
|
55
|
+
const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
56
|
+
const matching = linked.filter((rule) => (
|
|
57
|
+
rule.type === "retention-schedule-item"
|
|
58
|
+
&& rule.status === "active"
|
|
59
|
+
&& usableRules.includes(rule)
|
|
60
|
+
&& (rule.scopeResourceIds || []).includes(coverage.id)
|
|
61
|
+
));
|
|
62
|
+
items.push(readinessItem(
|
|
63
|
+
`retention-source-coverage-${coverage.id}`,
|
|
64
|
+
matching.length ? "complete" : "action",
|
|
65
|
+
`Confirm retained evidence for ${coverage.title}`,
|
|
66
|
+
matching.length
|
|
67
|
+
? `The source-coverage record references a current schedule item scoped to this population.`
|
|
68
|
+
: `The source-coverage record must reference an active, current schedule item whose scope includes ${coverage.id}. A draft schedule or unrelated rule does not satisfy this check.`,
|
|
69
|
+
coverage,
|
|
70
|
+
{
|
|
71
|
+
retentionScheduleItemIds: matching.map(({ id }) => id),
|
|
72
|
+
commands: [
|
|
73
|
+
`npx filegrc get ${coverage.id} --mutation`,
|
|
74
|
+
"npx filegrc list retention-schedule-item --workflow --json"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const duplicates = nearDuplicateInformationTypes(loaded.resources);
|
|
81
|
+
if (duplicates.length) {
|
|
82
|
+
items.push(readinessItem(
|
|
83
|
+
"retention-information-type-duplicates",
|
|
84
|
+
options.informationTypesReviewed ? "complete" : "action",
|
|
85
|
+
"Review similar Information Types",
|
|
86
|
+
options.informationTypesReviewed
|
|
87
|
+
? `${duplicates.length} similar pair${duplicates.length === 1 ? " was" : "s were"} included in the current Information Type inventory review. FileGRC did not merge records or rewrite relationships.`
|
|
88
|
+
: `${duplicates.length} similar pair${duplicates.length === 1 ? " needs" : "s need"} management review. FileGRC will not merge records or rewrite relationships automatically.`,
|
|
89
|
+
{ type: "information-type" },
|
|
90
|
+
{
|
|
91
|
+
candidates: duplicates,
|
|
92
|
+
commands: [
|
|
93
|
+
"npx filegrc list information-type --workflow --json",
|
|
94
|
+
"npx filegrc review-collection information-type --scaffold"
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
));
|
|
98
|
+
}
|
|
99
|
+
return items;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function resourceReviewRevision(loaded, resourceId) {
|
|
103
|
+
return (await resourceReviewRevisions(loaded, [resourceId])).get(resourceId) || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function resourceReviewRevisions(loaded, ids) {
|
|
107
|
+
const wanted = new Set(ids);
|
|
108
|
+
const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
109
|
+
const revisions = new Map();
|
|
110
|
+
const reviewing = new Set();
|
|
111
|
+
const review = async (id) => {
|
|
112
|
+
if (revisions.has(id)) return revisions.get(id);
|
|
113
|
+
const entry = entries.get(id);
|
|
114
|
+
if (!entry || reviewing.has(id)) return null;
|
|
115
|
+
reviewing.add(id);
|
|
116
|
+
const parts = [entry.source];
|
|
117
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
118
|
+
try {
|
|
119
|
+
parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code !== "ENOENT") throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
|
|
125
|
+
const revision = await review(sourceId);
|
|
126
|
+
if (revision) parts.push(`${sourceId}:${revision}`);
|
|
127
|
+
}
|
|
128
|
+
reviewing.delete(id);
|
|
129
|
+
const revision = contentRevision(parts.join("\n"));
|
|
130
|
+
revisions.set(id, revision);
|
|
131
|
+
return revision;
|
|
132
|
+
};
|
|
133
|
+
for (const id of wanted) {
|
|
134
|
+
await review(id);
|
|
135
|
+
}
|
|
136
|
+
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function retentionUses(loaded, program) {
|
|
140
|
+
const systemIds = new Set(program.systemIds || []);
|
|
141
|
+
const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
|
|
142
|
+
const vendorIds = new Set(program.vendorIds || loaded.resources
|
|
143
|
+
.filter((record) => record.type === "vendor" && record.status !== "retired")
|
|
144
|
+
.map(({ id }) => id));
|
|
145
|
+
const uses = [];
|
|
146
|
+
for (const record of loaded.resources) {
|
|
147
|
+
if (record.type === "system" && systemIds.has(record.id)) {
|
|
148
|
+
for (const informationTypeId of record.informationTypeIds || []) uses.push({ resource: record, informationTypeId });
|
|
149
|
+
}
|
|
150
|
+
if (record.type === "component" && componentIds.has(record.id)) {
|
|
151
|
+
for (const use of record.informationUses || []) uses.push({ resource: record, informationTypeId: use.informationTypeId });
|
|
152
|
+
}
|
|
153
|
+
if (record.type === "vendor" && vendorIds.has(record.id)) {
|
|
154
|
+
for (const informationTypeId of record.informationTypeIds || []) uses.push({ resource: record, informationTypeId });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function nearDuplicateInformationTypes(records) {
|
|
161
|
+
const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
|
|
162
|
+
const pairs = [];
|
|
163
|
+
for (let leftIndex = 0; leftIndex < types.length; leftIndex += 1) {
|
|
164
|
+
for (let rightIndex = leftIndex + 1; rightIndex < types.length; rightIndex += 1) {
|
|
165
|
+
const left = types[leftIndex];
|
|
166
|
+
const right = types[rightIndex];
|
|
167
|
+
const score = similarity(normalize(left.title), normalize(right.title));
|
|
168
|
+
if (score >= 0.8) pairs.push({ leftId: left.id, rightId: right.id, score });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return pairs;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ruleCoversUse(rule, use, program) {
|
|
175
|
+
if (!(rule.informationTypeIds || []).includes(use.informationTypeId)) return false;
|
|
176
|
+
const scope = new Set(rule.scopeResourceIds || []);
|
|
177
|
+
return scope.has(use.resource.id) || scope.has(program.id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function retentionReviewResourceIds(rule, loaded) {
|
|
181
|
+
const programUseIds = [];
|
|
182
|
+
if (loaded) {
|
|
183
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
184
|
+
const informationTypeIds = new Set(rule.informationTypeIds || []);
|
|
185
|
+
for (const scopeId of rule.scopeResourceIds || []) {
|
|
186
|
+
const program = byId.get(scopeId);
|
|
187
|
+
if (program?.type !== "program") continue;
|
|
188
|
+
for (const use of retentionUses(loaded, program)) {
|
|
189
|
+
if (informationTypeIds.has(use.informationTypeId)) programUseIds.push(use.resource.id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return [...new Set([
|
|
194
|
+
rule.scheduleDocumentId,
|
|
195
|
+
...(rule.sourceResourceIds || []),
|
|
196
|
+
...(rule.informationTypeIds || []),
|
|
197
|
+
...(rule.scopeResourceIds || []),
|
|
198
|
+
...programUseIds
|
|
199
|
+
].filter(Boolean))];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded) {
|
|
203
|
+
if (!String(rule.description || "").trim()) return false;
|
|
204
|
+
if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
|
|
205
|
+
const schedule = byId.get(rule.scheduleDocumentId);
|
|
206
|
+
if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
|
|
207
|
+
if (!(rule.ownerIds || []).length || !(rule.approvedByIds || []).length || !rule.approvedOn) return false;
|
|
208
|
+
if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
|
|
209
|
+
if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
|
|
210
|
+
if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
|
|
211
|
+
if (!String(rule.dispositionInstructions || "").trim()) return false;
|
|
212
|
+
const dependencyIds = retentionReviewResourceIds(rule, loaded);
|
|
213
|
+
if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
|
|
214
|
+
return dependencyIds.every((id) => (
|
|
215
|
+
revisions.get(id) && rule.reviewedSourceRevisions?.[id] === revisions.get(id)
|
|
216
|
+
));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function shellArgument(value) {
|
|
220
|
+
const text = String(value);
|
|
221
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(text)
|
|
222
|
+
? text
|
|
223
|
+
: `'${text.replaceAll("'", "'\\''")}'`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function retentionCutoffIsComplete(cutoff) {
|
|
227
|
+
if (!cutoff || !["creation", "receipt", "calendar-year-end", "fiscal-year-end", "event"].includes(cutoff.basis)) return false;
|
|
228
|
+
return cutoff.basis !== "event" || Boolean(String(cutoff.event || "").trim());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function retentionPeriodIsComplete(period) {
|
|
232
|
+
if (!period || !["fixed", "until-event", "permanent"].includes(period.basis)) return false;
|
|
233
|
+
if (period.basis === "fixed") {
|
|
234
|
+
return Number.isInteger(period.amount) && period.amount >= 1 && ["day", "month", "year"].includes(period.unit);
|
|
235
|
+
}
|
|
236
|
+
return period.basis !== "until-event" || Boolean(String(period.event || "").trim());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalize(value) {
|
|
240
|
+
return new Set(String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean).map((word) => (
|
|
241
|
+
word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word
|
|
242
|
+
)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function similarity(left, right) {
|
|
246
|
+
if (!left.size || !right.size) return 0;
|
|
247
|
+
const intersection = [...left].filter((word) => right.has(word)).length;
|
|
248
|
+
return Number((intersection / new Set([...left, ...right]).size).toFixed(2));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readinessItem(id, status, title, message, resource = {}, details = {}) {
|
|
252
|
+
return {
|
|
253
|
+
id,
|
|
254
|
+
status,
|
|
255
|
+
title,
|
|
256
|
+
message,
|
|
257
|
+
...(resource.type ? { resourceType: resource.type } : {}),
|
|
258
|
+
...(resource.id ? { resourceId: resource.id } : {}),
|
|
259
|
+
...details
|
|
260
|
+
};
|
|
261
|
+
}
|
package/src/server.js
CHANGED
|
@@ -46,7 +46,8 @@ import {
|
|
|
46
46
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
47
47
|
import { activatePolicies } from "./policy-activation.js";
|
|
48
48
|
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
49
|
-
import {
|
|
49
|
+
import { resourceReviewRevisions } from "./retention.js";
|
|
50
|
+
import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
|
|
50
51
|
import { setupWorkspace } from "./setup.js";
|
|
51
52
|
import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
|
|
52
53
|
import {
|
|
@@ -59,6 +60,8 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
59
60
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
60
61
|
|
|
61
62
|
export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
63
|
+
const stateSessions = new Map();
|
|
64
|
+
let nextStateSession = 0;
|
|
62
65
|
return createHttpServer(async (request, response) => {
|
|
63
66
|
const requestStarted = performance.now();
|
|
64
67
|
if (timingEnabled()) {
|
|
@@ -94,6 +97,31 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
94
97
|
includeDetails: false
|
|
95
98
|
}));
|
|
96
99
|
}
|
|
100
|
+
if (request.method === "GET" && url.pathname === "/api/state/bootstrap") {
|
|
101
|
+
const loaded = await serializeWorkspaceMutation(input, (root) => loadWorkspace(root));
|
|
102
|
+
const token = `${Date.now().toString(36)}-${(++nextStateSession).toString(36)}`;
|
|
103
|
+
const session = {
|
|
104
|
+
loaded,
|
|
105
|
+
generatedAt: new Date().toISOString(),
|
|
106
|
+
promises: new Map()
|
|
107
|
+
};
|
|
108
|
+
stateSessions.set(token, session);
|
|
109
|
+
while (stateSessions.size > 8) stateSessions.delete(stateSessions.keys().next().value);
|
|
110
|
+
const state = await createAppBootstrap(loaded, { generatedAt: session.generatedAt });
|
|
111
|
+
state.stateToken = token;
|
|
112
|
+
return json(response, 200, state);
|
|
113
|
+
}
|
|
114
|
+
if (request.method === "GET" && url.pathname.startsWith("/api/state/")) {
|
|
115
|
+
const section = url.pathname.slice("/api/state/".length);
|
|
116
|
+
if (!["repository", "program", "obligations", "audits", "workflow"].includes(section)) {
|
|
117
|
+
return json(response, 404, { error: "Unknown app-state section." });
|
|
118
|
+
}
|
|
119
|
+
const token = url.searchParams.get("token");
|
|
120
|
+
const session = stateSessions.get(token);
|
|
121
|
+
if (!session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
|
|
122
|
+
const state = await loadStateSessionSection(session, section, options);
|
|
123
|
+
return json(response, 200, { stateToken: token, section, state });
|
|
124
|
+
}
|
|
97
125
|
if (request.method === "GET" && url.pathname === "/api/history") {
|
|
98
126
|
const path = url.searchParams.get("path");
|
|
99
127
|
if (!path || path.includes("..") || !path.startsWith("data/")) return json(response, 400, { error: "A safe data path is required." });
|
|
@@ -319,7 +347,8 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
319
347
|
const payload = await readJson(request);
|
|
320
348
|
const completeSetup = async () => {
|
|
321
349
|
return browserMutation(input, options, {
|
|
322
|
-
message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}
|
|
350
|
+
message: (setupResult) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${setupResult.workspace.organizationName}`,
|
|
351
|
+
fastResponse: prefersFastMutation(request)
|
|
323
352
|
}, () => setupWorkspace(input, payload));
|
|
324
353
|
};
|
|
325
354
|
return json(response, 200, await completeSetup());
|
|
@@ -389,6 +418,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
389
418
|
readOnly: repository.mode === "trunk" && !repository.writesAllowed
|
|
390
419
|
});
|
|
391
420
|
}
|
|
421
|
+
if (request.method === "GET" && url.pathname === "/api/review-revisions") {
|
|
422
|
+
const ids = [...new Set(url.searchParams.getAll("id"))];
|
|
423
|
+
if (!ids.length || ids.some((id) => !safeSegment(id))) {
|
|
424
|
+
return json(response, 400, { error: "Pass one or more safe resource IDs." });
|
|
425
|
+
}
|
|
426
|
+
const loaded = await loadWorkspace(input);
|
|
427
|
+
const revisions = await resourceReviewRevisions(loaded, ids);
|
|
428
|
+
const missing = ids.filter((id) => !revisions.has(id));
|
|
429
|
+
if (missing.length) return json(response, 404, { error: `Resources not found: ${missing.join(", ")}.` });
|
|
430
|
+
return json(response, 200, { revisions: Object.fromEntries(revisions) });
|
|
431
|
+
}
|
|
392
432
|
if (request.method === "PUT" && url.pathname === "/api/content") {
|
|
393
433
|
const payload = await readJson(request);
|
|
394
434
|
const result = await browserMutation(input, options, {
|
|
@@ -593,6 +633,40 @@ export function reconcileMutationSynchronization(synchronization, repository) {
|
|
|
593
633
|
};
|
|
594
634
|
}
|
|
595
635
|
|
|
636
|
+
function loadStateSessionSection(session, section, serverOptions) {
|
|
637
|
+
if (session.promises.has(section)) return session.promises.get(section);
|
|
638
|
+
const dependencies = section === "workflow"
|
|
639
|
+
? Promise.all([
|
|
640
|
+
loadStateSessionSection(session, "repository", serverOptions),
|
|
641
|
+
loadStateSessionSection(session, "program", serverOptions),
|
|
642
|
+
loadStateSessionSection(session, "obligations", serverOptions),
|
|
643
|
+
loadStateSessionSection(session, "audits", serverOptions)
|
|
644
|
+
])
|
|
645
|
+
: section === "audits"
|
|
646
|
+
? Promise.all([loadStateSessionSection(session, "program", serverOptions)])
|
|
647
|
+
: Promise.resolve([]);
|
|
648
|
+
const promise = dependencies.then((results) => {
|
|
649
|
+
const repository = section === "workflow" ? results[0] : null;
|
|
650
|
+
const program = section === "workflow" ? results[1] : section === "audits" ? results[0] : null;
|
|
651
|
+
const obligations = section === "workflow" ? results[2] : null;
|
|
652
|
+
const audits = section === "workflow" ? results[3] : null;
|
|
653
|
+
return createAppStateSection(session.loaded, section, {
|
|
654
|
+
allowNonAuthoritativeWrites: serverOptions.allowNonAuthoritativeWrites,
|
|
655
|
+
generatedAt: session.generatedAt,
|
|
656
|
+
programReadiness: program?.programReadiness,
|
|
657
|
+
auditPreparations: audits?.auditPreparations,
|
|
658
|
+
obligations: obligations?.obligations,
|
|
659
|
+
git: repository?.git,
|
|
660
|
+
validation: repository?.validation
|
|
661
|
+
});
|
|
662
|
+
}).catch((error) => {
|
|
663
|
+
session.promises.delete(section);
|
|
664
|
+
throw error;
|
|
665
|
+
});
|
|
666
|
+
session.promises.set(section, promise);
|
|
667
|
+
return promise;
|
|
668
|
+
}
|
|
669
|
+
|
|
596
670
|
function prefersFastMutation(request) {
|
|
597
671
|
return String(request.headers.prefer || "")
|
|
598
672
|
.split(",")
|
package/src/setup.js
CHANGED
|
@@ -81,7 +81,7 @@ export function summarizeSetupResult(result) {
|
|
|
81
81
|
},
|
|
82
82
|
system: setupSystemSummary(result.system),
|
|
83
83
|
target: setupTargetSummary(result.program || result.workspace, {
|
|
84
|
-
modelVersion: result.workspace?.dataModelVersion || (result.program ? "
|
|
84
|
+
modelVersion: result.workspace?.dataModelVersion || (result.program ? "7" : "3")
|
|
85
85
|
}),
|
|
86
86
|
renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
|
|
87
87
|
commitment: result.commitment || null,
|
package/src/source-coverage.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
2
|
+
|
|
3
|
+
export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
|
|
4
|
+
const structuredRetention = loaded.model.resources["retention-schedule-item"];
|
|
5
|
+
if (!record?.validFrom || !record.collectionCadence || !record.reconciliationMethod) {
|
|
3
6
|
return false;
|
|
4
7
|
}
|
|
8
|
+
if (structuredRetention) {
|
|
9
|
+
const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
|
|
10
|
+
loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
|
|
11
|
+
));
|
|
12
|
+
const revisions = await resourceReviewRevisions(loaded, linkedRules.filter(Boolean).flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
|
|
13
|
+
if (!linkedRules.some((rule) => (
|
|
14
|
+
rule?.status === "active"
|
|
15
|
+
&& (rule.scopeResourceIds || []).includes(record.id)
|
|
16
|
+
&& retentionRuleIsCurrent(rule, revisions, new Map(loaded.resources.map((resource) => [resource.id, resource])), loaded)
|
|
17
|
+
))) return false;
|
|
18
|
+
} else if (!record.retention) return false;
|
|
5
19
|
const external = record.coverageKind === "external-system" || record.coverageKind === "external-component";
|
|
6
20
|
const sourceId = record.componentId || record.systemId;
|
|
7
21
|
if (external && (!sourceId || !(record.retrieverIds || []).length)) {
|
|
@@ -34,7 +48,7 @@ export function sourceCoverageComplete(record, loaded, program = loaded.workspac
|
|
|
34
48
|
return true;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], program = loaded.workspace) {
|
|
51
|
+
export async function assessSourceCoverageReadiness(loaded, selectedControlIds = [], program = loaded.workspace) {
|
|
38
52
|
if (!loaded.model.resources["source-coverage"]) return [];
|
|
39
53
|
const selected = new Set(selectedControlIds);
|
|
40
54
|
const selectedControlCodes = new Set(loaded.resources
|
|
@@ -45,9 +59,9 @@ export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], p
|
|
|
45
59
|
))
|
|
46
60
|
.map(({ code }) => code)
|
|
47
61
|
.filter(Boolean));
|
|
48
|
-
return (loaded.model.evidenceSourceFamilies || [])
|
|
62
|
+
return Promise.all((loaded.model.evidenceSourceFamilies || [])
|
|
49
63
|
.filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
|
|
50
|
-
.map((family) => {
|
|
64
|
+
.map(async (family) => {
|
|
51
65
|
const records = loaded.resources.filter((record) => (
|
|
52
66
|
record.type === "source-coverage"
|
|
53
67
|
&& record.sourceFamilyId === family.id
|
|
@@ -59,7 +73,7 @@ export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], p
|
|
|
59
73
|
return {
|
|
60
74
|
family,
|
|
61
75
|
record,
|
|
62
|
-
complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded, program))
|
|
76
|
+
complete: Boolean(record?.status === "active" && await sourceCoverageComplete(record, loaded, program))
|
|
63
77
|
};
|
|
64
|
-
});
|
|
78
|
+
}));
|
|
65
79
|
}
|
package/src/state.js
CHANGED
|
@@ -37,6 +37,174 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
37
37
|
return promise;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export async function createAppBootstrap(input = process.cwd(), options = {}) {
|
|
41
|
+
const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
|
|
42
|
+
const workspace = loaded.workspace ?? {
|
|
43
|
+
dataModelVersion: loaded.model.modelVersion,
|
|
44
|
+
id: "workspace",
|
|
45
|
+
type: "workspace",
|
|
46
|
+
title: "filegrc workspace",
|
|
47
|
+
organizationName: "Workspace configuration unavailable",
|
|
48
|
+
timezone: "UTC"
|
|
49
|
+
};
|
|
50
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
51
|
+
return {
|
|
52
|
+
generatedAt,
|
|
53
|
+
asOf: options.asOf ?? currentCalendarDate(workspace.timezone),
|
|
54
|
+
readOnly: true,
|
|
55
|
+
repository: {
|
|
56
|
+
loading: true,
|
|
57
|
+
mode: null,
|
|
58
|
+
status: "loading",
|
|
59
|
+
label: "Checking Git",
|
|
60
|
+
writesAllowed: false
|
|
61
|
+
},
|
|
62
|
+
workspace,
|
|
63
|
+
model: loaded.model,
|
|
64
|
+
resources: loaded.entries.map((entry) => ({
|
|
65
|
+
record: structuredClone(entry.record),
|
|
66
|
+
relativePath: `data/${entry.relativePath}`,
|
|
67
|
+
revision: contentRevision(entry.source),
|
|
68
|
+
content: {},
|
|
69
|
+
history: undefined,
|
|
70
|
+
detailsLoaded: false
|
|
71
|
+
})),
|
|
72
|
+
validation: {
|
|
73
|
+
loading: true,
|
|
74
|
+
ok: null,
|
|
75
|
+
counts: { errors: 0, warnings: 0 },
|
|
76
|
+
diagnostics: []
|
|
77
|
+
},
|
|
78
|
+
obligations: { loading: true, items: [], triggers: [], counts: {} },
|
|
79
|
+
collectionReviews: {},
|
|
80
|
+
applicabilityConstraints: {},
|
|
81
|
+
programReadiness: null,
|
|
82
|
+
auditPreparations: {},
|
|
83
|
+
workflow: { loading: true, findings: [], workItems: [], assessments: {} },
|
|
84
|
+
git: {
|
|
85
|
+
loading: true,
|
|
86
|
+
available: false,
|
|
87
|
+
clean: null,
|
|
88
|
+
changes: [],
|
|
89
|
+
branch: null,
|
|
90
|
+
shortCommit: "checking"
|
|
91
|
+
},
|
|
92
|
+
sections: {
|
|
93
|
+
repository: "loading",
|
|
94
|
+
program: "idle",
|
|
95
|
+
obligations: "idle",
|
|
96
|
+
audits: "idle",
|
|
97
|
+
workflow: "idle"
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function createAppStateSection(input, section, options = {}) {
|
|
103
|
+
const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
|
|
104
|
+
const workspace = loaded.workspace;
|
|
105
|
+
const asOf = options.asOf ?? currentCalendarDate(workspace?.timezone || "UTC");
|
|
106
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
107
|
+
if (section === "repository") {
|
|
108
|
+
const [validation, snapshot] = await Promise.all([
|
|
109
|
+
validateWorkspace(loaded),
|
|
110
|
+
measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root))
|
|
111
|
+
]);
|
|
112
|
+
const git = { ...snapshot };
|
|
113
|
+
delete git.root;
|
|
114
|
+
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
|
|
115
|
+
readOnly: options.readOnly,
|
|
116
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
117
|
+
repositorySnapshot: git
|
|
118
|
+
}));
|
|
119
|
+
return {
|
|
120
|
+
generatedAt,
|
|
121
|
+
readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
|
|
122
|
+
repository,
|
|
123
|
+
git,
|
|
124
|
+
validation: {
|
|
125
|
+
ok: validation.ok,
|
|
126
|
+
counts: validation.counts,
|
|
127
|
+
diagnostics: validation.diagnostics
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (section === "program") {
|
|
132
|
+
const programReadiness = options.programReadiness ?? await assessProgramReadiness(loaded, { asOf, generatedAt });
|
|
133
|
+
const activeProgram = resolveProgram(loaded);
|
|
134
|
+
return {
|
|
135
|
+
generatedAt,
|
|
136
|
+
asOf,
|
|
137
|
+
programReadiness,
|
|
138
|
+
collectionReviews: Object.fromEntries(
|
|
139
|
+
assessCollectionReviews(loaded).map((assessment) => [
|
|
140
|
+
assessment.resourceType,
|
|
141
|
+
{
|
|
142
|
+
resourceType: assessment.resourceType,
|
|
143
|
+
configuration: assessment.configuration,
|
|
144
|
+
recordCount: assessment.recordCount,
|
|
145
|
+
review: assessment.review,
|
|
146
|
+
reviewRevision: assessment.reviewRevision,
|
|
147
|
+
collectionRevision: assessment.collectionRevision,
|
|
148
|
+
status: assessment.status,
|
|
149
|
+
complete: assessment.complete,
|
|
150
|
+
message: assessment.message
|
|
151
|
+
}
|
|
152
|
+
])
|
|
153
|
+
),
|
|
154
|
+
applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
155
|
+
const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
|
|
156
|
+
return constraint ? [[record.id, constraint]] : [];
|
|
157
|
+
}))
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (section === "obligations") {
|
|
161
|
+
return {
|
|
162
|
+
generatedAt,
|
|
163
|
+
asOf,
|
|
164
|
+
obligations: planObligations(loaded.resources, {
|
|
165
|
+
asOf,
|
|
166
|
+
now: options.now ?? generatedAt,
|
|
167
|
+
model: loaded.model
|
|
168
|
+
})
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (section === "audits") {
|
|
172
|
+
const programReadiness = options.programReadiness;
|
|
173
|
+
const audits = loaded.resources.filter((record) => record.type === "audit");
|
|
174
|
+
return {
|
|
175
|
+
generatedAt,
|
|
176
|
+
asOf,
|
|
177
|
+
auditPreparations: Object.fromEntries(await Promise.all(
|
|
178
|
+
(audits.length ? audits : [null]).map(async (audit) => [
|
|
179
|
+
audit?.id || "none",
|
|
180
|
+
await assessAuditPreparation(loaded, {
|
|
181
|
+
auditId: audit?.id,
|
|
182
|
+
asOf,
|
|
183
|
+
generatedAt,
|
|
184
|
+
...(audit || !programReadiness ? {} : { programReadiness })
|
|
185
|
+
})
|
|
186
|
+
])
|
|
187
|
+
))
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (section === "workflow") {
|
|
191
|
+
return {
|
|
192
|
+
generatedAt,
|
|
193
|
+
asOf,
|
|
194
|
+
workflow: await assessWorkflow(loaded, {
|
|
195
|
+
asOf,
|
|
196
|
+
evaluatedAt: generatedAt,
|
|
197
|
+
programReadiness: options.programReadiness,
|
|
198
|
+
auditPreparations: options.auditPreparations,
|
|
199
|
+
obligations: options.obligations,
|
|
200
|
+
git: options.git,
|
|
201
|
+
validation: options.validation
|
|
202
|
+
})
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`Unknown app-state section "${section}".`);
|
|
206
|
+
}
|
|
207
|
+
|
|
40
208
|
async function createAppStateUnlocked(input, options) {
|
|
41
209
|
let validation;
|
|
42
210
|
if (options.validationProof) {
|
|
@@ -65,7 +233,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
65
233
|
|
|
66
234
|
const git = { ...await measureTiming("state-repository-snapshot", () => getRepositorySnapshot(loaded.root)) };
|
|
67
235
|
delete git.root;
|
|
68
|
-
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded
|
|
236
|
+
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded, {
|
|
69
237
|
readOnly: options.readOnly,
|
|
70
238
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
71
239
|
repositorySnapshot: git
|
|
@@ -94,8 +262,9 @@ async function createAppStateUnlocked(input, options) {
|
|
|
94
262
|
(audits.length ? audits : [null]).map(async (audit) => {
|
|
95
263
|
const preparation = await assessAuditPreparation(loaded, {
|
|
96
264
|
auditId: audit?.id,
|
|
265
|
+
asOf,
|
|
97
266
|
generatedAt,
|
|
98
|
-
programReadiness
|
|
267
|
+
...(audit ? {} : { programReadiness })
|
|
99
268
|
});
|
|
100
269
|
return [audit?.id || "none", preparation];
|
|
101
270
|
})
|