filegrc 0.9.1 → 0.10.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 +4 -4
- package/model/v7.json +10359 -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 +20 -7
- package/src/collection-review.js +16 -3
- package/src/collection-revision.js +29 -4
- package/src/collection-scope.js +110 -11
- package/src/document-activation.js +13 -1
- package/src/model-migration.js +224 -34
- package/src/obligations.js +128 -31
- package/src/policy-activation.js +5 -0
- package/src/program-lifecycle.js +1 -1
- package/src/program-path.js +1 -4
- package/src/program-readiness.js +20 -3
- package/src/reconciliation.js +15 -3
- package/src/server.js +2 -1
- package/src/setup.js +1 -1
- package/src/state.js +2 -1
- package/src/validate.js +25 -7
- package/src/web.js +121 -43
- package/src/workflow.js +7 -6
package/src/model-migration.js
CHANGED
|
@@ -3,7 +3,9 @@ import { applyModelMigrationBatch, applyResourceBatch, contentRevision } from ".
|
|
|
3
3
|
import { loadWorkspace } from "./workspace.js";
|
|
4
4
|
import { ACTIVE_MODEL_VERSION, loadModel } from "../model/index.js";
|
|
5
5
|
import { legacyCoverage } from "./coverage.js";
|
|
6
|
-
import {
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
7
9
|
import { resolveDataPath } from "./paths.js";
|
|
8
10
|
import { markdownEntries } from "./resource-markdown.js";
|
|
9
11
|
|
|
@@ -895,7 +897,8 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
895
897
|
: sourceVersion === "2" ? "3"
|
|
896
898
|
: sourceVersion === "3" ? "4"
|
|
897
899
|
: sourceVersion === "4" ? "5"
|
|
898
|
-
:
|
|
900
|
+
: sourceVersion === "5" ? "6"
|
|
901
|
+
: ACTIVE_MODEL_VERSION;
|
|
899
902
|
if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
|
|
900
903
|
if (sourceVersion === "1" && requestedTarget === "2") {
|
|
901
904
|
return planV1ToV2Migration(input, options);
|
|
@@ -909,20 +912,24 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
909
912
|
if (sourceVersion === "4" && requestedTarget === "5") {
|
|
910
913
|
return planV4ToV5Migration(loaded, options);
|
|
911
914
|
}
|
|
912
|
-
if (sourceVersion === "5" && requestedTarget ===
|
|
915
|
+
if (sourceVersion === "5" && requestedTarget === "6") {
|
|
913
916
|
return planV5ToV6Migration(loaded);
|
|
914
917
|
}
|
|
918
|
+
if (sourceVersion === "6" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
919
|
+
return planV6ToV7Migration(loaded);
|
|
920
|
+
}
|
|
915
921
|
if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
916
922
|
throw new Error(
|
|
917
923
|
"Model v1 workspaces must migrate to model v2 first. "
|
|
918
|
-
+
|
|
924
|
+
+ `Preview and apply \`npx filegrc migrate --to-model 2\`, then migrate one version at a time through model v${ACTIVE_MODEL_VERSION}.`
|
|
919
925
|
);
|
|
920
926
|
}
|
|
921
927
|
throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
|
|
922
928
|
}
|
|
923
929
|
|
|
924
930
|
export async function migrateModel(input = process.cwd(), options = {}) {
|
|
925
|
-
const
|
|
931
|
+
const loaded = await loadWorkspace(input);
|
|
932
|
+
const plan = await planModelMigration(loaded.root, options);
|
|
926
933
|
if (plan.sourceModelVersion === plan.targetModelVersion) {
|
|
927
934
|
return { ...plan, applied: false };
|
|
928
935
|
}
|
|
@@ -932,18 +939,44 @@ export async function migrateModel(input = process.cwd(), options = {}) {
|
|
|
932
939
|
+ "and resolve every missing value, conflict, manual action, and unsupported change."
|
|
933
940
|
);
|
|
934
941
|
}
|
|
942
|
+
const migrationReportPath = await persistMigrationReport(loaded.root, plan);
|
|
935
943
|
if (plan.sourceModelVersion === "1" && plan.targetModelVersion === "2") {
|
|
936
|
-
return migrateV1ToV2(
|
|
944
|
+
return { ...await migrateV1ToV2(loaded.root, options), migrationReportPath };
|
|
937
945
|
}
|
|
938
|
-
const result = await applyModelMigrationBatch(
|
|
946
|
+
const result = await applyModelMigrationBatch(loaded.root, plan.changes);
|
|
939
947
|
return {
|
|
940
948
|
...plan,
|
|
941
949
|
applied: true,
|
|
950
|
+
migrationReportPath,
|
|
942
951
|
result,
|
|
943
|
-
postMigrationAssessment: await postMigrationAssessment(
|
|
952
|
+
postMigrationAssessment: await postMigrationAssessment(loaded.root)
|
|
944
953
|
};
|
|
945
954
|
}
|
|
946
955
|
|
|
956
|
+
async function persistMigrationReport(root, plan) {
|
|
957
|
+
const relativePath = `.filegrc/migrations/model-v${plan.sourceModelVersion}-to-v${plan.targetModelVersion}.json`;
|
|
958
|
+
const directory = join(root, ".filegrc", "migrations");
|
|
959
|
+
const path = join(root, relativePath);
|
|
960
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
961
|
+
await mkdir(directory, { recursive: true });
|
|
962
|
+
try {
|
|
963
|
+
await writeFile(temporaryPath, `${JSON.stringify({
|
|
964
|
+
schemaVersion: 1,
|
|
965
|
+
sourceModelVersion: plan.sourceModelVersion,
|
|
966
|
+
targetModelVersion: plan.targetModelVersion,
|
|
967
|
+
summary: plan.summary,
|
|
968
|
+
classifications: plan.classifications,
|
|
969
|
+
migrationReport: plan.migrationReport || null,
|
|
970
|
+
fileDiff: plan.fileDiff
|
|
971
|
+
}, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
972
|
+
await rename(temporaryPath, path);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
975
|
+
throw error;
|
|
976
|
+
}
|
|
977
|
+
return relativePath;
|
|
978
|
+
}
|
|
979
|
+
|
|
947
980
|
async function planV2ToV3Migration(loaded) {
|
|
948
981
|
if (!loaded.workspace?.id) {
|
|
949
982
|
throw new Error("Model migration requires a valid Workspace record.");
|
|
@@ -1312,7 +1345,7 @@ async function planV3ToV4Migration(loaded, options = {}) {
|
|
|
1312
1345
|
? [{
|
|
1313
1346
|
systemId: targetIds[0],
|
|
1314
1347
|
roles: derivedComponentRoles(record, loaded.resources),
|
|
1315
|
-
rationale: `
|
|
1348
|
+
rationale: `Supports the bounded System "${byId.get(targetIds[0])?.title || targetIds[0]}".`
|
|
1316
1349
|
}]
|
|
1317
1350
|
: [];
|
|
1318
1351
|
if (!uses.length) {
|
|
@@ -1477,6 +1510,7 @@ async function planV3ToV4Migration(loaded, options = {}) {
|
|
|
1477
1510
|
componentIds: [...systemKinds].filter(([, kind]) => kind === "component").map(([id]) => id),
|
|
1478
1511
|
classificationIds: [...classifications.values()],
|
|
1479
1512
|
informationTypeIds: [...informationTypes.values()],
|
|
1513
|
+
unmappedLegacyFields: collectV4LegacyFields(loaded.resources, systemKinds),
|
|
1480
1514
|
relationshipUpdates: updates.filter((record) => record.type !== "workspace").map(({ id, type }) => ({ id, type }))
|
|
1481
1515
|
},
|
|
1482
1516
|
summary: {
|
|
@@ -1847,6 +1881,159 @@ async function planV5ToV6Migration(loaded) {
|
|
|
1847
1881
|
};
|
|
1848
1882
|
}
|
|
1849
1883
|
|
|
1884
|
+
async function planV6ToV7Migration(loaded) {
|
|
1885
|
+
if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
|
|
1886
|
+
const targetModel = loadModel("7");
|
|
1887
|
+
const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
1888
|
+
const automatic = [];
|
|
1889
|
+
const unsupported = [];
|
|
1890
|
+
const missing = [];
|
|
1891
|
+
const manualActions = [];
|
|
1892
|
+
const updates = [];
|
|
1893
|
+
const historicalActivationIds = [];
|
|
1894
|
+
const trainingReactivation = [];
|
|
1895
|
+
const preservedTrainingHistoryIds = [];
|
|
1896
|
+
const purifiedNarrativeIds = [];
|
|
1897
|
+
const removedMigrationExtensions = [];
|
|
1898
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
1899
|
+
|
|
1900
|
+
for (const original of loaded.resources) {
|
|
1901
|
+
const record = structuredClone(original);
|
|
1902
|
+
let changed = false;
|
|
1903
|
+
if (original.type === "workspace") {
|
|
1904
|
+
record.dataModelVersion = "7";
|
|
1905
|
+
changed = true;
|
|
1906
|
+
automatic.push(classifiedChange("automatic", original.id, "dataModelVersion", "Select model v7 and keep upgrade mechanics out of compliance entities."));
|
|
1907
|
+
}
|
|
1908
|
+
if (original.type === "document" && original.activationBasis === "legacy-v4") {
|
|
1909
|
+
record.activationBasis = "historical";
|
|
1910
|
+
changed = true;
|
|
1911
|
+
historicalActivationIds.push(original.id);
|
|
1912
|
+
automatic.push(classifiedChange(
|
|
1913
|
+
"automatic",
|
|
1914
|
+
original.id,
|
|
1915
|
+
"activationBasis",
|
|
1916
|
+
"Replace the model-version label with the equivalent historical activation basis."
|
|
1917
|
+
));
|
|
1918
|
+
}
|
|
1919
|
+
if (original.type === "training" && original.activationBasis === "legacy-v5") {
|
|
1920
|
+
delete record.activationBasis;
|
|
1921
|
+
delete record.activatedByIds;
|
|
1922
|
+
delete record.activatedOn;
|
|
1923
|
+
delete record.activatedContentRevisions;
|
|
1924
|
+
if (original.status === "active") {
|
|
1925
|
+
trainingReactivation.push({ resourceId: original.id, priorEffectiveOn: original.effectiveOn || null });
|
|
1926
|
+
record.status = "approved";
|
|
1927
|
+
delete record.effectiveOn;
|
|
1928
|
+
} else {
|
|
1929
|
+
preservedTrainingHistoryIds.push(original.id);
|
|
1930
|
+
}
|
|
1931
|
+
changed = true;
|
|
1932
|
+
automatic.push(classifiedChange(
|
|
1933
|
+
"automatic",
|
|
1934
|
+
original.id,
|
|
1935
|
+
original.status === "active" ? "status" : "activationBasis",
|
|
1936
|
+
original.status === "active"
|
|
1937
|
+
? "Keep the approved Training content and require management to record its next activation explicitly."
|
|
1938
|
+
: "Keep the closed Training lifecycle facts without a model-version activation label."
|
|
1939
|
+
));
|
|
1940
|
+
}
|
|
1941
|
+
if (
|
|
1942
|
+
original.type === "information-type"
|
|
1943
|
+
&& /^Information category migrated from the v3 dataTypes value ".+"\.$/.test(original.description || "")
|
|
1944
|
+
) {
|
|
1945
|
+
record.description = `Information handled by in-scope Systems or Components under the "${original.title}" category.`;
|
|
1946
|
+
changed = true;
|
|
1947
|
+
purifiedNarrativeIds.push(original.id);
|
|
1948
|
+
automatic.push(classifiedChange("automatic", original.id, "description", "Replace generated upgrade prose with the underlying information-handling fact."));
|
|
1949
|
+
}
|
|
1950
|
+
if (original.type === "component" && Array.isArray(original.systemUses)) {
|
|
1951
|
+
let systemUsesPurified = false;
|
|
1952
|
+
record.systemUses = original.systemUses.map((use) => {
|
|
1953
|
+
if (!/^Migrated from v3 System ".+" because it was recorded as part of or support for this bounded System\.$/.test(use.rationale || "")) return use;
|
|
1954
|
+
changed = true;
|
|
1955
|
+
systemUsesPurified = true;
|
|
1956
|
+
purifiedNarrativeIds.push(original.id);
|
|
1957
|
+
return { ...use, rationale: `Supports the bounded System "${byId.get(use.systemId)?.title || use.systemId}".` };
|
|
1958
|
+
});
|
|
1959
|
+
if (systemUsesPurified) automatic.push(classifiedChange("automatic", original.id, "systemUses", "Replace generated upgrade prose with the existing Component-to-System fact."));
|
|
1960
|
+
}
|
|
1961
|
+
if (original.type === "audit" && Array.isArray(original.subserviceTreatments)) {
|
|
1962
|
+
let treatmentsPurified = false;
|
|
1963
|
+
record.subserviceTreatments = original.subserviceTreatments.map((treatment) => {
|
|
1964
|
+
if (treatment.rationale !== "Migrated from the explicit v3 Audit subservice scope and method; management must confirm the treatment.") return treatment;
|
|
1965
|
+
changed = true;
|
|
1966
|
+
treatmentsPurified = true;
|
|
1967
|
+
purifiedNarrativeIds.push(original.id);
|
|
1968
|
+
return { ...treatment, rationale: `This Vendor is subject to the ${treatment.method} method for this engagement.` };
|
|
1969
|
+
});
|
|
1970
|
+
if (treatmentsPurified) automatic.push(classifiedChange("automatic", original.id, "subserviceTreatments", "Replace generated upgrade prose with the existing engagement treatment fact."));
|
|
1971
|
+
}
|
|
1972
|
+
if (record.extensions?.["filegrc.migration"] !== undefined) {
|
|
1973
|
+
removedMigrationExtensions.push({ resourceId: original.id, details: record.extensions["filegrc.migration"] });
|
|
1974
|
+
delete record.extensions["filegrc.migration"];
|
|
1975
|
+
if (!Object.keys(record.extensions).length) delete record.extensions;
|
|
1976
|
+
changed = true;
|
|
1977
|
+
automatic.push(classifiedChange("automatic", original.id, "extensions", "Move prior upgrade details out of the compliance record and into this migration report."));
|
|
1978
|
+
}
|
|
1979
|
+
if (changed) updates.push(record);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const updatedById = new Map(updates.map((record) => [record.id, record]));
|
|
1983
|
+
const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
|
|
1984
|
+
await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
|
|
1985
|
+
for (const item of [...missing, ...manualActions]) {
|
|
1986
|
+
unsupported.push(classifiedChange(
|
|
1987
|
+
"unsupported",
|
|
1988
|
+
item.resourceId,
|
|
1989
|
+
item.field,
|
|
1990
|
+
item.message || `Resolve ${item.field} before applying the model v7 migration.`
|
|
1991
|
+
));
|
|
1992
|
+
}
|
|
1993
|
+
const ready = unsupported.length === 0;
|
|
1994
|
+
return {
|
|
1995
|
+
schemaVersion: 2,
|
|
1996
|
+
sourceModelVersion: "6",
|
|
1997
|
+
targetModelVersion: "7",
|
|
1998
|
+
ready,
|
|
1999
|
+
missing,
|
|
2000
|
+
conflicts: [],
|
|
2001
|
+
manualActions,
|
|
2002
|
+
classifications: { automatic, reviewRequired: [], unsupported },
|
|
2003
|
+
notes: [],
|
|
2004
|
+
migrationReport: {
|
|
2005
|
+
historicalActivationIds,
|
|
2006
|
+
trainingReactivation,
|
|
2007
|
+
preservedTrainingHistoryIds,
|
|
2008
|
+
purifiedNarrativeIds: [...new Set(purifiedNarrativeIds)],
|
|
2009
|
+
removedMigrationExtensions
|
|
2010
|
+
},
|
|
2011
|
+
summary: {
|
|
2012
|
+
create: 0,
|
|
2013
|
+
update: updates.length,
|
|
2014
|
+
automatic: automatic.length,
|
|
2015
|
+
reviewRequired: 0,
|
|
2016
|
+
unsupported: unsupported.length
|
|
2017
|
+
},
|
|
2018
|
+
fileDiff: {
|
|
2019
|
+
create: [],
|
|
2020
|
+
update: updates.map((record) => ({
|
|
2021
|
+
type: record.type,
|
|
2022
|
+
id: record.id,
|
|
2023
|
+
before: loaded.resources.find(({ id }) => id === record.id),
|
|
2024
|
+
after: record
|
|
2025
|
+
}))
|
|
2026
|
+
},
|
|
2027
|
+
changes: {
|
|
2028
|
+
create: [],
|
|
2029
|
+
update: updates,
|
|
2030
|
+
expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
|
|
2031
|
+
validateWholeWorkspace: true,
|
|
2032
|
+
targetModelVersion: "7"
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1850
2037
|
function normalizeDocumentScopeDecision(value) {
|
|
1851
2038
|
const candidate = typeof value === "object" && value
|
|
1852
2039
|
? value.workflowScope || value.scope
|
|
@@ -2085,7 +2272,7 @@ function migrateV4InformationTypes(loaded, creates, automatic, reviewRequired, u
|
|
|
2085
2272
|
type: "information-type",
|
|
2086
2273
|
title: value.title,
|
|
2087
2274
|
status: classificationIds.length === 1 ? "active" : "planned",
|
|
2088
|
-
description: `Information
|
|
2275
|
+
description: `Information handled by in-scope Systems or Components under the "${value.title}" category.`,
|
|
2089
2276
|
...(classificationIds.length === 1 ? { classificationId: classificationIds[0] } : {})
|
|
2090
2277
|
});
|
|
2091
2278
|
result.set(key, id);
|
|
@@ -2113,13 +2300,7 @@ function migrateBoundedSystem(record, informationTypes, classifications) {
|
|
|
2113
2300
|
continuityObjectives: record.continuityObjectives,
|
|
2114
2301
|
statusTransition: record.statusTransition,
|
|
2115
2302
|
tags: record.tags,
|
|
2116
|
-
extensions:
|
|
2117
|
-
...(record.environment ? { environment: record.environment } : {}),
|
|
2118
|
-
...(record.vendorId ? { vendorId: record.vendorId } : {}),
|
|
2119
|
-
...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {}),
|
|
2120
|
-
...(record.evidenceSourceKinds ? { evidenceSourceKinds: record.evidenceSourceKinds } : {}),
|
|
2121
|
-
...(record.evidenceOwnerIds ? { evidenceOwnerIds: record.evidenceOwnerIds } : {})
|
|
2122
|
-
}),
|
|
2303
|
+
extensions: complianceExtensions(record.extensions),
|
|
2123
2304
|
externalIds: record.externalIds
|
|
2124
2305
|
});
|
|
2125
2306
|
}
|
|
@@ -2149,9 +2330,7 @@ function migrateComponent(record, systemUses, informationTypes, classifications)
|
|
|
2149
2330
|
continuityObjectives: record.continuityObjectives,
|
|
2150
2331
|
statusTransition: record.statusTransition,
|
|
2151
2332
|
tags: record.tags,
|
|
2152
|
-
extensions:
|
|
2153
|
-
...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {})
|
|
2154
|
-
}),
|
|
2333
|
+
extensions: complianceExtensions(record.extensions),
|
|
2155
2334
|
externalIds: record.externalIds
|
|
2156
2335
|
});
|
|
2157
2336
|
}
|
|
@@ -2165,10 +2344,8 @@ function componentKind(value, vendorId) {
|
|
|
2165
2344
|
|
|
2166
2345
|
function migrateV4Vendor(record, informationTypes, classifications, reviewRequired) {
|
|
2167
2346
|
const migrated = { ...record };
|
|
2168
|
-
const legacy = {};
|
|
2169
2347
|
for (const field of ["service", "subprocessor", "backupVendorId"]) {
|
|
2170
2348
|
if (!Object.hasOwn(migrated, field)) continue;
|
|
2171
|
-
legacy[field] = migrated[field];
|
|
2172
2349
|
delete migrated[field];
|
|
2173
2350
|
reviewRequired.push(classifiedChange(
|
|
2174
2351
|
"review-required",
|
|
@@ -2184,7 +2361,7 @@ function migrateV4Vendor(record, informationTypes, classifications, reviewRequir
|
|
|
2184
2361
|
migrated.informationTypeIds = normalizedInformationTypeIds(record.dataTypes, informationTypes);
|
|
2185
2362
|
delete migrated.dataTypes;
|
|
2186
2363
|
if (record.classificationId) migrated.classificationId = classifications.get(record.classificationId);
|
|
2187
|
-
migrated.extensions =
|
|
2364
|
+
migrated.extensions = complianceExtensions(record.extensions);
|
|
2188
2365
|
if (!Object.keys(migrated.extensions || {}).length) delete migrated.extensions;
|
|
2189
2366
|
return migrated;
|
|
2190
2367
|
}
|
|
@@ -2291,7 +2468,7 @@ function rewriteV4SystemRelationships(record, context) {
|
|
|
2291
2468
|
vendorId,
|
|
2292
2469
|
componentIds,
|
|
2293
2470
|
method,
|
|
2294
|
-
rationale:
|
|
2471
|
+
rationale: `This Vendor is subject to the ${method} method for this engagement.`
|
|
2295
2472
|
});
|
|
2296
2473
|
context.reviewRequired.push(classifiedChange("review-required", record.id, "subserviceTreatments", "Confirm each migrated audit-time subservice treatment and rationale."));
|
|
2297
2474
|
} else requireComponentDecision(record, "subserviceVendorIds", context, `Choose the Components and audit-time treatment for Vendor "${vendorId}".`);
|
|
@@ -2312,16 +2489,29 @@ function normalizedInformationTypeIds(values, informationTypes) {
|
|
|
2312
2489
|
return [...new Set((values || []).map((value) => informationTypes.get(String(value).trim().toLowerCase())).filter(Boolean))];
|
|
2313
2490
|
}
|
|
2314
2491
|
|
|
2315
|
-
function
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2492
|
+
function complianceExtensions(existing) {
|
|
2493
|
+
if (!existing) return undefined;
|
|
2494
|
+
const result = { ...existing };
|
|
2495
|
+
delete result["filegrc.migration"];
|
|
2496
|
+
return Object.keys(result).length ? result : undefined;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function collectV4LegacyFields(resources, systemKinds) {
|
|
2500
|
+
return resources.flatMap((record) => {
|
|
2501
|
+
const fieldNames = record.type === "system"
|
|
2502
|
+
? systemKinds.get(record.id) === "system"
|
|
2503
|
+
? ["environment", "vendorId", "subserviceVendorIds", "evidenceSourceKinds", "evidenceOwnerIds"]
|
|
2504
|
+
: ["subserviceVendorIds"]
|
|
2505
|
+
: record.type === "vendor"
|
|
2506
|
+
? ["service", "subprocessor", "backupVendorId"]
|
|
2507
|
+
: [];
|
|
2508
|
+
const fields = Object.fromEntries(fieldNames
|
|
2509
|
+
.filter((field) => Object.hasOwn(record, field))
|
|
2510
|
+
.map((field) => [field, record[field]]));
|
|
2511
|
+
const priorMigrationDetails = record.extensions?.["filegrc.migration"];
|
|
2512
|
+
if (priorMigrationDetails !== undefined) fields.priorMigrationDetails = priorMigrationDetails;
|
|
2513
|
+
return Object.keys(fields).length ? [{ resourceId: record.id, resourceType: record.type, fields }] : [];
|
|
2514
|
+
});
|
|
2325
2515
|
}
|
|
2326
2516
|
|
|
2327
2517
|
function cleanUndefined(value) {
|
package/src/obligations.js
CHANGED
|
@@ -37,6 +37,11 @@ const COMPLETION_TIMESTAMP_FIELDS = [
|
|
|
37
37
|
"deprovisionedOn"
|
|
38
38
|
];
|
|
39
39
|
const MAX_PLANNED_ITEMS = 10_000;
|
|
40
|
+
const SCAFFOLDED_COMPLETION_TYPES = new Set([
|
|
41
|
+
"access-review", "attestation", "backup-test", "control-activity", "control-test",
|
|
42
|
+
"evidence", "exercise", "meeting", "penetration-test", "policy-review",
|
|
43
|
+
"risk-assessment", "vendor-review", "vulnerability-scan"
|
|
44
|
+
]);
|
|
40
45
|
|
|
41
46
|
export function planObligations(resources, options = {}) {
|
|
42
47
|
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
@@ -101,7 +106,7 @@ export function planObligations(resources, options = {}) {
|
|
|
101
106
|
eventRiskLevels: obligation.eventRiskLevels || [],
|
|
102
107
|
templateResourceId: obligation.templateResourceId || null,
|
|
103
108
|
completionResourceTypes: expectedCompletionTypes,
|
|
104
|
-
completionType: activity
|
|
109
|
+
completionType: preferredCompletionType(activity, obligation, byId),
|
|
105
110
|
completionProfile: activity.completionProfile || null,
|
|
106
111
|
programStatus,
|
|
107
112
|
window: normalizedEventWindow(obligation.window)
|
|
@@ -155,7 +160,7 @@ export function planObligations(resources, options = {}) {
|
|
|
155
160
|
controlIds: obligation.controlIds || [],
|
|
156
161
|
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
157
162
|
completionResourceTypes: expectedCompletionTypes,
|
|
158
|
-
completionType: activity
|
|
163
|
+
completionType: preferredCompletionType(activity, obligation, byId),
|
|
159
164
|
completionProfile: activity.completionProfile || null,
|
|
160
165
|
completionResourceIds: completions.map((record) => record.id),
|
|
161
166
|
status,
|
|
@@ -313,6 +318,7 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
313
318
|
));
|
|
314
319
|
if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
|
|
315
320
|
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
321
|
+
assertAttestationCompletionScope(obligation, options.record, loaded.resources);
|
|
316
322
|
return createResourceAndLink(loaded.root, options.record, {
|
|
317
323
|
type: "obligation",
|
|
318
324
|
id: obligation.id,
|
|
@@ -343,7 +349,10 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
343
349
|
? plannedActionForScaffold(loaded, action, completedOn)
|
|
344
350
|
: plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
|
|
345
351
|
const activity = obligationActivity(loaded.model, obligation.activityType);
|
|
346
|
-
const type =
|
|
352
|
+
const type = item.completionType || preferredCompletionType(activity, {
|
|
353
|
+
...obligation,
|
|
354
|
+
subjectResourceIds: item.subjectResourceIds || []
|
|
355
|
+
}, new Map(loaded.resources.map((record) => [record.id, record])));
|
|
347
356
|
if (!type) throw new Error(`Obligation "${obligation.id}" has no configured completion resource type.`);
|
|
348
357
|
|
|
349
358
|
const mutation = scaffoldResourceMutation(
|
|
@@ -369,10 +378,14 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
369
378
|
activityType: obligation.activityType,
|
|
370
379
|
completionResourceType: type,
|
|
371
380
|
completionProfile: activity.completionProfile || null,
|
|
381
|
+
workItemStatus: item.status,
|
|
382
|
+
programStatus: item.programStatus || null,
|
|
372
383
|
requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [],
|
|
373
384
|
dueWindowStart: item.dueWindowStart || null,
|
|
374
385
|
dueWindowEnd: item.dueWindowEnd || null,
|
|
375
|
-
instructions:
|
|
386
|
+
instructions: item.status === "proposed" || item.programStatus === "proposed"
|
|
387
|
+
? "This work is still a proposal. Resolve its governing Policy, Control, owner, and completion profile before recording completion."
|
|
388
|
+
: "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
|
|
376
389
|
}
|
|
377
390
|
};
|
|
378
391
|
}
|
|
@@ -394,6 +407,7 @@ export async function completeObligationAction(input, options) {
|
|
|
394
407
|
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
395
408
|
const completedOn = requireDate(options?.completedOn, "completion date");
|
|
396
409
|
const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
|
|
410
|
+
assertAttestationCompletionScope(obligation, options.record, loaded.resources, event);
|
|
397
411
|
if (event?.occurredOn && completedOn < event.occurredOn) {
|
|
398
412
|
throw new Error("The action completion date cannot be before its policy event date.");
|
|
399
413
|
}
|
|
@@ -456,6 +470,28 @@ function assertExpectedCompletionType(obligation, record, model) {
|
|
|
456
470
|
}
|
|
457
471
|
}
|
|
458
472
|
|
|
473
|
+
function assertAttestationCompletionScope(obligation, record, resources, event = null) {
|
|
474
|
+
if (record?.type !== "attestation") return;
|
|
475
|
+
const byId = new Map(resources.map((candidate) => [candidate.id, candidate]));
|
|
476
|
+
const eventPeople = (event?.subjectResourceIds || []).filter((id) => byId.get(id)?.type === "person");
|
|
477
|
+
const expectedPeople = eventPeople.length
|
|
478
|
+
? eventPeople
|
|
479
|
+
: (obligation.scopeResourceIds || []).filter((id) => byId.get(id)?.type === "person");
|
|
480
|
+
if (expectedPeople.length && !expectedPeople.includes(record.personId)) {
|
|
481
|
+
throw new Error(`Attestation personId must name the Person in scope for Obligation "${obligation.id}".`);
|
|
482
|
+
}
|
|
483
|
+
const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])]
|
|
484
|
+
.filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type));
|
|
485
|
+
const allowedSubjects = new Set(primarySubjects.length ? primarySubjects : obligation.policyIds || []);
|
|
486
|
+
const actualSubjects = new Set(record.subjectResourceIds || []);
|
|
487
|
+
if (
|
|
488
|
+
actualSubjects.size !== allowedSubjects.size
|
|
489
|
+
|| [...actualSubjects].some((id) => !allowedSubjects.has(id))
|
|
490
|
+
) {
|
|
491
|
+
throw new Error(`Attestation subjects must name the exact authored content in scope for Obligation "${obligation.id}".`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
459
495
|
function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
|
|
460
496
|
const start = requireDate(windowStart, "occurrence window start");
|
|
461
497
|
const plan = planObligations(loaded.resources, {
|
|
@@ -489,6 +525,7 @@ function plannedActionForScaffold(loaded, action, completedOn) {
|
|
|
489
525
|
function applyCompletionScaffoldDefaults(record, context) {
|
|
490
526
|
const { loaded, item, obligation, completedOn, activity } = context;
|
|
491
527
|
const program = resolveProgram(loaded);
|
|
528
|
+
const byId = new Map(loaded.resources.map((candidate) => [candidate.id, candidate]));
|
|
492
529
|
const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
|
|
493
530
|
if (!responsiblePeople.length) {
|
|
494
531
|
throw new Error(`Obligation "${obligation.id}" needs an active owner whose Appointment or Team resolves to a current Person.`);
|
|
@@ -554,19 +591,27 @@ function applyCompletionScaffoldDefaults(record, context) {
|
|
|
554
591
|
evidenceIds: [],
|
|
555
592
|
approvedOn: completedOn
|
|
556
593
|
}),
|
|
557
|
-
attestation: () =>
|
|
558
|
-
|
|
559
|
-
|
|
594
|
+
attestation: () => {
|
|
595
|
+
const personId = (item.subjectResourceIds || []).find((id) => byId.get(id)?.type === "person")
|
|
596
|
+
|| (obligation.scopeResourceIds || []).find((id) => byId.get(id)?.type === "person");
|
|
597
|
+
const primarySubjectIds = [...new Set([
|
|
560
598
|
obligation.templateResourceId,
|
|
561
599
|
...(obligation.scopeResourceIds || [])
|
|
562
|
-
].filter(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
600
|
+
].filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type)))];
|
|
601
|
+
const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
|
|
602
|
+
if (!personId) throw new Error("An Attestation completion needs the Person who made the acknowledgement or completed the training.");
|
|
603
|
+
if (!subjectResourceIds.length) throw new Error("An Attestation completion needs the exact Policy, Document, Training, or Action Item content acknowledged.");
|
|
604
|
+
return {
|
|
605
|
+
status: "completed",
|
|
606
|
+
subjectResourceIds,
|
|
607
|
+
personId,
|
|
608
|
+
attestationKind: obligation.activityType || "completion",
|
|
609
|
+
assignedOn: item.dueWindowStart || completedOn,
|
|
610
|
+
dueOn: item.dueWindowEnd || completedOn,
|
|
611
|
+
completedOn,
|
|
612
|
+
attestationMethod: "git-approval"
|
|
613
|
+
};
|
|
614
|
+
},
|
|
570
615
|
"access-review": () => {
|
|
571
616
|
if (!systemIds.length) throw new Error("An Access Review completion needs an active in-scope System.");
|
|
572
617
|
return {
|
|
@@ -621,22 +666,32 @@ function applyCompletionScaffoldDefaults(record, context) {
|
|
|
621
666
|
evidenceIds: [],
|
|
622
667
|
coverage
|
|
623
668
|
}),
|
|
624
|
-
"control-activity": () =>
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
669
|
+
"control-activity": () => {
|
|
670
|
+
const allowedScopeTypes = new Set(
|
|
671
|
+
loaded.model.resources["control-activity"].fields.scopeResourceIds.relation || []
|
|
672
|
+
);
|
|
673
|
+
const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
|
|
674
|
+
const validScopeIds = requestedScopeIds.filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
|
|
675
|
+
const fallbackScopeIds = systemIds.length
|
|
676
|
+
? systemIds
|
|
677
|
+
: (item.controlIds || obligation.controlIds || []).filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
|
|
678
|
+
return {
|
|
679
|
+
...common,
|
|
680
|
+
profileId: activity.completionProfile || obligation.activityType,
|
|
681
|
+
obligationId: obligation.id,
|
|
682
|
+
controlIds: item.controlIds || obligation.controlIds || [],
|
|
683
|
+
scopeResourceIds: validScopeIds.length
|
|
684
|
+
? validScopeIds
|
|
685
|
+
: fallbackScopeIds.length ? fallbackScopeIds : [loaded.workspace.id],
|
|
686
|
+
performerIds: responsiblePeople,
|
|
687
|
+
completedAt: timestamp,
|
|
688
|
+
method: "",
|
|
689
|
+
result: "",
|
|
690
|
+
reviewerIds,
|
|
691
|
+
reviewedOn: completedOn,
|
|
692
|
+
ownerIds: item.ownerIds || obligation.ownerIds || []
|
|
693
|
+
};
|
|
694
|
+
},
|
|
640
695
|
exercise: () => ({
|
|
641
696
|
...common,
|
|
642
697
|
exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
|
|
@@ -792,6 +847,12 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
792
847
|
const completionProfile = obligation?.type === "obligation"
|
|
793
848
|
? obligationActivity(model, obligation.activityType).completionProfile || null
|
|
794
849
|
: null;
|
|
850
|
+
const completionType = obligation?.type === "obligation"
|
|
851
|
+
? preferredCompletionType(obligationActivity(model, obligation.activityType), {
|
|
852
|
+
...obligation,
|
|
853
|
+
subjectResourceIds: event.subjectResourceIds || []
|
|
854
|
+
}, byId)
|
|
855
|
+
: null;
|
|
795
856
|
const completionIds = modelSupports(model, "guided-workflow")
|
|
796
857
|
? record.completionResourceIds || []
|
|
797
858
|
: [...(record.completionResourceIds || []), ...(record.evidenceIds || [])];
|
|
@@ -801,6 +862,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
801
862
|
|| matchingCompletionIds.length > 0;
|
|
802
863
|
const complete = record.status === "done" && completionSatisfied;
|
|
803
864
|
const window = plannedCompletionWindow(record.completionWindow);
|
|
865
|
+
const lateCompletion = complete && completionWasLate(record, matchingCompletionIds, byId, window);
|
|
804
866
|
const timingStatus = complete
|
|
805
867
|
? "complete"
|
|
806
868
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
@@ -824,14 +886,17 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
824
886
|
ownerIds: record.assigneeIds || [],
|
|
825
887
|
policyIds: obligation?.policyIds || [],
|
|
826
888
|
controlIds: obligation?.controlIds || [],
|
|
889
|
+
subjectResourceIds: event.subjectResourceIds || [],
|
|
827
890
|
scopeResourceIds: obligation?.scopeResourceIds || [],
|
|
828
891
|
templateResourceId: obligation?.templateResourceId || null,
|
|
829
892
|
completionResourceIds: record.completionResourceIds || [],
|
|
830
893
|
evidenceIds: record.evidenceIds || [],
|
|
831
894
|
expectedCompletionTypes,
|
|
832
895
|
completionProfile,
|
|
896
|
+
completionType,
|
|
833
897
|
matchingCompletionIds,
|
|
834
898
|
missingCompletion: record.status === "done" && !completionSatisfied,
|
|
899
|
+
lateCompletion,
|
|
835
900
|
canceledAction: record.status === "canceled",
|
|
836
901
|
recordedStatus: record.status,
|
|
837
902
|
completedOn: record.completedOn || null,
|
|
@@ -870,6 +935,38 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
870
935
|
};
|
|
871
936
|
}
|
|
872
937
|
|
|
938
|
+
function completionWasLate(action, completionIds, byId, window) {
|
|
939
|
+
if (window.dueWindowEndAt) {
|
|
940
|
+
const completedAt = completionIds
|
|
941
|
+
.map((id) => completionTimestamp(byId.get(id)))
|
|
942
|
+
.filter(Boolean)
|
|
943
|
+
.sort()[0];
|
|
944
|
+
if (completedAt) return new Date(completedAt) > new Date(window.dueWindowEndAt);
|
|
945
|
+
return !action.completedOn || action.completedOn >= window.dueWindowEndAt.slice(0, 10);
|
|
946
|
+
}
|
|
947
|
+
return Boolean(window.dueWindowEnd && action.completedOn && action.completedOn > window.dueWindowEnd);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function completionTimestamp(record) {
|
|
951
|
+
if (!record) return null;
|
|
952
|
+
return record.completedAt || record.occurredAt || record.collectedAt || record.verifiedAt || null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function preferredCompletionType(activity, item, byId) {
|
|
956
|
+
const primary = activity.completionType;
|
|
957
|
+
const hasPersonSubject = [...(item.subjectResourceIds || []), ...(item.scopeResourceIds || [])]
|
|
958
|
+
.some((id) => byId.get(id)?.type === "person");
|
|
959
|
+
const hasAuthoredSubject = [item.templateResourceId, ...(item.scopeResourceIds || [])]
|
|
960
|
+
.some((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type))
|
|
961
|
+
|| (item.policyIds || []).some((id) => byId.get(id)?.type === "policy");
|
|
962
|
+
if (primary === "attestation" && (!hasPersonSubject || !hasAuthoredSubject) && activity.completionResourceTypes.includes("evidence")) {
|
|
963
|
+
return "evidence";
|
|
964
|
+
}
|
|
965
|
+
if (SCAFFOLDED_COMPLETION_TYPES.has(primary)) return primary;
|
|
966
|
+
if (activity.completionResourceTypes.includes("evidence")) return "evidence";
|
|
967
|
+
return primary;
|
|
968
|
+
}
|
|
969
|
+
|
|
873
970
|
function planStandaloneAction(record, byId, asOf, now) {
|
|
874
971
|
const source = byId.get(record.sourceResourceId);
|
|
875
972
|
const window = plannedCompletionWindow(record.completionWindow);
|
package/src/policy-activation.js
CHANGED
|
@@ -12,6 +12,11 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
|
|
|
12
12
|
));
|
|
13
13
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
14
14
|
return {
|
|
15
|
+
available: approved.length > 0,
|
|
16
|
+
message: approved.length
|
|
17
|
+
? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
|
|
18
|
+
: "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
|
|
19
|
+
nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
|
|
15
20
|
policyIds: approved.map(({ policyId }) => policyId),
|
|
16
21
|
effectiveOn: currentCalendarDate(loaded.workspace.timezone),
|
|
17
22
|
expectedRevisions: Object.fromEntries(approved.map(({ policyId }) => [policyId, revisionById.get(policyId)])),
|