filegrc 0.2.0 → 0.3.1
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/LICENSE +1 -1
- package/README.md +10 -9
- package/model/v1.json +21 -21
- package/package.json +2 -2
- package/src/audit-preparation.js +13 -36
- package/src/cli.js +154 -17
- package/src/evidence-packet.js +18 -18
- package/src/git.js +1 -1
- package/src/index.js +2 -2
- package/src/model-docs.js +1 -1
- package/src/parties.js +46 -0
- package/src/paths.js +1 -1
- package/src/program-lifecycle.js +3 -0
- package/src/program-path.js +9 -9
- package/src/program-readiness.js +80 -32
- package/src/server.js +2 -2
- package/src/setup.js +104 -66
- package/src/state.js +1 -1
- package/src/validate.js +5 -34
- package/src/web.js +98 -40
package/src/program-path.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const RESOURCE_INSTRUCTIONS = {
|
|
2
|
-
person: "Confirm the
|
|
2
|
+
person: "Confirm the policy owner created during setup, then add the actual people who will approve, review, or operate the program.",
|
|
3
3
|
team: "Review the starter Security and Risk Oversight team, including its members and chair. Add another team only when the organization assigns shared responsibility to it.",
|
|
4
4
|
system: "Catalog all in-scope systems for the program. Treat anything that operates a control or produces evidence as a System, including software provided by a vendor (like HR software).",
|
|
5
5
|
vendor: "Catalog the companies that provide in-scope software or services. Link each vendor-provided System to the company that provides it.",
|
|
@@ -14,7 +14,7 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
14
14
|
"risk-assessment": "Complete and approve an assessment of the risks to the in-scope service, systems, vendors, and commitments.",
|
|
15
15
|
risk: "Record each risk identified by an assessment or operating activity. Assign an owner, rate it, and document the chosen response.",
|
|
16
16
|
obligation: "Review the recurring work proposed by effective policies. Confirm who owns it, when it is due, and what proof completion requires.",
|
|
17
|
-
"obligation-event": "When a policy-triggering event occurs, record it here and complete the actions
|
|
17
|
+
"obligation-event": "When a policy-triggering event occurs, record it here and complete the actions filegrc creates for it.",
|
|
18
18
|
"policy-review": "Record scheduled and change-driven reviews of policies and governed documents, including the decision and any follow-up.",
|
|
19
19
|
meeting: "Record required oversight meetings, including attendees, decisions, minutes, and follow-up work.",
|
|
20
20
|
exception: "Record and approve any time-limited departure from a policy or control before the departure begins.",
|
|
@@ -64,13 +64,13 @@ export const PROGRAM_PATH = [
|
|
|
64
64
|
description: "Ownership, criteria, and service boundary",
|
|
65
65
|
summary: "Confirm the people and teams responsible for the program, set the management goal, review the criteria and customer commitments in scope, then define the customer-facing service, supporting systems, and supplier dependencies.",
|
|
66
66
|
sections: [
|
|
67
|
-
{ id: "ownership", title: "Program Ownership", description: "Confirm the people and teams that own, approve, review, and operate the program.", steps: ["
|
|
67
|
+
{ id: "ownership", title: "Program Ownership", description: "Confirm the people and teams that own, approve, review, and operate the program.", steps: ["Confirm the policy owner created during setup, then add the organization’s actual reviewers and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "team"], defaultOpen: true },
|
|
68
68
|
{ id: "criteria", title: "Criteria", description: "Confirm the criteria used for the program, resolve whether each requirement applies, and record customer commitments that shape the service or control design.", steps: ["Review the included Security criteria references.", "Mark each requirement applicable or not applicable with a rationale.", "Record customer commitments and keep optional criteria out until management deliberately adds them."], types: ["framework", "requirement", "commitment"], defaultOpen: true },
|
|
69
69
|
{ id: "boundary", title: "Service Boundary", description: "Record the service and its supporting technology and providers. An application or platform is a System because it operates controls or produces evidence; the company providing it is a Vendor because contracts, due diligence, and supplier risk belong to that relationship.", steps: ["Create a Vendor record for each material provider.", "Create System records for the customer-facing service and each supporting application, platform, or internal system that is in scope or produces evidence, then connect vendor-provided Systems to their providers.", "Assign owners, classification, dependencies, and a clear in-scope decision to each System, and keep supplier reviews with the Vendor."], types: ["vendor", "system"], defaultOpen: false }
|
|
70
70
|
],
|
|
71
71
|
resourceTypes: ["person", "team", "framework", "requirement", "commitment", "vendor", "system"],
|
|
72
72
|
commands: [
|
|
73
|
-
"filegrc setup
|
|
73
|
+
"filegrc setup",
|
|
74
74
|
"filegrc guide person --json",
|
|
75
75
|
"filegrc guide system --json",
|
|
76
76
|
"filegrc list system --json"
|
|
@@ -99,7 +99,7 @@ export const PROGRAM_PATH = [
|
|
|
99
99
|
description: "Tailor and finish the starter control set",
|
|
100
100
|
summary: "Review the starter catalog against the scoped service, then give every applicable internal control an actual procedure, owner, system scope, cadence, policy and criteria mappings, authoritative evidence source, and implementation date. Mark it implemented only after the procedure is operating, then record any controls that customers or carved-out providers must perform.",
|
|
101
101
|
sections: [
|
|
102
|
-
{ id: "catalog", title: "Control Catalog", description: "Finish the starter controls, record applicable complementary controls, and see whether
|
|
102
|
+
{ id: "catalog", title: "Control Catalog", description: "Finish the starter controls, record applicable complementary controls, and see whether filegrc tracks operation through Work Queue or evidence records.", steps: ["Open every planned control and confirm its mappings and suggested frequency.", "Write the real procedure in Record Markdown and add system scope and evidence sources.", "Record any required customer or carved-out provider controls as Complementary Controls."], types: ["control", "complementary-control"], defaultOpen: true }
|
|
103
103
|
],
|
|
104
104
|
resourceTypes: ["control", "complementary-control"],
|
|
105
105
|
commands: [
|
|
@@ -119,7 +119,7 @@ export const PROGRAM_PATH = [
|
|
|
119
119
|
],
|
|
120
120
|
resourceTypes: ["evidence"],
|
|
121
121
|
commands: [
|
|
122
|
-
"filegrc evidence-test-drafts --json",
|
|
122
|
+
"filegrc evidence-test-drafts --preview --json",
|
|
123
123
|
"filegrc guide evidence --json",
|
|
124
124
|
"filegrc list evidence --json",
|
|
125
125
|
"filegrc program-readiness --json"
|
|
@@ -170,7 +170,7 @@ export const PROGRAM_PATH = [
|
|
|
170
170
|
{
|
|
171
171
|
id: "policy-events",
|
|
172
172
|
title: "Policy Events",
|
|
173
|
-
instructions: "Trigger the matching workflow when an event occurs.
|
|
173
|
+
instructions: "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.",
|
|
174
174
|
use: "Preview the full workflow before triggering it, then create the event and every linked task in one validated write.",
|
|
175
175
|
policyBasis: "Active event obligations translate policy-triggering changes into owned, deadline-bound Action Items. Proposed workflows remain unavailable until their governing policies and linked controls are ready.",
|
|
176
176
|
commands: ["filegrc obligations --json", "filegrc trigger EVENT_TYPE (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --subject RESOURCE_ID --json"]
|
|
@@ -201,14 +201,14 @@ export const PROGRAM_PATH = [
|
|
|
201
201
|
summary: "After the program is collecting reliable evidence, create an Audit record for the real CPA engagement, keep the firm-agreed report period separate from management’s candidate dates, complete management documents, populations, requests, evidence delivery, and fieldwork, then preserve the findings, responses, opinion, and final report.",
|
|
202
202
|
sections: [
|
|
203
203
|
{ id: "engagement", title: "Engagement", description: "Record the actual CPA engagement, formal scope and dates, requests, and management responses.", steps: ["Create the Audit after the CPA firm is engaged.", "Record the firm-agreed type, scope, systems, criteria, and dates.", "Track incoming requests and approved response material."], types: ["audit", "audit-request"], defaultOpen: true },
|
|
204
|
-
{ id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated
|
|
204
|
+
{ id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated filegrc Evidence and verified External Evidence for the formal period.", "Reconcile complete populations, link samples, and resolve fieldwork requests and findings.", "Build the packet from a clean Git revision; it includes filegrc records, Markdown, External Evidence, attachments, indexes, history, and checksums."], types: ["audit-population", "control-test"], utility: "audit-packet", defaultOpen: true }
|
|
205
205
|
],
|
|
206
206
|
resourceTypes: ["audit", "audit-request", "audit-population", "control-test"],
|
|
207
207
|
utilities: [
|
|
208
208
|
{
|
|
209
209
|
id: "audit-packet",
|
|
210
210
|
title: "Audit Evidence & Packet",
|
|
211
|
-
instructions: "Review
|
|
211
|
+
instructions: "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet.",
|
|
212
212
|
use: "Prepare management documents and populations, answer fieldwork requests, review both evidence paths, and compile a delivery bound to a clean Git revision.",
|
|
213
213
|
policyBasis: "Management prepares the scoped records, evidence, populations, assertions, and responses. The CPA firm selects samples, evaluates evidence and exceptions, and issues the report.",
|
|
214
214
|
commands: ["filegrc audit-readiness AUDIT_ID --json", "filegrc evidence-packet --audit AUDIT_ID --preview --json"]
|
package/src/program-readiness.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { planObligations } from "./obligations.js";
|
|
3
3
|
import { resolveDataPath } from "./paths.js";
|
|
4
4
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
5
|
+
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
5
6
|
import { markdownEntries } from "./resource-markdown.js";
|
|
6
7
|
import { currentCalendarDate } from "./time.js";
|
|
7
8
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -33,7 +34,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
33
34
|
[...sourceStage.items, ...collectionStage.items]
|
|
34
35
|
);
|
|
35
36
|
const evidenceGateStages = [
|
|
36
|
-
scopeStage(workspace, scope, records),
|
|
37
|
+
scopeStage(workspace, scope, records, byId),
|
|
37
38
|
await policiesStage(scope, byId, readMarkdown, asOf),
|
|
38
39
|
await controlsStage(scope, byId, readMarkdown, asOf),
|
|
39
40
|
evidenceStage
|
|
@@ -116,7 +117,7 @@ function programScope(workspace, records, byId) {
|
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function scopeStage(workspace, scope, records) {
|
|
120
|
+
function scopeStage(workspace, scope, records, byId) {
|
|
120
121
|
const items = [];
|
|
121
122
|
const goal = workspace?.assuranceGoal || "none";
|
|
122
123
|
items.push(item(
|
|
@@ -129,6 +130,8 @@ function scopeStage(workspace, scope, records) {
|
|
|
129
130
|
workspace || { type: "workspace" }
|
|
130
131
|
));
|
|
131
132
|
|
|
133
|
+
items.push(programOwnershipItem(records, byId));
|
|
134
|
+
|
|
132
135
|
const completeSystems = scope.systems.filter((system) => (
|
|
133
136
|
system.status === "active"
|
|
134
137
|
&& system.inScope === true
|
|
@@ -175,7 +178,75 @@ function scopeStage(workspace, scope, records) {
|
|
|
175
178
|
workspace || { type: "workspace" }
|
|
176
179
|
));
|
|
177
180
|
|
|
178
|
-
return stage("scope", "Define Scope", "Set the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
181
|
+
return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function programOwnershipItem(records, byId) {
|
|
185
|
+
const ownedRecords = records.filter((record) => (
|
|
186
|
+
["policy", "control", "obligation"].includes(record.type)
|
|
187
|
+
&& !["retired", "superseded", "not-applicable"].includes(record.status)
|
|
188
|
+
));
|
|
189
|
+
const unresolved = ownedRecords.filter((record) => currentPartyPeople(record.ownerIds, byId).size === 0);
|
|
190
|
+
const currentOwners = new Set(ownedRecords.flatMap((record) => [...currentPartyPeople(record.ownerIds, byId)]));
|
|
191
|
+
const oversight = byId.get("team-security-risk-oversight");
|
|
192
|
+
const policyOwnerIds = new Set(records
|
|
193
|
+
.filter((record) => record.type === "policy" && !["retired", "superseded"].includes(record.status))
|
|
194
|
+
.flatMap((record) => [...partyPeople(record.ownerIds || [], byId)]));
|
|
195
|
+
const oversightChairs = oversight?.type === "team"
|
|
196
|
+
? currentPartyPeople(oversight.chairIds || [], byId)
|
|
197
|
+
: new Set();
|
|
198
|
+
const oversightComplete = !oversight || (
|
|
199
|
+
oversight.type === "team"
|
|
200
|
+
&& oversight.status === "active"
|
|
201
|
+
&& currentPartyPeople(oversight.memberIds || [], byId).size > 0
|
|
202
|
+
&& oversightChairs.size > 0
|
|
203
|
+
&& ![...oversightChairs].some((id) => policyOwnerIds.has(id))
|
|
204
|
+
);
|
|
205
|
+
const complete = currentOwners.size > 0 && unresolved.length === 0 && oversightComplete;
|
|
206
|
+
const unresolvedAssignments = unresolved.map((record) => ({
|
|
207
|
+
resourceType: record.type,
|
|
208
|
+
resourceId: record.id,
|
|
209
|
+
title: record.title,
|
|
210
|
+
ownerIds: record.ownerIds || [],
|
|
211
|
+
reasons: ownershipResolutionReasons(record.ownerIds || [], byId)
|
|
212
|
+
}));
|
|
213
|
+
const detail = [];
|
|
214
|
+
if (!currentOwners.size) detail.push("No current person owns the program records.");
|
|
215
|
+
if (unresolved.length) {
|
|
216
|
+
detail.push(
|
|
217
|
+
`${unresolved.length} ${unresolved.length === 1 ? "record has" : "records have"} no current person owner: ` +
|
|
218
|
+
`${unresolvedAssignments.map(({ title, resourceId }) => `${title} (${resourceId})`).join(", ")}.`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
|
|
222
|
+
return item(
|
|
223
|
+
"program-ownership",
|
|
224
|
+
complete ? "complete" : "action",
|
|
225
|
+
"Confirm program owners and oversight",
|
|
226
|
+
complete
|
|
227
|
+
? `${currentOwners.size} current ${currentOwners.size === 1 ? "person owns" : "people own"} the program records.${oversight ? " Security and Risk Oversight has a separate current chair." : ""}`
|
|
228
|
+
: detail.join(" "),
|
|
229
|
+
!oversightComplete ? oversight : unresolved[0] || { type: "person" },
|
|
230
|
+
{ unresolvedAssignments }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ownershipResolutionReasons(ownerIds, byId) {
|
|
235
|
+
if (!ownerIds.length) return [{ ownerId: null, reason: "missing-owner" }];
|
|
236
|
+
return ownerIds.flatMap((ownerId) => {
|
|
237
|
+
const owner = byId.get(ownerId);
|
|
238
|
+
if (!owner) return [{ ownerId, reason: "missing-record" }];
|
|
239
|
+
if (owner.type === "person" && owner.status !== "active") {
|
|
240
|
+
return [{ ownerId, reason: "inactive-person" }];
|
|
241
|
+
}
|
|
242
|
+
if (owner.type === "team" && owner.status !== "active") {
|
|
243
|
+
return [{ ownerId, reason: "inactive-team" }];
|
|
244
|
+
}
|
|
245
|
+
if (currentPartyPeople([ownerId], byId).size === 0) {
|
|
246
|
+
return [{ ownerId, reason: owner.type === "team" ? "team-has-no-current-members" : "no-current-person" }];
|
|
247
|
+
}
|
|
248
|
+
return [];
|
|
249
|
+
});
|
|
179
250
|
}
|
|
180
251
|
|
|
181
252
|
async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
@@ -183,14 +254,11 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
183
254
|
const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
184
255
|
record?.type === "policy" && !["superseded", "retired"].includes(record.status)
|
|
185
256
|
));
|
|
186
|
-
const
|
|
257
|
+
const appointedReviewer = policies
|
|
258
|
+
.filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
|
|
259
|
+
.flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
|
|
187
260
|
.map((id) => byId.get(id))
|
|
188
|
-
.
|
|
189
|
-
const appointedReviewer = approvers.find((person) => (
|
|
190
|
-
["active", "external"].includes(person.status)
|
|
191
|
-
&& person.email
|
|
192
|
-
&& !(person.id === "person-independent-approver" && ["Independent Approver", "Independent Reviewer"].includes(person.title))
|
|
193
|
-
));
|
|
261
|
+
.find(Boolean);
|
|
194
262
|
const items = [
|
|
195
263
|
item(
|
|
196
264
|
"independent-reviewer",
|
|
@@ -199,7 +267,7 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
199
267
|
appointedReviewer
|
|
200
268
|
? `${appointedReviewer.title} is recorded as a reviewer separate from policy ownership.`
|
|
201
269
|
: "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
|
|
202
|
-
appointedReviewer || { type: "person"
|
|
270
|
+
appointedReviewer || { type: "person" }
|
|
203
271
|
)
|
|
204
272
|
];
|
|
205
273
|
if (!policies.length) {
|
|
@@ -355,7 +423,7 @@ function evidenceCollectionStage(scope, records, byId, model) {
|
|
|
355
423
|
? `${capture.title} is a draft. Open it, select the authoritative source System, collect the named artifact, and have another person verify it.`
|
|
356
424
|
: capture
|
|
357
425
|
? `${capture.title} is ${capture.status} but must be verified before this family is ready.`
|
|
358
|
-
: `Run and verify one test export or test capture from an authoritative source outside
|
|
426
|
+
: `Run and verify one test export or test capture from an authoritative source outside filegrc, then link it to a family control.`,
|
|
359
427
|
capture || { type: "evidence" },
|
|
360
428
|
{
|
|
361
429
|
familyId: family.id,
|
|
@@ -549,26 +617,6 @@ function substantiveMarkdown(source) {
|
|
|
549
617
|
return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
|
|
550
618
|
}
|
|
551
619
|
|
|
552
|
-
function partiesIndependent(ownerIds = [], approverIds = [], byId) {
|
|
553
|
-
const owners = new Set(partyPeople(ownerIds, byId));
|
|
554
|
-
const approvers = partyPeople(approverIds, byId);
|
|
555
|
-
return owners.size > 0 && approvers.length > 0 && !approvers.some((id) => owners.has(id));
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function partyPeople(ids = [], byId, seen = new Set()) {
|
|
559
|
-
const people = [];
|
|
560
|
-
for (const id of ids) {
|
|
561
|
-
if (seen.has(id)) continue;
|
|
562
|
-
seen.add(id);
|
|
563
|
-
const record = byId.get(id);
|
|
564
|
-
if (record?.type === "person") people.push(id);
|
|
565
|
-
if (record?.type === "team") {
|
|
566
|
-
people.push(...partyPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen));
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
return [...new Set(people)];
|
|
570
|
-
}
|
|
571
|
-
|
|
572
620
|
function policyCheckLabel(name) {
|
|
573
621
|
return ({
|
|
574
622
|
reviewed: "draft review",
|
package/src/server.js
CHANGED
|
@@ -15,7 +15,7 @@ import { setupWorkspace } from "./setup.js";
|
|
|
15
15
|
import { loadWorkspace } from "./workspace.js";
|
|
16
16
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
17
17
|
|
|
18
|
-
export function
|
|
18
|
+
export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
19
19
|
return createHttpServer(async (request, response) => {
|
|
20
20
|
try {
|
|
21
21
|
if (!expectedHost(request, options.allowedHosts)) {
|
|
@@ -184,7 +184,7 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
184
184
|
}
|
|
185
185
|
const loaded = await loadWorkspace(input);
|
|
186
186
|
getResourceDefinition(loaded.model, "workspace");
|
|
187
|
-
const server =
|
|
187
|
+
const server = createFilegrcServer(loaded.root, { allowedHosts: [host] });
|
|
188
188
|
await new Promise((resolve, reject) => {
|
|
189
189
|
server.once("error", reject);
|
|
190
190
|
server.listen(port, host, resolve);
|
package/src/setup.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createResource, updateResource } from "./files.js";
|
|
2
|
-
import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
|
|
3
2
|
import { createResourceId } from "./id.js";
|
|
4
3
|
import { loadWorkspace } from "./workspace.js";
|
|
5
4
|
|
|
@@ -10,81 +9,61 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
10
9
|
const loaded = await loadWorkspace(input);
|
|
11
10
|
const setup = normalizeSetupPayload(payload);
|
|
12
11
|
validateSetup(loaded, setup);
|
|
12
|
+
const plan = buildSetupRecords(loaded, setup);
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
system = {
|
|
18
|
-
...(system || {}),
|
|
19
|
-
schemaVersion: 1,
|
|
20
|
-
id: systemId,
|
|
21
|
-
type: "system",
|
|
22
|
-
title: setup.serviceName,
|
|
23
|
-
status: setup.draft ? system?.status || "planned" : (system?.status === "planned" ? "active" : system?.status || "active"),
|
|
24
|
-
criticality: setup.criticality,
|
|
25
|
-
ownerIds: [setup.ownerId],
|
|
26
|
-
description: setup.boundary,
|
|
27
|
-
systemKind: system?.systemKind || "service",
|
|
28
|
-
dataClassification: setup.dataClassification,
|
|
29
|
-
internetExposed: setup.internetExposed,
|
|
30
|
-
inScope: true
|
|
31
|
-
};
|
|
32
|
-
await upsertResource(current.root, current.resources.find(({ id }) => id === systemId), system);
|
|
33
|
-
|
|
34
|
-
current = await loadWorkspace(current.root);
|
|
35
|
-
const linkedControlIds = [];
|
|
36
|
-
for (const control of current.resources.filter(({ type, status }) => (
|
|
37
|
-
type === "control" && !["not-applicable", "retired"].includes(status)
|
|
38
|
-
))) {
|
|
39
|
-
if ((control.systemIds || []).includes(systemId)) continue;
|
|
40
|
-
await updateResource(current.root, "control", control.id, {
|
|
41
|
-
...control,
|
|
42
|
-
systemIds: [...new Set([...(control.systemIds || []), systemId])]
|
|
43
|
-
});
|
|
44
|
-
linkedControlIds.push(control.id);
|
|
45
|
-
}
|
|
14
|
+
await upsertResource(loaded.root, plan.existingSystem, plan.system);
|
|
15
|
+
await updateResource(loaded.root, "workspace", plan.workspace.id, plan.workspace);
|
|
16
|
+
if (plan.renderer) await updateResource(loaded.root, plan.renderer.type, plan.renderer.id, plan.renderer);
|
|
46
17
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.filter(({ type, status }) => type === "framework" && status === "active")
|
|
55
|
-
.map(({ id }) => id),
|
|
56
|
-
requirementIds: current.resources
|
|
57
|
-
.filter(({ type, applicability }) => type === "requirement" && applicability === "applicable")
|
|
58
|
-
.map(({ id }) => id),
|
|
59
|
-
controlIds: current.resources
|
|
60
|
-
.filter(({ type, status }) => type === "control" && !["not-applicable", "retired"].includes(status))
|
|
61
|
-
.map(({ id }) => id),
|
|
62
|
-
systemIds: [...new Set([...(existingWorkspace.systemIds || []), systemId])]
|
|
18
|
+
return {
|
|
19
|
+
draft: setup.draft,
|
|
20
|
+
system: plan.system,
|
|
21
|
+
workspace: plan.workspace,
|
|
22
|
+
linkedControlIds: [],
|
|
23
|
+
evidenceTestDraftIds: [],
|
|
24
|
+
onboardingComplete: !setup.draft
|
|
63
25
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
current = await loadWorkspace(current.root);
|
|
67
|
-
const renderer = current.resources.find(({ type }) => type === "renderer-settings");
|
|
68
|
-
if (renderer) {
|
|
69
|
-
await updateResource(current.root, renderer.type, renderer.id, {
|
|
70
|
-
...renderer,
|
|
71
|
-
showOnboarding: setup.draft
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
const evidenceTestDrafts = setup.draft
|
|
75
|
-
? { created: [], existing: [], total: 0 }
|
|
76
|
-
: await ensureEvidenceTestDrafts(current.root);
|
|
26
|
+
}
|
|
77
27
|
|
|
28
|
+
export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
29
|
+
const loaded = await loadWorkspace(input);
|
|
30
|
+
const setup = normalizeSetupPayload(payload);
|
|
31
|
+
validateSetup(loaded, setup);
|
|
32
|
+
const plan = buildSetupRecords(loaded, setup);
|
|
78
33
|
return {
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
preview: true,
|
|
79
36
|
draft: setup.draft,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
37
|
+
changes: {
|
|
38
|
+
system: plan.existingSystem ? "update" : "create",
|
|
39
|
+
workspace: "update",
|
|
40
|
+
renderer: plan.renderer ? "update" : "unchanged",
|
|
41
|
+
controls: 0,
|
|
42
|
+
evidenceDrafts: 0
|
|
43
|
+
},
|
|
44
|
+
system: setupSystemSummary(plan.system),
|
|
45
|
+
target: setupTargetSummary(plan.workspace),
|
|
84
46
|
onboardingComplete: !setup.draft
|
|
85
47
|
};
|
|
86
48
|
}
|
|
87
49
|
|
|
50
|
+
export function summarizeSetupResult(result) {
|
|
51
|
+
return {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
preview: false,
|
|
54
|
+
draft: result.draft,
|
|
55
|
+
changes: {
|
|
56
|
+
system: "saved",
|
|
57
|
+
workspace: "updated",
|
|
58
|
+
controls: result.linkedControlIds?.length || 0,
|
|
59
|
+
evidenceDrafts: result.evidenceTestDraftIds?.length || 0
|
|
60
|
+
},
|
|
61
|
+
system: setupSystemSummary(result.system),
|
|
62
|
+
target: setupTargetSummary(result.workspace),
|
|
63
|
+
onboardingComplete: result.onboardingComplete
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
88
67
|
export function normalizeSetupPayload(payload = {}) {
|
|
89
68
|
if (!payload || Array.isArray(payload) || typeof payload !== "object") {
|
|
90
69
|
throw new Error("Setup input must be a JSON object.");
|
|
@@ -146,6 +125,44 @@ function findSetupSystem(resources, setup) {
|
|
|
146
125
|
));
|
|
147
126
|
}
|
|
148
127
|
|
|
128
|
+
function buildSetupRecords(loaded, setup) {
|
|
129
|
+
const existingSystem = findSetupSystem(loaded.resources, setup);
|
|
130
|
+
const systemId = existingSystem?.id || createResourceId(
|
|
131
|
+
"system",
|
|
132
|
+
setup.serviceName,
|
|
133
|
+
loaded.resources.map(({ id }) => id)
|
|
134
|
+
);
|
|
135
|
+
const system = {
|
|
136
|
+
...(existingSystem || {}),
|
|
137
|
+
schemaVersion: 1,
|
|
138
|
+
id: systemId,
|
|
139
|
+
type: "system",
|
|
140
|
+
title: setup.serviceName,
|
|
141
|
+
status: setup.draft
|
|
142
|
+
? existingSystem?.status || "planned"
|
|
143
|
+
: existingSystem?.status === "planned"
|
|
144
|
+
? "active"
|
|
145
|
+
: existingSystem?.status || "active",
|
|
146
|
+
criticality: setup.criticality,
|
|
147
|
+
ownerIds: [setup.ownerId],
|
|
148
|
+
description: setup.boundary,
|
|
149
|
+
systemKind: existingSystem?.systemKind || "service",
|
|
150
|
+
dataClassification: setup.dataClassification,
|
|
151
|
+
internetExposed: setup.internetExposed,
|
|
152
|
+
inScope: true
|
|
153
|
+
};
|
|
154
|
+
const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
|
|
155
|
+
if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
|
|
156
|
+
const workspace = {
|
|
157
|
+
...existingWorkspace,
|
|
158
|
+
assuranceGoal: assuranceGoalFromSetup(setup.programGoal),
|
|
159
|
+
systemIds: [...new Set([...(existingWorkspace.systemIds || []), systemId])]
|
|
160
|
+
};
|
|
161
|
+
const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
|
|
162
|
+
const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
|
|
163
|
+
return { existingSystem, system, workspace, renderer };
|
|
164
|
+
}
|
|
165
|
+
|
|
149
166
|
async function upsertResource(root, existing, record) {
|
|
150
167
|
return existing
|
|
151
168
|
? updateResource(root, record.type, record.id, record)
|
|
@@ -159,6 +176,27 @@ function assuranceGoalFromSetup(goal) {
|
|
|
159
176
|
return "none";
|
|
160
177
|
}
|
|
161
178
|
|
|
179
|
+
function setupSystemSummary(system) {
|
|
180
|
+
return {
|
|
181
|
+
id: system.id,
|
|
182
|
+
title: system.title,
|
|
183
|
+
status: system.status,
|
|
184
|
+
inScope: system.inScope
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setupTargetSummary(workspace) {
|
|
189
|
+
return {
|
|
190
|
+
assuranceGoal: workspace.assuranceGoal,
|
|
191
|
+
scopeCounts: {
|
|
192
|
+
systems: workspace.systemIds?.length || 0,
|
|
193
|
+
frameworks: workspace.frameworkIds?.length || 0,
|
|
194
|
+
requirements: workspace.requirementIds?.length || 0,
|
|
195
|
+
controls: workspace.controlIds?.length || 0
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
162
200
|
function booleanValue(value, name) {
|
|
163
201
|
if (value === true || value === false) return value;
|
|
164
202
|
if (value === "true") return true;
|
package/src/state.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
47
47
|
dataModelVersion: loaded.model.modelVersion,
|
|
48
48
|
id: "workspace",
|
|
49
49
|
type: "workspace",
|
|
50
|
-
title: "
|
|
50
|
+
title: "filegrc workspace",
|
|
51
51
|
organizationName: "Workspace configuration unavailable",
|
|
52
52
|
timezone: "UTC"
|
|
53
53
|
};
|
package/src/validate.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getResourceDefinition } from "../model/index.js";
|
|
|
3
3
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
4
4
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
5
5
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
6
|
+
import { partyPeople } from "./parties.js";
|
|
6
7
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
7
8
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
8
9
|
import { indexResources, loadWorkspace } from "./workspace.js";
|
|
@@ -223,9 +224,10 @@ function validateEvidencePaths(record, path, diagnostics) {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
226
|
-
if (!["policy", "document"].includes(record.type)
|
|
227
|
-
|
|
228
|
-
const
|
|
227
|
+
if (!["policy", "document"].includes(record.type)) return;
|
|
228
|
+
if (!(record.approverIds || []).length) return;
|
|
229
|
+
const owners = partyPeople(record.ownerIds || [], byId);
|
|
230
|
+
const approvers = partyPeople(record.approverIds || [], byId);
|
|
229
231
|
const overlap = [...owners].filter((id) => approvers.has(id));
|
|
230
232
|
if (overlap.length) {
|
|
231
233
|
diagnostics.push(error(
|
|
@@ -234,37 +236,6 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
|
234
236
|
`Approvers must be separate from owners, including through team membership: ${overlap.join(", ")}.`
|
|
235
237
|
));
|
|
236
238
|
}
|
|
237
|
-
if (["approved", "active"].includes(record.status)) {
|
|
238
|
-
const incompleteStarterApprover = [...approvers]
|
|
239
|
-
.map((id) => byId.get(id))
|
|
240
|
-
.find((person) => (
|
|
241
|
-
person?.id === "person-independent-approver"
|
|
242
|
-
&& ["Independent Approver", "Independent Reviewer"].includes(person.title)
|
|
243
|
-
));
|
|
244
|
-
if (incompleteStarterApprover) {
|
|
245
|
-
diagnostics.push(error(
|
|
246
|
-
"independent-approver-not-appointed",
|
|
247
|
-
path,
|
|
248
|
-
`The selected approver "${incompleteStarterApprover.title}" is still the starter placeholder. Open People and replace it with the reviewer's actual name before approving this record. The reviewer may be internal or external but must be separate from the owner.`
|
|
249
|
-
));
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function expandPeople(ids, byId, seen = new Set()) {
|
|
255
|
-
const people = new Set();
|
|
256
|
-
for (const id of ids) {
|
|
257
|
-
if (seen.has(id)) continue;
|
|
258
|
-
seen.add(id);
|
|
259
|
-
const record = byId.get(id);
|
|
260
|
-
if (record?.type === "person") people.add(id);
|
|
261
|
-
if (record?.type === "team") {
|
|
262
|
-
for (const personId of expandPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen)) {
|
|
263
|
-
people.add(personId);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return people;
|
|
268
239
|
}
|
|
269
240
|
|
|
270
241
|
function validateObligation(record, path, diagnostics) {
|