filegrc 0.7.1 → 0.9.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/README.md +7 -7
- package/model/index.js +22 -4
- package/model/v5.json +10233 -0
- package/model/v6.json +10358 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +143 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +181 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +173 -35
- package/src/git.js +71 -7
- package/src/index.js +11 -1
- package/src/model-migration.js +363 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +7 -3
- package/src/program-lifecycle.js +131 -3
- package/src/program-path.js +19 -13
- package/src/program-readiness.js +338 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +36 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +178 -16
- package/src/web.js +264 -17
- package/src/workflow.js +38 -7
- package/src/workspace.js +5 -0
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import { loadModel } from "../model/index.js";
|
|
4
|
+
import { ACTIVE_MODEL_VERSION, loadModel, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
|
|
5
5
|
import { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
|
|
6
6
|
import { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
7
7
|
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
@@ -16,6 +16,14 @@ import {
|
|
|
16
16
|
scaffoldCollectionReview
|
|
17
17
|
} from "./collection-review.js";
|
|
18
18
|
import { buildWorkspace } from "./build.js";
|
|
19
|
+
import {
|
|
20
|
+
activateDocuments,
|
|
21
|
+
activateGovernedContent,
|
|
22
|
+
planDocumentActivation,
|
|
23
|
+
planGovernedContentActivation,
|
|
24
|
+
scaffoldDocumentActivation,
|
|
25
|
+
scaffoldGovernedContentActivation
|
|
26
|
+
} from "./document-activation.js";
|
|
19
27
|
import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
|
|
20
28
|
import {
|
|
21
29
|
addEvidenceAttachment,
|
|
@@ -183,15 +191,18 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
183
191
|
}
|
|
184
192
|
if (command === "migrate") {
|
|
185
193
|
const targetModel = String(flags["to-model"] || "");
|
|
186
|
-
if (!
|
|
187
|
-
|
|
194
|
+
if (!SUPPORTED_MODEL_VERSIONS.filter((version) => version !== "1").includes(targetModel)) {
|
|
195
|
+
throw new Error(`Pass --to-model ${SUPPORTED_MODEL_VERSIONS.filter((version) => version !== "1").join(", --to-model ")}.`);
|
|
196
|
+
}
|
|
197
|
+
const migrationDecisions = flags.decisions
|
|
188
198
|
? JSON.parse(await readFile(resolve(String(flags.decisions)), "utf8"))
|
|
189
199
|
: undefined;
|
|
190
200
|
const options = {
|
|
191
201
|
jobTitle: flags["job-title"],
|
|
192
202
|
startsOn: flags["starts-on"],
|
|
193
203
|
targetModelVersion: targetModel,
|
|
194
|
-
systemDecisions:
|
|
204
|
+
systemDecisions: migrationDecisions?.systemDecisions || (targetModel === "4" ? migrationDecisions : undefined),
|
|
205
|
+
documentScopes: migrationDecisions?.documentScopes || (targetModel === "5" ? migrationDecisions : undefined)
|
|
195
206
|
};
|
|
196
207
|
const plan = await planModelMigration(root, options);
|
|
197
208
|
if (!flags.preview && plan.sourceModelVersion !== plan.targetModelVersion && !flags.yes) {
|
|
@@ -617,6 +628,55 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
617
628
|
else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
|
|
618
629
|
return result;
|
|
619
630
|
}
|
|
631
|
+
if (command === "activate-documents") {
|
|
632
|
+
if (flags.scaffold) {
|
|
633
|
+
const result = await scaffoldDocumentActivation(root, { programId: flags.program, auditId: flags.audit });
|
|
634
|
+
console.log(JSON.stringify(result, null, 2));
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
const payload = await readSetupPayload(positionals[0]);
|
|
638
|
+
const options = {
|
|
639
|
+
...payload,
|
|
640
|
+
documentIds: flags.document ? String(flags.document).split(",").filter(Boolean) : payload.documentIds,
|
|
641
|
+
activatedByIds: flags["activated-by"] ? String(flags["activated-by"]).split(",").filter(Boolean) : payload.activatedByIds,
|
|
642
|
+
activatedOn: flags["activated-on"] || payload.activatedOn,
|
|
643
|
+
effectiveOn: flags["effective-on"] || payload.effectiveOn,
|
|
644
|
+
programId: flags.program || payload.programId,
|
|
645
|
+
auditId: flags.audit || payload.auditId,
|
|
646
|
+
confirmed: flags.yes === true
|
|
647
|
+
};
|
|
648
|
+
const result = flags.preview
|
|
649
|
+
? await planDocumentActivation(root, options)
|
|
650
|
+
: await withWorkflowDelta(root, () => activateDocuments(root, options));
|
|
651
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
652
|
+
else if (flags.preview) console.log(`Document activation preview: ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
|
|
653
|
+
else console.log(`Activated ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
656
|
+
if (command === "activate-content") {
|
|
657
|
+
if (flags.scaffold) {
|
|
658
|
+
const result = await scaffoldGovernedContentActivation(root, { programId: flags.program });
|
|
659
|
+
console.log(JSON.stringify(result, null, 2));
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
const payload = await readSetupPayload(positionals[0]);
|
|
663
|
+
const options = {
|
|
664
|
+
...payload,
|
|
665
|
+
resourceIds: flags.resource ? String(flags.resource).split(",").filter(Boolean) : payload.resourceIds,
|
|
666
|
+
activatedByIds: flags["activated-by"] ? String(flags["activated-by"]).split(",").filter(Boolean) : payload.activatedByIds,
|
|
667
|
+
activatedOn: flags["activated-on"] || payload.activatedOn,
|
|
668
|
+
effectiveOn: flags["effective-on"] || payload.effectiveOn,
|
|
669
|
+
programId: flags.program || payload.programId,
|
|
670
|
+
confirmed: flags.yes === true
|
|
671
|
+
};
|
|
672
|
+
const result = flags.preview
|
|
673
|
+
? await planGovernedContentActivation(root, options)
|
|
674
|
+
: await withWorkflowDelta(root, () => activateGovernedContent(root, options));
|
|
675
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
676
|
+
else if (flags.preview) console.log(`Governed-content activation preview: ${result.resourceIds.length} records effective ${result.effectiveOn}.`);
|
|
677
|
+
else console.log(`Activated ${result.resourceIds.length} governed-content records effective ${result.effectiveOn}.`);
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
620
680
|
if (command === "policy-library") {
|
|
621
681
|
if (flags.yes && !flags.accept) {
|
|
622
682
|
throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
|
|
@@ -1055,7 +1115,7 @@ Usage:
|
|
|
1055
1115
|
filegrc build [root] [--output .filegrc/site]
|
|
1056
1116
|
filegrc validate [root] [--json]
|
|
1057
1117
|
filegrc model [--json|--write-docs|--check-docs]
|
|
1058
|
-
filegrc migrate --to-model <2|3|4> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
|
|
1118
|
+
filegrc migrate --to-model <2|3|4|5|6> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
|
|
1059
1119
|
filegrc describe <resource-type>
|
|
1060
1120
|
filegrc types [--json]
|
|
1061
1121
|
filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
|
|
@@ -1078,6 +1138,8 @@ Usage:
|
|
|
1078
1138
|
filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
|
|
1079
1139
|
filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
|
|
1080
1140
|
filegrc activate-policies [--scaffold | activation.json|-] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1141
|
+
filegrc activate-content [--scaffold | activation.json|-] [--program id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1142
|
+
filegrc activate-documents [--scaffold | activation.json|-] [--program id | --audit id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1081
1143
|
filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
|
|
1082
1144
|
filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
|
|
1083
1145
|
filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
|
|
@@ -1145,19 +1207,21 @@ Options:
|
|
|
1145
1207
|
}
|
|
1146
1208
|
if (command === "migrate") {
|
|
1147
1209
|
console.log(`Usage:
|
|
1148
|
-
filegrc migrate --to-model
|
|
1210
|
+
filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [options]
|
|
1149
1211
|
|
|
1150
1212
|
Upgrade a workspace through an explicit, reviewable model boundary. Model v1
|
|
1151
|
-
workspaces migrate to v2 first.
|
|
1213
|
+
workspaces migrate to v2 first. Continue one version at a time through v${ACTIVE_MODEL_VERSION}. v4 separates
|
|
1152
1214
|
the repository Workspace, management Program, bounded Systems, operational Components,
|
|
1153
|
-
specific Assets, Vendors, normalized information, and Evidence Artifacts.
|
|
1215
|
+
specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
|
|
1216
|
+
Document approval from activation and records program-versus-engagement scope. Model v6 gives
|
|
1217
|
+
Training the same approval and activation split and moves its schedule into Obligations. v3 migration
|
|
1154
1218
|
previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
|
|
1155
1219
|
core Appointments, removal of obsolete manual page state, classified review work,
|
|
1156
1220
|
and dataModelVersion changed last. The command writes no Git commit.
|
|
1157
1221
|
|
|
1158
1222
|
Options:
|
|
1159
|
-
--to-model <version> Required target model; migrations must run in order through
|
|
1160
|
-
--decisions <path>
|
|
1223
|
+
--to-model <version> Required target model; migrations must run in order through ${SUPPORTED_MODEL_VERSIONS.join(", ")}
|
|
1224
|
+
--decisions <path> JSON systemDecisions for v4 or documentScopes for ambiguous v5 Documents
|
|
1161
1225
|
--preview Show the complete atomic record plan without writing
|
|
1162
1226
|
--job-title <title> Actual job title for the former Policy Owner seed person
|
|
1163
1227
|
--starts-on <date> Effective date of a new Policy Owner Appointment
|
|
@@ -1167,7 +1231,7 @@ Options:
|
|
|
1167
1231
|
--help Show this help
|
|
1168
1232
|
|
|
1169
1233
|
Start with:
|
|
1170
|
-
npx filegrc migrate --to-model
|
|
1234
|
+
npx filegrc migrate --to-model ${ACTIVE_MODEL_VERSION} --preview --json`);
|
|
1171
1235
|
return;
|
|
1172
1236
|
}
|
|
1173
1237
|
if (command === "program-readiness") {
|
|
@@ -1248,6 +1312,54 @@ Options:
|
|
|
1248
1312
|
--help Show this help`);
|
|
1249
1313
|
return;
|
|
1250
1314
|
}
|
|
1315
|
+
if (command === "activate-documents") {
|
|
1316
|
+
console.log(`Usage:
|
|
1317
|
+
filegrc activate-documents --scaffold [--program id | --audit id]
|
|
1318
|
+
filegrc activate-documents <activation.json|-> [--audit id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1319
|
+
|
|
1320
|
+
Activate required program Documents in Step 3 after their linked
|
|
1321
|
+
Controls are implemented, or activate engagement Documents in Step 5 after
|
|
1322
|
+
their audit-specific facts are complete. Approval, activation, and effective
|
|
1323
|
+
dates remain separate, and activation binds its own exact Markdown revision.
|
|
1324
|
+
|
|
1325
|
+
Options:
|
|
1326
|
+
--scaffold Print the ready activation payload without writing
|
|
1327
|
+
--program <id> Program to assess when more than one active Program exists
|
|
1328
|
+
--audit <id> Audit whose engagement Documents should be activated in Step 5
|
|
1329
|
+
--activated-by <id> Active Person who performs the activation
|
|
1330
|
+
--activated-on <date> Actual activation date, which must be today
|
|
1331
|
+
--effective-on <date> Effective date on or after activation
|
|
1332
|
+
--preview Validate and show the atomic updates without writing
|
|
1333
|
+
--yes Confirm and apply the reviewed activation
|
|
1334
|
+
--json Print the result as JSON
|
|
1335
|
+
--root <path> Workspace path
|
|
1336
|
+
--help Show this help`);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (command === "activate-content") {
|
|
1340
|
+
console.log(`Usage:
|
|
1341
|
+
filegrc activate-content --scaffold [--program id]
|
|
1342
|
+
filegrc activate-content <activation.json|-> [--resource id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1343
|
+
|
|
1344
|
+
Activate approved program Documents and Training in Step 3 after their linked
|
|
1345
|
+
Controls are implemented and Training has an enabled assignment Obligation.
|
|
1346
|
+
Approval, activation, and effective dates remain separate, and activation binds
|
|
1347
|
+
the exact approved Markdown revision.
|
|
1348
|
+
|
|
1349
|
+
Options:
|
|
1350
|
+
--scaffold Print the ready activation payload without writing
|
|
1351
|
+
--program <id> Program to assess when more than one active Program exists
|
|
1352
|
+
--resource <id> Comma-separated Document or Training IDs
|
|
1353
|
+
--activated-by <id> Active Person who performs the activation
|
|
1354
|
+
--activated-on <date> Actual activation date, which must be today
|
|
1355
|
+
--effective-on <date> Effective date on or after activation
|
|
1356
|
+
--preview Validate and show the atomic updates without writing
|
|
1357
|
+
--yes Confirm and apply the reviewed activation
|
|
1358
|
+
--json Print the result as JSON
|
|
1359
|
+
--root <path> Workspace path
|
|
1360
|
+
--help Show this help`);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1251
1363
|
if (command === "policy-library") {
|
|
1252
1364
|
console.log(`Usage:
|
|
1253
1365
|
filegrc policy-library [--json]
|
|
@@ -1294,7 +1406,7 @@ function agentOverview(model) {
|
|
|
1294
1406
|
build: "filegrc build [root]",
|
|
1295
1407
|
validate: "filegrc validate [root] --json",
|
|
1296
1408
|
model: "filegrc model --json",
|
|
1297
|
-
migrate: "filegrc migrate --to-model
|
|
1409
|
+
migrate: "filegrc migrate --to-model 6 --preview --json",
|
|
1298
1410
|
describe: "filegrc describe <resource-type>",
|
|
1299
1411
|
types: "filegrc types --json",
|
|
1300
1412
|
guide: "filegrc guide [resource-type] --json",
|
|
@@ -1313,6 +1425,8 @@ function agentOverview(model) {
|
|
|
1313
1425
|
reconcile: "filegrc reconcile --preview --json",
|
|
1314
1426
|
externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
|
|
1315
1427
|
policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
|
|
1428
|
+
documentActivation: "filegrc activate-documents [--scaffold | <activation.json|-> --preview] --json",
|
|
1429
|
+
governedContentActivation: "filegrc activate-content [--scaffold | <activation.json|-> --preview] --json",
|
|
1316
1430
|
nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
|
|
1317
1431
|
reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
|
|
1318
1432
|
reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
|
|
@@ -1465,6 +1579,8 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
|
|
|
1465
1579
|
evidenceReady: readiness.evidenceReady,
|
|
1466
1580
|
operating: readiness.operating,
|
|
1467
1581
|
policyActivations: readiness.policyActivations,
|
|
1582
|
+
documentActivations: readiness.documentActivations,
|
|
1583
|
+
trainingActivations: readiness.trainingActivations,
|
|
1468
1584
|
policyLibraryProposals: readiness.policyLibraryProposals,
|
|
1469
1585
|
stages
|
|
1470
1586
|
};
|
|
@@ -1668,6 +1784,21 @@ function summarizeProgramReadiness(result) {
|
|
|
1668
1784
|
label,
|
|
1669
1785
|
gapCount
|
|
1670
1786
|
})),
|
|
1787
|
+
documentActivations: result.documentActivations.map(({ documentId, title, state, label, gapCount }) => ({
|
|
1788
|
+
documentId,
|
|
1789
|
+
title,
|
|
1790
|
+
state,
|
|
1791
|
+
label,
|
|
1792
|
+
gapCount
|
|
1793
|
+
})),
|
|
1794
|
+
trainingActivations: result.trainingActivations.map(({ trainingId, title, state, label, gapCount, assignmentScheduled }) => ({
|
|
1795
|
+
trainingId,
|
|
1796
|
+
title,
|
|
1797
|
+
state,
|
|
1798
|
+
label,
|
|
1799
|
+
gapCount,
|
|
1800
|
+
assignmentScheduled
|
|
1801
|
+
})),
|
|
1671
1802
|
policyLibraryProposals: result.policyLibraryProposals,
|
|
1672
1803
|
unresolvedOwnership: {
|
|
1673
1804
|
count: unresolvedOwnership.length,
|
package/src/collection-review.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { modelSupports } from "../model/index.js";
|
|
2
3
|
import { collectionRevision } from "./collection-revision.js";
|
|
3
4
|
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
4
5
|
import { applyResourceBatch } from "./files.js";
|
|
@@ -26,7 +27,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
26
27
|
record.type === "collection-review"
|
|
27
28
|
&& record.resourceType === resourceType
|
|
28
29
|
&& record.status !== "retired"
|
|
29
|
-
&& (
|
|
30
|
+
&& (!modelSupports(loaded.model, "program-scope") || (record.scopeResourceIds || []).includes(program.id))
|
|
30
31
|
));
|
|
31
32
|
const review = reviewEntry?.record || null;
|
|
32
33
|
const authoritativeSourceId = review?.decision === "externally-managed"
|
|
@@ -86,7 +87,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
86
87
|
rationale: null,
|
|
87
88
|
reviewedByIds: [],
|
|
88
89
|
reviewedOn: null,
|
|
89
|
-
...(
|
|
90
|
+
...(modelSupports(loaded.model, "program-scope")
|
|
90
91
|
? { authoritativeComponentId: null }
|
|
91
92
|
: { authoritativeSystemId: null })
|
|
92
93
|
};
|
|
@@ -103,7 +104,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
103
104
|
const reviewedByIds = [...new Set((options.reviewedByIds || []).map(String).filter(Boolean))];
|
|
104
105
|
const reviewedOn = String(options.reviewedOn || "").trim();
|
|
105
106
|
const scopeRevision = String(options.scopeRevision || getGitSummary(loaded.root).commit || "uncommitted").trim();
|
|
106
|
-
const v4 =
|
|
107
|
+
const v4 = modelSupports(loaded.model, "program-scope");
|
|
107
108
|
const authoritativeSourceId = String(v4 ? options.authoritativeComponentId : options.authoritativeSystemId || "").trim();
|
|
108
109
|
if (!(configuration.decisions || ["complete"]).includes(decision)) {
|
|
109
110
|
throw new Error(
|
package/src/collection-scope.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
1
2
|
import { programComponents, selectedRequirementIds } from "./program.js";
|
|
2
3
|
|
|
3
4
|
export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
4
|
-
if (
|
|
5
|
+
if (!modelSupports(loaded.model, "program-scope")) {
|
|
5
6
|
return loaded.resources.filter((record) => record.type === resourceType);
|
|
6
7
|
}
|
|
7
8
|
if (resourceType === "vendor") {
|
|
@@ -37,7 +38,7 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
37
38
|
|
|
38
39
|
export function collectionRevisionInputs(loaded, resourceType, program) {
|
|
39
40
|
const reviewed = scopedCollectionRecords(loaded, resourceType, program);
|
|
40
|
-
if (
|
|
41
|
+
if (!modelSupports(loaded.model, "program-scope")) {
|
|
41
42
|
return reviewed.map((record) => ({ record, value: record }));
|
|
42
43
|
}
|
|
43
44
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
@@ -94,7 +95,7 @@ export function collectionRevisionInputs(loaded, resourceType, program) {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
97
|
-
if (
|
|
98
|
+
if (!modelSupports(loaded.model, "program-scope")) {
|
|
98
99
|
const common = { programId: program?.id ?? null };
|
|
99
100
|
if (resourceType === "framework") {
|
|
100
101
|
return {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { applyDocumentActivationBatch, applyGovernedContentActivationBatch, contentRevision } from "./files.js";
|
|
2
|
+
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { assessAuditDocumentActivations } from "./audit-preparation.js";
|
|
5
|
+
import { assessProgramReadiness } from "./program-readiness.js";
|
|
6
|
+
import { personWasActiveOn } from "./soc2.js";
|
|
7
|
+
import { currentCalendarDate } from "./time.js";
|
|
8
|
+
import { loadWorkspace } from "./workspace.js";
|
|
9
|
+
|
|
10
|
+
export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) {
|
|
11
|
+
const loaded = await loadWorkspace(input);
|
|
12
|
+
requireDocumentLifecycle(loaded);
|
|
13
|
+
const candidates = await activationCandidates(loaded, { ...options, documentsOnly: true });
|
|
14
|
+
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
15
|
+
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
16
|
+
return {
|
|
17
|
+
documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
|
|
18
|
+
...(options.auditId ? { auditId: options.auditId, workflowScope: "engagement" } : { workflowScope: "program" }),
|
|
19
|
+
activatedByIds: [],
|
|
20
|
+
activatedOn: today,
|
|
21
|
+
effectiveOn: today,
|
|
22
|
+
expectedRevisions: Object.fromEntries(candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)])),
|
|
23
|
+
confirmed: false
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function planDocumentActivation(input = process.cwd(), options = {}) {
|
|
28
|
+
const loaded = await loadWorkspace(input);
|
|
29
|
+
requireDocumentLifecycle(loaded);
|
|
30
|
+
const resourceIds = [...new Set((options.resourceIds || options.documentIds || []).map(String))];
|
|
31
|
+
if (!resourceIds.length) throw new Error("Governed-content activation needs at least one approved program Document or Training record.");
|
|
32
|
+
const activatedByIds = [...new Set((options.activatedByIds || []).map(String))];
|
|
33
|
+
if (!activatedByIds.length) throw new Error("Document activation needs the Person who performed the activation.");
|
|
34
|
+
const activatedOn = String(options.activatedOn || "").trim();
|
|
35
|
+
const effectiveOn = String(options.effectiveOn || "").trim();
|
|
36
|
+
if (!isCalendarDate(activatedOn)) throw new Error("Document activation needs the actual activation date in YYYY-MM-DD format.");
|
|
37
|
+
if (!isCalendarDate(effectiveOn)) throw new Error("Document activation needs a real effective date in YYYY-MM-DD format.");
|
|
38
|
+
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
39
|
+
if (activatedOn !== today) {
|
|
40
|
+
throw new Error(`The activation date records today's lifecycle event and must be ${today}. Keep a future date in proposedEffectiveOn until activation occurs.`);
|
|
41
|
+
}
|
|
42
|
+
if (effectiveOn < activatedOn) {
|
|
43
|
+
throw new Error("The effective date cannot be before the separate activation date; do not backdate adoption.");
|
|
44
|
+
}
|
|
45
|
+
const expectedRevisions = options.expectedRevisions || {};
|
|
46
|
+
if (Array.isArray(expectedRevisions) || !expectedRevisions || typeof expectedRevisions !== "object") {
|
|
47
|
+
throw new Error("Governed-content activation expected revisions must be keyed by resource ID.");
|
|
48
|
+
}
|
|
49
|
+
const workflowScope = options.auditId ? "engagement" : "program";
|
|
50
|
+
const governedContentOperation = workflowScope === "program" && options.resourceIds !== undefined;
|
|
51
|
+
const candidates = await activationCandidates(loaded, options);
|
|
52
|
+
const assessmentById = new Map(candidates.map((item) => [item.resourceId, item]));
|
|
53
|
+
const entryById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
54
|
+
const invalidActivatorIds = activatedByIds.filter((id) => {
|
|
55
|
+
const person = entryById.get(id)?.record;
|
|
56
|
+
return !personWasActiveOn(person, activatedOn);
|
|
57
|
+
});
|
|
58
|
+
if (invalidActivatorIds.length) {
|
|
59
|
+
throw new Error(`Document activation needs active People as activators: ${invalidActivatorIds.join(", ")}.`);
|
|
60
|
+
}
|
|
61
|
+
const update = resourceIds.map((resourceId) => {
|
|
62
|
+
const entry = entryById.get(resourceId);
|
|
63
|
+
const allowedTypes = governedContentOperation ? ["document", "training"] : ["document"];
|
|
64
|
+
if (!entry || !allowedTypes.includes(entry.record.type)) throw new Error(`Governed content "${resourceId}" was not found in the ${workflowScope} workflow.`);
|
|
65
|
+
const resourceTitle = loaded.model.resources[entry.record.type].title;
|
|
66
|
+
if (entry.record.status !== "approved") {
|
|
67
|
+
throw new Error(`${resourceTitle} "${resourceId}" must be independently approved before its separate ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`);
|
|
68
|
+
}
|
|
69
|
+
if (entry.record.type === "document" && entry.record.workflowScope !== workflowScope) {
|
|
70
|
+
throw new Error(`Document "${resourceId}" belongs to the ${entry.record.workflowScope} workflow, not ${workflowScope}.`);
|
|
71
|
+
}
|
|
72
|
+
const assessment = assessmentById.get(resourceId);
|
|
73
|
+
if (assessment?.state !== "ready-to-activate") {
|
|
74
|
+
const missing = assessment?.missingImplementationControlIds || [];
|
|
75
|
+
throw new Error(
|
|
76
|
+
missing.length
|
|
77
|
+
? `${resourceTitle} "${resourceId}" cannot be activated until linked Controls are implemented: ${missing.join(", ")}.`
|
|
78
|
+
: `${resourceTitle} "${resourceId}" is not ready for ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!/^[a-f0-9]{64}$/.test(expectedRevisions[resourceId] || "")) {
|
|
82
|
+
throw new Error(`Governed-content activation needs the current record revision for "${resourceId}". Regenerate the activation review and try again.`);
|
|
83
|
+
}
|
|
84
|
+
const record = {
|
|
85
|
+
...entry.record,
|
|
86
|
+
status: "active",
|
|
87
|
+
activationBasis: "recorded",
|
|
88
|
+
activatedByIds,
|
|
89
|
+
activatedOn,
|
|
90
|
+
effectiveOn,
|
|
91
|
+
activatedContentRevisions: structuredClone(entry.record.approvedContentRevisions)
|
|
92
|
+
};
|
|
93
|
+
delete record.proposedEffectiveOn;
|
|
94
|
+
return record;
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
operation: governedContentOperation ? "governed-content-activation" : "document-activation",
|
|
98
|
+
workflowScope,
|
|
99
|
+
...(options.auditId ? { auditId: options.auditId } : {}),
|
|
100
|
+
resourceIds,
|
|
101
|
+
documentIds: update.filter(({ type }) => type === "document").map(({ id }) => id),
|
|
102
|
+
trainingIds: update.filter(({ type }) => type === "training").map(({ id }) => id),
|
|
103
|
+
activatedByIds,
|
|
104
|
+
activatedOn,
|
|
105
|
+
effectiveOn,
|
|
106
|
+
changes: {
|
|
107
|
+
update,
|
|
108
|
+
expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, expectedRevisions[resourceId]])),
|
|
109
|
+
validateWholeWorkspace: true
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function activationCandidates(loaded, options) {
|
|
115
|
+
if (options.auditId) {
|
|
116
|
+
return (await assessAuditDocumentActivations(loaded, {
|
|
117
|
+
auditId: options.auditId,
|
|
118
|
+
asOf: currentCalendarDate(loaded.workspace.timezone)
|
|
119
|
+
})).filter(({ state }) => state === "ready-to-activate")
|
|
120
|
+
.map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId }));
|
|
121
|
+
}
|
|
122
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
123
|
+
return [
|
|
124
|
+
...readiness.documentActivations.map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId })),
|
|
125
|
+
...(!options.documentsOnly ? (readiness.trainingActivations || []).map((item) => ({ ...item, resourceType: "training", resourceId: item.trainingId })) : [])
|
|
126
|
+
].filter(({ state }) => state === "ready-to-activate");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function activateDocuments(input = process.cwd(), options = {}) {
|
|
130
|
+
if (options.confirmed !== true) throw new Error("Review the governed Document activation and confirm the write.");
|
|
131
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
132
|
+
const plan = await planDocumentActivation(root, options);
|
|
133
|
+
const result = plan.operation === "governed-content-activation"
|
|
134
|
+
? await applyGovernedContentActivationBatch(root, plan.changes)
|
|
135
|
+
: await applyDocumentActivationBatch(root, plan.changes);
|
|
136
|
+
return { ...plan, result };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function scaffoldGovernedContentActivation(input = process.cwd(), options = {}) {
|
|
141
|
+
const loaded = await loadWorkspace(input);
|
|
142
|
+
requireDocumentLifecycle(loaded);
|
|
143
|
+
if (!modelSupports(loaded.model, "governed-training-activation")) {
|
|
144
|
+
throw new Error("Unified governed-content activation requires a model v6 workspace.");
|
|
145
|
+
}
|
|
146
|
+
const candidates = await activationCandidates(loaded, { ...options, auditId: undefined });
|
|
147
|
+
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
148
|
+
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
149
|
+
return {
|
|
150
|
+
resourceIds: candidates.map(({ resourceId }) => resourceId),
|
|
151
|
+
documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
|
|
152
|
+
trainingIds: candidates.filter(({ resourceType }) => resourceType === "training").map(({ resourceId }) => resourceId),
|
|
153
|
+
workflowScope: "program",
|
|
154
|
+
activatedByIds: [],
|
|
155
|
+
activatedOn: today,
|
|
156
|
+
effectiveOn: today,
|
|
157
|
+
expectedRevisions: Object.fromEntries(candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)])),
|
|
158
|
+
confirmed: false
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const planGovernedContentActivation = planDocumentActivation;
|
|
163
|
+
export const activateGovernedContent = activateDocuments;
|
|
164
|
+
|
|
165
|
+
function requireDocumentLifecycle(loaded) {
|
|
166
|
+
if (!modelSupports(loaded.model, "governed-document-activation")) {
|
|
167
|
+
throw new Error("Separate governed Document approval and activation requires a model v5 workspace.");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isCalendarDate(value) {
|
|
172
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
173
|
+
if (!match) return false;
|
|
174
|
+
const [, year, month, day] = match.map(Number);
|
|
175
|
+
const date = new Date(0);
|
|
176
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
177
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
178
|
+
return date.getUTCFullYear() === year
|
|
179
|
+
&& date.getUTCMonth() === month - 1
|
|
180
|
+
&& date.getUTCDate() === day;
|
|
181
|
+
}
|