From 66adc204989448e7543c890ee3a23a704b5a2946 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Thu, 15 Oct 2026 12:46:15 -0500 Subject: [PATCH 1/2] Reconcile provider accounts separately from access grants and release 0.18.9 --- docs/data-model.md | 31 ++- docs/upgrading-to-model-v13.md | 6 + package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/model/v13.json | 165 ++++++++++++++- packages/filegrc/package.json | 2 +- packages/filegrc/src/agent.js | 22 +- packages/filegrc/src/operating-baseline.js | 19 +- .../src/provider-access-reconciliation.js | 114 +++++++++++ .../filegrc/test/operating-baseline.test.js | 42 ++++ .../provider-access-reconciliation.test.js | 188 ++++++++++++++++++ scripts/run-filegrc-tests.mjs | 1 + 15 files changed, 584 insertions(+), 24 deletions(-) create mode 100644 packages/filegrc/src/provider-access-reconciliation.js create mode 100644 packages/filegrc/test/provider-access-reconciliation.test.js diff --git a/docs/data-model.md b/docs/data-model.md index 11a9e81..d48f92a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -688,6 +688,30 @@ Key format: `namespace`. | `rehearsalEvidenceIds` | array of id | No | Operating handoff rehearsal proof References: `evidence` | | `procedureResourceIds` | array of id | No | Existing operating procedures References: `control`, `document` | +### `provider-access-reconciliation` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `componentId` | id | Yes | References: `component` | +| `populationEvidenceId` | id | Conditional | References: `evidence` Required when `boundary` is `direct`. Allowed when `boundary` is `direct`. | +| `accounts` | array of object (`provider-account-decision`) | Conditional | Required when `boundary` is `direct`. Allowed when `boundary` is `direct`. | +| `boundary` | enum | Yes | Values: `direct`, `inherited`, `no-account-store` | +| `identityComponentId` | id | Conditional | References: `component` Required when `boundary` is `inherited`. Allowed when `boundary` is `inherited`. | +| `boundaryEvidenceIds` | array of id | Conditional | References: `evidence` Required when `boundary` is one of `inherited`, `no-account-store`. | +| `rationale` | string | Conditional | Required when `boundary` is one of `inherited`, `no-account-store`. | + +### `provider-account-decision` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `accountReference` | string | Yes | | +| `disposition` | enum | Yes | Values: `authorized`, `provider-managed`, `removed`, `unresolved` | +| `subjectId` | id | No | References: `person`, `service-account` | +| `accessGrantIds` | array of id | No | References: `access-grant` | +| `rationale` | string | Conditional | Required when `authorizationKind` is `review-renewal`. | +| `resolutionEvidenceIds` | array of id | No | References: `evidence` | +| `authorizationKind` | enum | Conditional | Values: `access-grant`, `review-renewal`, `service-account-inventory` Required when `disposition` is `authorized`. Allowed when `disposition` is `authorized`. | + ## Common fields | Field | Type | Required | Meaning | @@ -1786,7 +1810,7 @@ When reviewing: - Confirm the review population is complete for every System and exact date or period in scope. - Decide whether each person and service account still needs its exact access and privilege level. - Confirm every removal, change, or exception has follow-up. -- For the selected access Control baseline, reconcile actual provider human and service-account exports with Access Grants. Retain a checked population export per source, including zero counts, record every grant decision and resolve count differences before completion. +- For the selected access Control baseline, retain actual provider populations and use sourceReconciliations to account for every row. Map human accounts to current authorization and owned service identities to Service Accounts, explain inherited identity stores and provider-managed exclusions, retain removal proof, and resolve unknown access. Account counts and Access Grant counts are separate. Default sources: `policy-information-security` @@ -1812,6 +1836,7 @@ Record Markdown: shown by default as an implicit companion file. | `completedOn` | date | Conditional | Completed on Required when `status` is `complete`. Allowed when `status` is `complete`. | | `cancellation` | object (`cancellation`) | Conditional | Required when `status` is `canceled`. Allowed when `status` is `canceled`. | | `componentIds` | array of id | Yes | Components References: `component` | +| `sourceReconciliations` | array of object (`provider-access-reconciliation`) | No | Provider account reconciliation | ### Systems and Vendors @@ -2336,8 +2361,8 @@ Markdown companions: | `verifiedOn` | date | Conditional | Required when `status` is `verified`. | | `expiresOn` | date | No | | | `sourceResourceIds` | array of id | No | Relation group: `evidence-source-record`. References: `appointment`, `document`, `obligation`, `obligation-event`, `requirement`, `commitment`, `complementary-control`, `control`, `control-test`, `finding`, `exception`, `action-item`, `policy`, `policy-review`, `attestation`, `meeting`, `training`, `risk`, `risk-assessment`, `vendor`, `vendor-review`, `access-grant`, `access-review`, `vulnerability`, `vulnerability-scan`, `incident`, `exercise`, `backup-test`, `penetration-test`, `data-request`, `audit`, `audit-population`, `audit-request`, `source-coverage`, `control-activity`, `retention-schedule-item`, `requirement-mapping`, `obligation-rule`, `obligation-occurrence`, `reporting-route`, `reporting-route-set` | -| `filePaths` | array of data-path | Conditional | Required when `sourceKind` is `file` and `status` is one of `collected`, `verified`, `withdrawn`. Allowed when `sourceKind` is one of `file`, `system`, `rendered-page`, `authored-record`. | -| `externalReference` | object (`external-reference`) | Conditional | Required when `sourceKind` is `external-reference` and `status` is one of `collected`, `verified`, `withdrawn`. Allowed when `sourceKind` is one of `external-reference`, `system`. | +| `filePaths` | array of data-path | Conditional | Required when `sourceKind` is `file` and `status` is one of `collected`, `verified`, `withdrawn`. Allowed when `sourceKind` is one of `file`, `system`, `rendered-page`, `authored-record`, `component`. | +| `externalReference` | object (`external-reference`) | Conditional | Required when `sourceKind` is `external-reference` and `status` is one of `collected`, `verified`, `withdrawn`. Allowed when `sourceKind` is one of `external-reference`, `system`, `component`. | | `sourceCommit` | string | Conditional | Required when `sourceKind` is `rendered-page` and `status` is one of `collected`, `verified`, `withdrawn`. | | `capture` | object (`page-capture`) | Conditional | Required when `sourceKind` is `rendered-page` and `status` is one of `collected`, `verified`, `withdrawn`. Allowed when `sourceKind` is `rendered-page`. | | `classificationId` | id | Conditional | Classification References: `classification` Required when `status` is one of `collected`, `verified`, `withdrawn`. | diff --git a/docs/upgrading-to-model-v13.md b/docs/upgrading-to-model-v13.md index d8e0cbb..16d313b 100644 --- a/docs/upgrading-to-model-v13.md +++ b/docs/upgrading-to-model-v13.md @@ -52,3 +52,9 @@ For an unchanged committed proposal, retain the fixed configuration export and i If a Policy activated before approved channels became effective, the historical coverage gap remains. Record current work on its actual dates and assess the earlier gap through the normal program and audit workflows. Recollecting an unchanged capture or backdating approval does not resolve that gap. Component-scoped operating results can cover a System through the Component’s `systemUses` relationship. Explicit result `systemIds` remain authoritative. For a Component shared by several Systems, link Evidence naming both the tested Component and the intended System; a broad source or Control link does not establish execution scope. A System result does not automatically cover every Component. Dates, completion, review, proof and occurrence reconciliation still apply. + +Provider account counts are separate from Access Grant counts. Use the optional `access-review.sourceReconciliations` array to retain one review across all providers. A `direct` row names its Component, fixed `populationEvidenceId` and one account decision per opaque export row. The actual export count must equal the number of account rows, and the review's `populationCount` totals the direct sources. A captured authenticated UI population is valid when its actual capture time, source, filters, complete page population and accuracy checks are retained; describe its observed provenance rather than claiming an API or CSV export. + +An `authorized` account names its Person or Service Account and `authorizationKind`: `access-grant` maps existing grants, `review-renewal` records the review's actual current authorization and rationale without inventing historical provisioning dates, and `service-account-inventory` uses the active owned service identity. All current grants still need decisions and account mappings. `provider-managed` accounts require a bounded rationale with no human/grant mapping. `removed` accounts require retained resolution Evidence. `unresolved` accounts prevent completion. + +An `inherited` source names its `identityComponentId`, rationale and fixed `boundaryEvidenceIds`; the chain must end in a reconciled direct source. A `no-account-store` source retains rationale and configuration proof and cannot conceal an active grant. Neither boundary creates a fictional zero-account export. Evidence with `sourceKind: "component"` now supports fixed `filePaths` and `externalReference` while retaining its authoritative `sourceComponentId`. Keep sensitive account identifiers in the retained report outside Git and use opaque row references here. Existing reviews remain unchanged and use their previous checks until explicit source reconciliation is recorded. No new independent approval is added. diff --git a/package-lock.json b/package-lock.json index a0b820e..739d7f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.18.8", + "version": "0.18.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.18.8", + "version": "0.18.9", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.18.8", + "version": "0.18.9", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.18.8", + "version": "0.18.9", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 9451eed..14751bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.18.8", + "version": "0.18.9", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 5754dcb..09eb7ef 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.18.8", + "version": "0.18.9", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 91bc33a..b46a98a 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.18.8", + version: "0.18.9", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.18.8", + version: "0.18.9", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 47778ac..a5346b5 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.18.8", + "version": "0.18.9", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/model/v13.json b/packages/filegrc/model/v13.json index 62fe1a6..54a2913 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -2017,7 +2017,8 @@ "file", "system", "rendered-page", - "authored-record" + "authored-record", + "component" ] } }, @@ -2035,7 +2036,8 @@ "allowedWhen": { "sourceKind": [ "external-reference", - "system" + "system", + "component" ] } }, @@ -5507,7 +5509,7 @@ "Confirm the review population is complete for every System and exact date or period in scope.", "Decide whether each person and service account still needs its exact access and privilege level.", "Confirm every removal, change, or exception has follow-up.", - "For the selected access Control baseline, reconcile actual provider human and service-account exports with Access Grants. Retain a checked population export per source, including zero counts, record every grant decision and resolve count differences before completion." + "For the selected access Control baseline, retain actual provider populations and use sourceReconciliations to account for every row. Map human accounts to current authorization and owned service identities to Service Accounts, explain inherited identity stores and provider-managed exclusions, retain removal proof, and resolve unknown access. Account counts and Access Grant counts are separate." ], "sourceResourceIds": [ "policy-information-security" @@ -5641,6 +5643,13 @@ "component" ], "label": "Components" + }, + "sourceReconciliations": { + "type": "array", + "items": "object", + "itemObjectType": "provider-access-reconciliation", + "label": "Provider account reconciliation", + "description": "Reconcile each actual provider account export, including accountable identities, mapped grants, bounded provider-managed exclusions, removals and unresolved rows. Use opaque row references; retain personal account identifiers outside Git." } }, "listFields": [ @@ -5654,6 +5663,13 @@ "scheduledFor", "coverage", "cancellation" + ], + "scaffoldFields": [ + "componentIds", + "grantDecisions", + "populationCount", + "exceptionCount", + "sourceReconciliations" ] }, "vulnerability": { @@ -11221,6 +11237,149 @@ } }, "additionalProperties": false + }, + "provider-access-reconciliation": { + "additionalProperties": false, + "required": [ + "componentId", + "boundary" + ], + "properties": { + "componentId": { + "type": "id", + "relation": [ + "component" + ] + }, + "populationEvidenceId": { + "type": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "boundary": "direct" + }, + "allowedWhen": { + "boundary": "direct" + } + }, + "accounts": { + "type": "array", + "items": "object", + "itemObjectType": "provider-account-decision", + "requiredWhen": { + "boundary": "direct" + }, + "allowedWhen": { + "boundary": "direct" + } + }, + "boundary": { + "type": "enum", + "values": [ + "direct", + "inherited", + "no-account-store" + ] + }, + "identityComponentId": { + "type": "id", + "relation": [ + "component" + ], + "requiredWhen": { + "boundary": "inherited" + }, + "allowedWhen": { + "boundary": "inherited" + } + }, + "boundaryEvidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "boundary": [ + "inherited", + "no-account-store" + ] + } + }, + "rationale": { + "type": "string", + "requiredWhen": { + "boundary": [ + "inherited", + "no-account-store" + ] + } + } + } + }, + "provider-account-decision": { + "additionalProperties": false, + "required": [ + "accountReference", + "disposition" + ], + "properties": { + "accountReference": { + "type": "string", + "description": "Unique opaque row reference into the retained export. Keep personal account identifiers outside Git." + }, + "disposition": { + "type": "enum", + "values": [ + "authorized", + "provider-managed", + "removed", + "unresolved" + ] + }, + "subjectId": { + "type": "id", + "relation": [ + "person", + "service-account" + ] + }, + "accessGrantIds": { + "type": "array", + "items": "id", + "relation": [ + "access-grant" + ] + }, + "rationale": { + "type": "string", + "requiredWhen": { + "authorizationKind": "review-renewal" + } + }, + "resolutionEvidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "authorizationKind": { + "type": "enum", + "values": [ + "access-grant", + "review-renewal", + "service-account-inventory" + ], + "requiredWhen": { + "disposition": "authorized" + }, + "allowedWhen": { + "disposition": "authorized" + } + } + } } }, "obligationActivities": { diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 2311793..cb3956d 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.18.8", + "version": "0.18.9", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/agent.js b/packages/filegrc/src/agent.js index 2a1ca0f..4f8f780 100644 --- a/packages/filegrc/src/agent.js +++ b/packages/filegrc/src/agent.js @@ -64,7 +64,11 @@ export function buildAgentGuide(loaded, type, options = {}) { values: allowedValues(loaded.model, field), relation, disjointFrom: field.disjointFrom ?? null, - format: field.format ?? null + format: field.format ?? null, + ...(field.description ? { description: field.description } : {}), + ...(field.items ? { items: field.items } : {}), + ...(field.objectType ? { objectType: field.objectType } : {}), + ...(field.itemObjectType ? { itemObjectType: field.itemObjectType } : {}) }; }); const requiredAtCreation = fieldList.filter(({ required: isRequired }) => isRequired); @@ -121,6 +125,7 @@ export function buildAgentGuide(loaded, type, options = {}) { conditionalRequirements, optionalFields, oneOf: definition.oneOf ?? [], + objectTypes: referencedObjectTypes(loaded.model, fields), markdown, workflow: [ "Inspect the recommended action, existing records, relationships, source Markdown, and available evidence. Do the research, drafting, record edits, and validation you can do yourself.", @@ -195,6 +200,9 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) { record[name] = scaffoldValue(name, field, loaded.model); } } + for (const name of definition.scaffoldFields || []) { + if (record[name] === undefined) record[name] = scaffoldValue(name, fields[name], loaded.model); + } applyModelScaffoldDefaults(record, loaded, options); const slots = markdownEntries(loaded.model, record).filter((slot) => ( @@ -361,3 +369,15 @@ function humanize(value) { .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/^./, (letter) => letter.toUpperCase()); } + +function referencedObjectTypes(model, fields) { + const definitions = {}; + const visit = (field) => { + const name = field.objectType || field.itemObjectType; + if (!name || definitions[name] || !model.objectTypes[name]) return; + definitions[name] = model.objectTypes[name]; + for (const child of Object.values(definitions[name].properties || {})) visit(child); + }; + for (const field of Object.values(fields)) visit(field); + return definitions; +} diff --git a/packages/filegrc/src/operating-baseline.js b/packages/filegrc/src/operating-baseline.js index 7942856..f23d00d 100644 --- a/packages/filegrc/src/operating-baseline.js +++ b/packages/filegrc/src/operating-baseline.js @@ -1,3 +1,4 @@ +import { assessProviderAccessReconciliation } from "./provider-access-reconciliation.js"; import { readFile } from "node:fs/promises"; import { markdownEntries, resourceDataPath } from "./resource-markdown.js"; import { resolveDataPath } from "./paths.js"; @@ -225,21 +226,25 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak }); if (unresolvedDecisionIds.length || candidate.exceptionCount < unique(permittedExceptionIds).length) issues.push("resolved-access-decisions"); if (invalidDecisions) issues.push("supported-grant-decisions"); - if (candidate.populationCount !== decisions.length || candidate.exceptionCount === undefined) issues.push("reconciled-account-counts"); + if ((!candidate.sourceReconciliations && candidate.populationCount !== decisions.length) || candidate.exceptionCount === undefined) issues.push("reconciled-account-counts"); + const generatedOn = (proof) => proof?.generatedAt ? currentCalendarDate(timezone, new Date(proof.generatedAt)) : null; const exports = evidence(candidate).filter((proof) => valid(proof) && !preparationEvidenceIssues(proof, byId, asOf).length - && proof.artifactKind === "population-export" && proof.readinessTest !== true && permittedDate(proof.generatedAt?.slice(0, 10)) - && proof.generatedAt.slice(0, 10) <= candidate.completedOn && proof.collectedOn >= proof.generatedAt.slice(0, 10) && proof.collectedOn <= candidate.completedOn && proof.completenessValidation?.trim() && proof.accuracyValidation?.trim()); - const missingSourceIds = componentIds.filter((id) => !exports.some((proof) => proof.sourceComponentId === id + && proof.artifactKind === "population-export" && proof.readinessTest !== true && permittedDate(generatedOn(proof)) + && generatedOn(proof) <= candidate.completedOn && proof.collectedOn >= generatedOn(proof) && proof.collectedOn <= candidate.completedOn && proof.completenessValidation?.trim() && proof.accuracyValidation?.trim()); + const reconciliation = candidate.sourceReconciliations ? assessProviderAccessReconciliation(candidate, + { componentIds, exports, byId, asOf, valid, proofValid, grants, timezone }) : null; + if (reconciliation) issues.push(...reconciliation.issues); + const missingSourceIds = reconciliation?.missingSourceIds || componentIds.filter((id) => !exports.some((proof) => proof.sourceComponentId === id && proof.populationCount === reviewedGrants.filter((grant) => grant?.componentId === id).length)); if (missingSourceIds.length || !await currentProcedureContent(loaded, candidate)) issues.push("actual-provider-population-and-results"); - return { resourceId: candidate.id, complete: !issues.length, missingFacts: issues, missingScopeIds, missingGrantIds, missingSourceIds, invalidGrantIds, unresolvedDecisionIds, permittedExceptionIds, remediationIds }; + return { resourceId: candidate.id, complete: !issues.length, missingFacts: issues, missingScopeIds, missingGrantIds, missingSourceIds, invalidGrantIds, unresolvedDecisionIds, permittedExceptionIds, remediationIds, ...(reconciliation || {}) }; })); const result = assessments.find(({ complete }) => complete) || assessments.sort((a, b) => a.missingFacts.length - b.missingFacts.length)[0]; complete = componentIds.length > 0 && result?.complete === true; record = byId.get(result?.resourceId) || record; title = "Reconcile actual provider access"; work = `Obtain the actual human and service-account lists from ${componentIds.map((id) => byId.get(id)?.title || id).join(", ") || "the scoped providers"}. Check export filters and completeness, compare every account and permission with approved Access Grants and current people or Service Accounts, and decide whether each grant stays, changes or is removed. Investigate missing accounts, orphan access and count differences, record exceptions and follow-up work, then complete the existing Access Review and its normal approval. A successful export rehearsal does not perform this reconciliation.`; - acceptance = "A completed Access Review covers all scoped providers, retains checked actual per-source population exports (including zero counts), reconciles their counts to reviewed grants and covers all current active grants, records every grant decision and has its normal dated approval. Removal and change decisions need actual resolution, or an unexpired approved Exception with assigned remediation and a deadline. Missing or excess provider accounts remain gaps until resolved."; - details = { ...details, populationIds: grants.map(({ id }) => id), componentIds, candidateResults: assessments, + acceptance = "A completed Access Review explains every scoped provider's identity boundary and retains checked actual account populations, including real zero counts. Reconcile each account row with its accountable Person or Service Account, mapped grants, explained provider-managed exclusion, or retained removal proof. Inherited identity stores link the reconciled provider; Components without an identity store retain bounded configuration proof. Account and grant counts are separate. Cover all active grants and their decisions, resolve unknown access, and keep the existing dated review approval. Removal and change decisions need actual resolution or an unexpired approved Exception with assigned remediation and a deadline. Captured authenticated UI populations are allowed when provenance, capture time, completeness and accuracy are retained; do not label them API exports."; + details = { ...details, suggestedValues: { sourceReconciliations: componentIds.map((componentId) => ({ componentId, boundary: null })) }, populationIds: grants.map(({ id }) => id), componentIds, candidateResults: assessments, resultIds: complete ? [record.id] : [], missingFacts: result?.missingFacts || ["completed-access-reconciliation"], instructions: result ? [ ...(result.missingScopeIds.length ? [`Include provider Components: ${result.missingScopeIds.map((id) => byId.get(id)?.title || id).join(", ")}.`] : []), diff --git a/packages/filegrc/src/provider-access-reconciliation.js b/packages/filegrc/src/provider-access-reconciliation.js new file mode 100644 index 0000000..3ebfdc8 --- /dev/null +++ b/packages/filegrc/src/provider-access-reconciliation.js @@ -0,0 +1,114 @@ +import { currentCalendarDate } from "./time.js"; +import { appointmentWasAuthorizedOn, personWasActiveOn } from "./soc2.js"; + +// Provider accounts, permissions and Access Grants are different populations. +// Reconcile actual export rows without treating a grant count as an account count. +export function assessProviderAccessReconciliation(candidate, context) { + const { componentIds, exports, byId, asOf, valid, proofValid, grants } = context; + const rows = candidate.sourceReconciliations; + const missingSourceIds = [], issues = [], unresolvedAccountReferences = [], mappedGrantIds = new Set(); + const byComponent = new Map(); + for (const row of rows) { + if (byComponent.has(row.componentId) || !componentIds.includes(row.componentId)) issues.push("unique-scoped-provider-reconciliations"); + byComponent.set(row.componentId, row); + } + const hasCurrentOwner = (ids, seen = new Set()) => (ids || []).some((id) => { + if (seen.has(id)) return false; + seen.add(id); + const owner = byId.get(id); + if (owner?.status !== "active") return false; + if (owner.type === "person") return personWasActiveOn(owner, asOf); + if (owner.type === "appointment") return appointmentWasAuthorizedOn(owner, asOf, byId); + return owner.type === "team" && hasCurrentOwner([...(owner.memberIds || []), ...(owner.chairIds || [])], seen); + }); + const retained = (proof) => Boolean(proof?.filePaths?.length || proof?.externalReference?.url?.trim() + || proof?.externalReference?.system?.trim() && proof?.externalReference?.reference?.trim()); + const boundedProof = (ids, componentId, notBefore = null) => ids?.length > 0 && ids.every((id) => { + const proof = byId.get(id); + const observedAt = proof?.generatedAt || proof?.businessEventAt; + const observedTime = new Date(observedAt).getTime(); + const observedOn = Number.isFinite(observedTime) ? currentCalendarDate(context.timezone || "UTC", new Date(observedTime)) : null; + const observationDatesValid = [proof?.generatedAt, proof?.businessEventAt].filter(Boolean).every((value) => { + const time = new Date(value).getTime(); + const on = Number.isFinite(time) ? currentCalendarDate(context.timezone || "UTC", new Date(time)) : null; + return on && on <= candidate.approvedOn && proof.collectedOn >= on; + }); + return candidate.evidenceIds?.includes(id) && retained(proof) && valid(proof) + && proof.collectedOn <= candidate.approvedOn && proof.collectedOn <= asOf + && observationDatesValid + && (!notBefore || observedOn && observedTime >= new Date(notBefore).getTime()) + && (proof.sourceComponentId === componentId || proof.componentIds?.includes(componentId)) + && proofValid({ evidenceIds: [id] }); + }); + const directSource = (componentId, seen = new Set()) => { + if (seen.has(componentId)) return null; + seen.add(componentId); + const row = byComponent.get(componentId); + if (row?.boundary === "direct") return componentId; + if (row?.boundary !== "inherited" || !row.rationale?.trim() + || !boundedProof(row.boundaryEvidenceIds, componentId)) return null; + return directSource(row.identityComponentId, seen); + }; + let populationCount = 0; + for (const componentId of componentIds) { + const row = byComponent.get(componentId); + if (!row) { missingSourceIds.push(componentId); continue; } + if (row.boundary !== "direct") { + const hasGrant = grants.some((grant) => grant.componentId === componentId); + if (!row.rationale?.trim() || !boundedProof(row.boundaryEvidenceIds, componentId) + || row.boundary === "inherited" && !directSource(componentId) + || row.boundary === "no-account-store" && hasGrant) missingSourceIds.push(componentId); + continue; + } + const proof = exports.find((proof) => proof.id === row.populationEvidenceId && proof.sourceComponentId === componentId && retained(proof)); + const accounts = row.accounts || []; + if (!proof || proof.populationCount !== accounts.length) missingSourceIds.push(componentId); + if (proof) populationCount += proof.populationCount; + const references = new Set(); + for (const account of accounts) { + const reference = account.accountReference?.trim(); + if (!reference || references.has(reference)) issues.push("unique-provider-account-references"); + references.add(reference); + if (account.disposition === "unresolved") { unresolvedAccountReferences.push({ componentId, accountReference: reference }); continue; } + const mapped = (account.accessGrantIds || []).map((id) => byId.get(id)); + if (new Set(account.accessGrantIds || []).size !== mapped.length + || mapped.some((grant) => grant?.type !== "access-grant" || !valid(grant) + || directSource(grant.componentId) !== componentId || grant.subjectId !== account.subjectId + || !candidate.grantDecisions?.some(({ accessGrantId }) => accessGrantId === grant.id))) { + issues.push("account-grant-mappings"); continue; + } + if (account.disposition === "provider-managed") { + if (!account.rationale?.trim() || account.subjectId || mapped.length) issues.push("bounded-provider-managed-exclusions"); + continue; + } + if (account.disposition === "removed") { + if (!account.rationale?.trim() || !boundedProof(account.resolutionEvidenceIds, componentId, proof?.generatedAt) + || mapped.some((grant) => !["revoked", "expired"].includes(grant.status) || !grant.deprovisionedOn || grant.deprovisionedOn > candidate.approvedOn)) { + issues.push("resolved-provider-account-removals"); + } + for (const grant of mapped) mappedGrantIds.add(grant.id); + continue; + } + const subject = byId.get(account.subjectId); + const currentSubject = subject?.type === "person" ? personWasActiveOn(subject, asOf) + : subject?.type === "service-account" && valid(subject) && subject.status === "active" + && (!subject.expiresOn || subject.expiresOn >= asOf) + && (!subject.lastReviewedOn || subject.lastReviewedOn <= asOf) + && (!subject.reviewDueOn || subject.reviewDueOn >= asOf) + && subject.componentIds?.some((id) => directSource(id) === componentId) + && hasCurrentOwner(subject.ownerIds); + const authorization = account.authorizationKind === "access-grant" ? mapped.length > 0 + : account.authorizationKind === "review-renewal" ? subject?.type === "person" && account.rationale?.trim() + && candidate.approvedByIds?.length > 0 && candidate.approvedByIds.every((id) => personWasActiveOn(byId.get(id), candidate.approvedOn)) + && candidate.approvedOn >= candidate.completedOn && candidate.approvedOn <= asOf + : account.authorizationKind === "service-account-inventory" && subject?.type === "service-account"; + if (account.disposition !== "authorized" || !currentSubject || !authorization || mapped.some((grant) => grant.status !== "active")) issues.push("accountable-provider-identities"); + for (const grant of mapped) mappedGrantIds.add(grant.id); + } + } + const missingMappedGrantIds = grants.filter((grant) => !mappedGrantIds.has(grant.id)).map(({ id }) => id); + if (missingMappedGrantIds.length) issues.push("mapped-current-access-grants"); + if (unresolvedAccountReferences.length) issues.push("unresolved-provider-accounts"); + if (candidate.populationCount !== populationCount) issues.push("reconciled-account-counts"); + return { issues: [...new Set(issues)], missingSourceIds, missingMappedGrantIds, unresolvedAccountReferences }; +} diff --git a/packages/filegrc/test/operating-baseline.test.js b/packages/filegrc/test/operating-baseline.test.js index c0204c4..7d869c7 100644 --- a/packages/filegrc/test/operating-baseline.test.js +++ b/packages/filegrc/test/operating-baseline.test.js @@ -265,3 +265,45 @@ test("role training and external audiences stay specific to their adopted subjec assert.ok(result.subjectPopulations.find(({ subjectId }) => subjectId === roleCourse.id).personIds.includes("person-independent-approver-example")); assert.ok(!result.subjectPopulations.find(({ subjectId }) => subjectId === general.id).personIds.includes("person-independent-approver-example")); }); + +test("provider baseline reconciles actual account rows separately from grants and exposes the same CLI guidance", async (context) => { + const { root, loaded, get, baseline } = await fixture(context, "IAM-01"); + const review = get("access-review"), grant = get("access-grant"), proof = get("evidence"), service = get("service-account"); + const component = get("component"); + Object.assign(service, { componentIds: [component.id], ownerIds: [get("person").id] }); + Object.assign(review, { completedOn: "2026-07-01", approvedOn: "2026-07-02", populationCount: 3, exceptionCount: 0, + grantDecisions: [{ accessGrantId: grant.id, decision: "retain" }], sourceReconciliations: [{ componentId: component.id, + boundary: "direct", populationEvidenceId: proof.id, accounts: [ + { accountReference: "row-1", disposition: "authorized", subjectId: grant.subjectId, authorizationKind: "access-grant", accessGrantIds: [grant.id] }, + { accountReference: "row-2", disposition: "authorized", subjectId: service.id, authorizationKind: "service-account-inventory" }, + { accountReference: "row-3", disposition: "provider-managed", rationale: "Provider-owned service role with no customer-managed human login; reviewed trust boundary" } + ] }] }); + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", timezone: "UTC", externalReference: { url: "https://evidence.example.com/provider-accounts" }, queryDescription: "Authenticated UI observation of all account classes; all pages inspected; owner confirmed completeness", + populationCount: 3, completenessValidation: "Compared all account classes and page totals with provider UI", accuracyValidation: "Checked account owners and permissions against provider detail", + coverage: { kind: "as-of", on: "2026-07-01" } }); + delete proof.filePaths; + assert.equal((await baseline("provider-access")).complete, true, JSON.stringify((await baseline("provider-access")).candidateResults)); + for (const record of [review, proof, service]) await writeFile(join(root, "data", resourceDataPath(loaded.model, record)), JSON.stringify(record)); + const cli = JSON.parse((await captureCli(runCli, ["program-readiness", "--root", root, "--json"])).stdout); + const cliCheck = cli.stages.flatMap(({ items }) => items).flatMap(({ preparationChecks }) => preparationChecks || []).find(({ id }) => id === "baseline-provider-access"); + assert.equal(cliCheck.complete, true); + review.sourceReconciliations[0].accounts[2].disposition = "unresolved"; + assert.equal((await baseline("provider-access")).complete, false); + review.sourceReconciliations[0].accounts[2].disposition = "provider-managed"; + proof.readinessTest = true; + assert.equal((await baseline("provider-access")).complete, false, "A retrieval rehearsal is not an actual account population"); +}); + +test("provider reconciliation guide and scaffold expose nested model facts without inventing account decisions", async (context) => { + const { root } = await fixture(context, "IAM-01"); + const guide = JSON.parse((await captureCli(runCli, ["guide", "access-review", "--root", root, "--json"])).stdout); + assert.equal(guide.optionalFields.find(({ name }) => name === "sourceReconciliations").itemObjectType, "provider-access-reconciliation"); + assert.ok(guide.objectTypes["provider-account-decision"].properties.authorizationKind.values.includes("review-renewal")); + assert.ok(guide.objectTypes["provider-access-reconciliation"].properties.boundary.values.includes("inherited")); + const scaffold = JSON.parse((await captureCli(runCli, ["scaffold", "access-review", "--title", "Provider reconciliation", "--root", root])).stdout); + assert.deepEqual(scaffold.record.sourceReconciliations, []); + assert.deepEqual(scaffold.record.grantDecisions, []); + assert.equal(scaffold.record.populationCount, null); + assert.notEqual(scaffold.record.status, "complete"); +}); diff --git a/packages/filegrc/test/provider-access-reconciliation.test.js b/packages/filegrc/test/provider-access-reconciliation.test.js new file mode 100644 index 0000000..8115b9f --- /dev/null +++ b/packages/filegrc/test/provider-access-reconciliation.test.js @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assessProviderAccessReconciliation } from "../src/provider-access-reconciliation.js"; + +function fixture() { + const records = [ + { id: "person-owner", type: "person", status: "active" }, + { id: "service-worker", type: "service-account", status: "active", componentIds: ["component-provider"], ownerIds: ["person-owner"] }, + { id: "grant-owner", type: "access-grant", componentId: "component-provider", subjectId: "person-owner", status: "active" }, + { id: "proof-population", type: "evidence", externalReference: { url: "https://evidence.example.com/population" }, sourceComponentId: "component-provider", collectedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", populationCount: 3 }, + { id: "proof-boundary", type: "evidence", externalReference: { url: "https://evidence.example.com/observation" }, componentIds: ["component-inherited", "component-files"], collectedOn: "2026-07-01" }, + { id: "proof-removal", type: "evidence", generatedAt: "2026-07-01T12:00:00Z", externalReference: { url: "https://evidence.example.com/observation" }, componentIds: ["component-provider"], collectedOn: "2026-07-01" } + ]; + const byId = new Map(records.map(r => [r.id, r])); + const row = { componentId: "component-provider", boundary: "direct", populationEvidenceId: "proof-population", accounts: [ + { accountReference: "row-1", disposition: "authorized", subjectId: "person-owner", authorizationKind: "access-grant", accessGrantIds: ["grant-owner"] }, + { accountReference: "row-2", disposition: "authorized", subjectId: "service-worker", authorizationKind: "service-account-inventory" }, + { accountReference: "row-3", disposition: "provider-managed", rationale: "Provider-owned noninteractive service role; reviewed trust boundary in retained export" } + ] }; + const candidate = { sourceReconciliations: [row], populationCount: 3, grantDecisions: [{ accessGrantId: "grant-owner", decision: "retain" }], + evidenceIds: ["proof-population", "proof-boundary", "proof-removal"], completedOn: "2026-07-01", approvedOn: "2026-07-02", approvedByIds: ["person-owner"] }; + const context = { componentIds: ["component-provider"], exports: [byId.get("proof-population")], byId, asOf: "2026-07-03", + valid: () => true, proofValid: () => true, grants: [byId.get("grant-owner")] }; + return { candidate, context, row, byId, assess: () => assessProviderAccessReconciliation(candidate, context) }; +} + +test("account, grant, and service identity counts reconcile independently", () => { + const { row, candidate, byId, assess } = fixture(); + assert.deepEqual(assess().issues, []); + row.accounts.push({ ...row.accounts[0], accountReference: "row-4" }); + byId.get("proof-population").populationCount = candidate.populationCount = 4; + assert.deepEqual(assess().issues, [], "Multiple accounts can map to the same approved grant"); + row.accounts.pop(); + assert.deepEqual(assess().missingSourceIds, ["component-provider"], "Missing provider row blocks even with valid grant decisions"); +}); + +test("unknown, duplicate and unauthorized accounts cannot pass", () => { + const { row, assess } = fixture(); + row.accounts[1].disposition = "unresolved"; + assert.ok(assess().issues.includes("unresolved-provider-accounts")); + row.accounts[1].disposition = "authorized"; + row.accounts[1].subjectId = "missing"; + assert.ok(assess().issues.includes("accountable-provider-identities")); + row.accounts[1].accountReference = "row-1"; + assert.ok(assess().issues.includes("unique-provider-account-references")); +}); + +test("provider-managed exclusions are explicit and cannot conceal mapped human access", () => { + const { row, assess } = fixture(); + row.accounts[2].rationale = ""; + assert.ok(assess().issues.includes("bounded-provider-managed-exclusions")); + row.accounts[2].rationale = "Provider-managed role"; + row.accounts[2].subjectId = "person-owner"; + assert.ok(assess().issues.includes("bounded-provider-managed-exclusions")); +}); + +test("existing human accounts can receive an actual current renewed authorization", () => { + const { row, candidate, context, assess } = fixture(); + context.grants = []; candidate.grantDecisions = []; + Object.assign(row.accounts[0], { authorizationKind: "review-renewal", accessGrantIds: [], rationale: "Renew continued introduction access based on current business need" }); + assert.deepEqual(assess().issues, []); + row.accounts[0].rationale = ""; + assert.ok(assess().issues.includes("accountable-provider-identities")); + row.accounts[0].rationale = "Current business need"; candidate.approvedOn = "2027-01-01"; + assert.ok(assess().issues.includes("accountable-provider-identities")); +}); + +test("mappings stay with the correct provider and accountable subject", () => { + const { row, byId, assess } = fixture(); + byId.get("grant-owner").componentId = "component-other"; + assert.ok(assess().issues.includes("account-grant-mappings")); + byId.get("grant-owner").componentId = "component-provider"; + row.accounts[0].subjectId = "service-worker"; + assert.ok(assess().issues.includes("account-grant-mappings")); +}); + +test("owned service identities require active inventory, provider boundary and current owners", () => { + const { byId, assess } = fixture(); + byId.get("service-worker").ownerIds = []; + assert.ok(assess().issues.includes("accountable-provider-identities")); + byId.get("service-worker").ownerIds = ["person-owner"]; + byId.get("service-worker").reviewDueOn = "2026-06-30"; + assert.ok(assess().issues.includes("accountable-provider-identities")); + delete byId.get("service-worker").reviewDueOn; + byId.get("service-worker").expiresOn = "2026-06-30"; + assert.ok(assess().issues.includes("accountable-provider-identities"), "Expired service inventory cannot authorize current access"); +}); + +test("removed accounts retain actual resolution proof", () => { + const { row, byId, assess } = fixture(); + row.accounts[2] = { accountReference: "row-3", disposition: "removed", rationale: "Unneeded old integration", resolutionEvidenceIds: ["proof-removal"] }; + assert.deepEqual(assess().issues, []); + row.accounts[2].resolutionEvidenceIds = ["proof-population"]; + assert.deepEqual(assess().issues, [], "A current population export showing inactive status can itself be the removal proof"); + row.accounts[2].resolutionEvidenceIds = ["proof-removal"]; + byId.get("proof-removal").collectedOn = "2025-01-01"; + assert.ok(assess().issues.includes("resolved-provider-account-removals"), "Historical proof does not resolve the current observed population"); + byId.get("proof-removal").collectedOn = "2026-07-01"; + byId.get("proof-removal").generatedAt = "2025-01-01T12:00:00Z"; + assert.ok(assess().issues.includes("resolved-provider-account-removals"), "Recollecting an old artifact does not prove current removal"); + byId.get("proof-removal").generatedAt = "2026-07-01T09:00:00Z"; + assert.ok(assess().issues.includes("resolved-provider-account-removals"), "Same-day proof must follow the actual population capture timestamp"); + byId.get("proof-removal").generatedAt = "2026-07-01T12:00:00Z"; + row.accounts[2].resolutionEvidenceIds = []; + assert.ok(assess().issues.includes("resolved-provider-account-removals")); +}); + +test("inherited identities and no-account-store sources need bounded proof without fabricated zero populations", () => { + const { candidate, context, assess } = fixture(); + context.componentIds.push("component-inherited", "component-files"); + candidate.sourceReconciliations.push( + { componentId: "component-inherited", boundary: "inherited", identityComponentId: "component-provider", rationale: "Uses provider identity", boundaryEvidenceIds: ["proof-boundary"] }, + { componentId: "component-files", boundary: "no-account-store", rationale: "Git-managed register; no independent authentication", boundaryEvidenceIds: ["proof-boundary"] }); + assert.deepEqual(assess().missingSourceIds, []); + assert.deepEqual(assess().issues, []); + candidate.sourceReconciliations[1].identityComponentId = "component-inherited"; + assert.deepEqual(assess().missingSourceIds, ["component-inherited"]); + candidate.sourceReconciliations[1].identityComponentId = "component-provider"; + context.proofValid = () => false; + assert.deepEqual(assess().missingSourceIds, ["component-inherited", "component-files"]); +}); + +test("no-account-store boundaries cannot hide current grants or reuse another provider's export", () => { + const { candidate, context, byId, row, assess } = fixture(); + Object.assign(row, { boundary: "no-account-store", rationale: "No accounts", boundaryEvidenceIds: ["proof-removal"] }); + assert.deepEqual(assess().missingSourceIds, ["component-provider"]); + row.boundary = "direct"; + byId.get("proof-population").sourceComponentId = "component-other"; + assert.deepEqual(assess().missingSourceIds, ["component-provider"]); + assert.ok(assess().issues.includes("reconciled-account-counts")); + context.exports = []; candidate.populationCount = 0; + assert.deepEqual(assess().missingSourceIds, ["component-provider"]); +}); + + +test("service identity owners must be effective on the assessment date", () => { + const { byId, assess } = fixture(); + byId.get("person-owner").startDate = "2027-01-01"; + assert.ok(assess().issues.includes("accountable-provider-identities")); + delete byId.get("person-owner").startDate; + byId.get("person-owner").endDate = "2026-06-30"; + assert.ok(assess().issues.includes("accountable-provider-identities")); + delete byId.get("person-owner").endDate; + byId.set("appointment-owner", { id: "appointment-owner", type: "appointment", status: "active", holderId: "person-owner", startsOn: "2026-01-01", endsOn: "2026-06-30" }); + byId.get("service-worker").ownerIds = ["appointment-owner"]; + assert.ok(assess().issues.includes("accountable-provider-identities")); + byId.get("appointment-owner").endsOn = "2026-12-31"; + assert.deepEqual(assess().issues, []); +}); + +test("expired grants with recorded removal remain reviewable without a duplicate grant", () => { + const { candidate, row, byId, context, assess } = fixture(); + Object.assign(byId.get("grant-owner"), { status: "expired", deprovisionedOn: "2026-07-01" }); + context.grants = []; + row.accounts[0] = { accountReference: "row-1", disposition: "removed", subjectId: "person-owner", accessGrantIds: ["grant-owner"], rationale: "Expired access removed", resolutionEvidenceIds: ["proof-removal"] }; + candidate.grantDecisions[0].decision = "remove"; + assert.deepEqual(assess().issues, []); +}); + + +test("removal chronology uses exact timestamps and workspace calendar dates", () => { + const { candidate, row, byId, context, assess } = fixture(); + context.timezone = "America/Chicago"; context.asOf = "2026-07-01"; + candidate.approvedOn = "2026-07-01"; + byId.get("proof-population").generatedAt = "2026-07-02T01:00:00Z"; + byId.get("proof-removal").generatedAt = "2026-07-02T02:00:00Z"; + row.accounts[2] = { accountReference: "row-3", disposition: "removed", rationale: "Current readback shows deactivated status", resolutionEvidenceIds: ["proof-removal"] }; + assert.deepEqual(assess().issues, []); + byId.get("proof-removal").generatedAt = "2026-07-01T19:00:00-05:00"; + assert.ok(assess().issues.includes("resolved-provider-account-removals")); +}); + + +test("boundary proof cannot claim future observations or collection before its observation", () => { + const { candidate, context, byId, assess } = fixture(); + context.componentIds.push("component-inherited"); + candidate.sourceReconciliations.push({ componentId: "component-inherited", boundary: "inherited", identityComponentId: "component-provider", rationale: "Provider identity", boundaryEvidenceIds: ["proof-boundary"] }); + byId.get("proof-boundary").generatedAt = "2027-01-01T12:00:00Z"; + assert.deepEqual(assess().missingSourceIds, ["component-inherited"]); + byId.get("proof-boundary").generatedAt = "2026-07-01T12:00:00Z"; + byId.get("proof-boundary").businessEventAt = "2027-01-01T12:00:00Z"; + assert.deepEqual(assess().missingSourceIds, ["component-inherited"]); + delete byId.get("proof-boundary").businessEventAt; + byId.get("proof-boundary").collectedOn = "2026-06-30"; + assert.deepEqual(assess().missingSourceIds, ["component-inherited"]); + byId.get("proof-boundary").collectedOn = "2026-07-01"; + assert.deepEqual(assess().missingSourceIds, []); +}); diff --git a/scripts/run-filegrc-tests.mjs b/scripts/run-filegrc-tests.mjs index 54f1959..8a510c1 100644 --- a/scripts/run-filegrc-tests.mjs +++ b/scripts/run-filegrc-tests.mjs @@ -8,6 +8,7 @@ const testRoot = join(packageRoot, "test"); const unitFiles = new Set([ "applicability-scope.test.js", "completion-scope.test.js", + "provider-access-reconciliation.test.js", "favicon.test.js", "git-adapter.test.js", "id.test.js", From 399c67b27cb6d386b13708edcfb3835c07347922 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Thu, 15 Oct 2026 13:00:06 -0500 Subject: [PATCH 2/2] Accept checked zero-provider account populations --- docs/data-model.md | 2 +- packages/filegrc/model/v13.json | 6 ++---- packages/filegrc/test/operating-baseline.test.js | 8 ++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index d48f92a..c597c70 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -694,7 +694,7 @@ Key format: `namespace`. | --- | --- | --- | --- | | `componentId` | id | Yes | References: `component` | | `populationEvidenceId` | id | Conditional | References: `evidence` Required when `boundary` is `direct`. Allowed when `boundary` is `direct`. | -| `accounts` | array of object (`provider-account-decision`) | Conditional | Required when `boundary` is `direct`. Allowed when `boundary` is `direct`. | +| `accounts` | array of object (`provider-account-decision`) | No | Allowed when `boundary` is `direct`. | | `boundary` | enum | Yes | Values: `direct`, `inherited`, `no-account-store` | | `identityComponentId` | id | Conditional | References: `component` Required when `boundary` is `inherited`. Allowed when `boundary` is `inherited`. | | `boundaryEvidenceIds` | array of id | Conditional | References: `evidence` Required when `boundary` is one of `inherited`, `no-account-store`. | diff --git a/packages/filegrc/model/v13.json b/packages/filegrc/model/v13.json index 54a2913..a1431aa 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -11267,12 +11267,10 @@ "type": "array", "items": "object", "itemObjectType": "provider-account-decision", - "requiredWhen": { - "boundary": "direct" - }, "allowedWhen": { "boundary": "direct" - } + }, + "description": "One decision for each actual retained account row. Empty or omitted only when the checked provider export count is zero." }, "boundary": { "type": "enum", diff --git a/packages/filegrc/test/operating-baseline.test.js b/packages/filegrc/test/operating-baseline.test.js index 7d869c7..c157c5c 100644 --- a/packages/filegrc/test/operating-baseline.test.js +++ b/packages/filegrc/test/operating-baseline.test.js @@ -293,6 +293,14 @@ test("provider baseline reconciles actual account rows separately from grants an review.sourceReconciliations[0].accounts[2].disposition = "provider-managed"; proof.readinessTest = true; assert.equal((await baseline("provider-access")).complete, false, "A retrieval rehearsal is not an actual account population"); + proof.readinessTest = false; + Object.assign(grant, { status: "revoked", deprovisionedOn: "2026-07-01", deprovisionedByIds: [get("person").id], deprovisionReason: "Removed before the checked zero population" }); + service.status = "disabled"; + review.grantDecisions = []; review.populationCount = 0; proof.populationCount = 0; + review.sourceReconciliations[0].accounts = []; + assert.equal((await baseline("provider-access")).complete, true, "A genuine checked zero population needs no invented account row"); + proof.populationCount = 1; + assert.equal((await baseline("provider-access")).complete, false, "An empty account list cannot hide a positive export count"); }); test("provider reconciliation guide and scaffold expose nested model facts without inventing account decisions", async (context) => {