diff --git a/docs/architecture.md b/docs/architecture.md index 1617b3a..374dce8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -775,3 +775,5 @@ At the Step 3 cutover, `program-path --next` names FileGRC Autopilot as `step.ne Reporting Channel Set proposals require committed independent approval during Step 1. Step 3 checks the approved revision, live support, and exact effective interval at content cutover. The same derived reporting check runs in activation planners and validation. Each Program has an exact-revision Obligation Collection Review. Its complete population includes linked active schedules, unresolved proposals, and retired duplicates. Per-schedule decisions distinguish adopted rules, adopted replacements, retired schedules, and intentional inapplicability. Pending alternative rule proposals need explicit exclusion. Changes to schedules, rules, selected scope, or governing content invalidate this approval; the approved-to-active content transition preserves its design binding. Ordinary setup Collection Reviews retain their existing historical confirmation semantics. + +Schedule-set and Control collection reviewer eligibility distinguish Obligation queue ownership from personal work assignments. Team membership and chairing a Team-owned review queue do not establish that a Person operates the Controls. Control ownership still resolves Team members and Appointment holders, and personal or Appointment assignments on Obligations remain conflicts. Governed content approval keeps its full owner separation checks, including Team membership. A reviewer who also owns implementation work remains ineligible. diff --git a/package-lock.json b/package-lock.json index 6296501..2517dbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.17.1", + "version": "0.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.17.1", + "version": "0.17.2", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.17.1", + "version": "0.17.2", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.17.1", + "version": "0.17.2", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 902cc8a..7169762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.17.1", + "version": "0.17.2", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 90345b9..315ff03 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.17.1", + "version": "0.17.2", "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 092c526..ce6dc55 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.17.1", + version: "0.17.2", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.17.1", + version: "0.17.2", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 0036a2c..c075b67 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.17.1", + "version": "0.17.2", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 955db98..57eb89b 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.17.1", + "version": "0.17.2", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index 2886fa6..d1ab434 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -11,7 +11,7 @@ import { resolveDataPath } from "./paths.js"; import { getFileAtRevision } from "./git.js"; import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js"; import { resolveProgram } from "./program.js"; -import { currentPartyPeople } from "./parties.js"; +import { currentPartyPeople, queueOwnerPeople } from "./parties.js"; import { markdownEntries } from "./resource-markdown.js"; import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionDigest, revisionsMatch } from "./revisions.js"; @@ -156,8 +156,7 @@ export function controlReviewerConflictIds(loaded, controls, byId = new Map(load && (record.controlIds || []).some((id) => controlIds.has(id)))) { // A team owns the work queue, but membership alone does not mean each member // performed its work. Named people and appointment holders remain conflicts. - const namedOwners = (obligation.ownerIds || []).filter((id) => byId.get(id)?.type !== "team"); - for (const id of currentPartyPeople(namedOwners, byId)) conflicts.add(id); + for (const id of queueOwnerPeople(obligation.ownerIds || [], byId)) conflicts.add(id); } return [...conflicts].sort(); } diff --git a/packages/filegrc/src/parties.js b/packages/filegrc/src/parties.js index ee69ee6..99d4a17 100644 --- a/packages/filegrc/src/parties.js +++ b/packages/filegrc/src/parties.js @@ -52,6 +52,14 @@ export function currentPartyPeople(ids = [], byId) { }); } +// A Team owns a work queue, but membership and chairing do not prove that a +// person performs its work. Personal and Appointment assignments do name them. +// Use this only for Obligation queue ownership, never for governed content or +// Control implementation ownership. +export function queueOwnerPeople(ids = [], byId) { + return currentPartyPeople(ids.filter((id) => byId.get(id)?.type !== "team"), byId); +} + export function partiesIndependent(ownerIds = [], approverIds = [], byId) { const owners = partyPeople(ownerIds, byId); const approvers = partyPeople(approverIds, byId); diff --git a/packages/filegrc/src/schedule-set.js b/packages/filegrc/src/schedule-set.js index 3fb5e61..ce9d7e4 100644 --- a/packages/filegrc/src/schedule-set.js +++ b/packages/filegrc/src/schedule-set.js @@ -2,7 +2,7 @@ import { personWasActiveOn } from "./soc2.js"; import { currentCalendarDate } from "./time.js"; import { modelSupports } from "../model/index.js"; import { recordBelongsToProgram } from "./program.js"; -import { currentPartyPeople } from "./parties.js"; +import { currentPartyPeople, queueOwnerPeople } from "./parties.js"; import { obligationRule } from "./obligation-rule.js"; export function scheduleSetRecords(loaded, program) { @@ -47,8 +47,13 @@ export function scheduleSetInputs(loaded, program) { export function scheduleReviewerConflicts(loaded, program) { const byId = new Map(loaded.resources.map((record) => [record.id, record])); const controls = loaded.resources.filter((record) => record.type === "control" && program?.controlIds?.includes(record.id)); - return [...new Set([...controls, ...scheduleSetRecords(loaded, program)] - .flatMap((record) => [...currentPartyPeople(record.ownerIds || [], byId)]))].sort(); + // Review the whole schedule population, including proposals. Queue Team + // membership identifies notification recipients, not individual operators. + // Control ownership and named schedule assignments still establish conflicts. + return [...new Set([ + ...controls.flatMap((record) => [...currentPartyPeople(record.ownerIds || [], byId)]), + ...scheduleSetRecords(loaded, program).flatMap((record) => [...queueOwnerPeople(record.ownerIds || [], byId)]) + ])].sort(); } export function scheduleSetIssues(loaded, program, review, options = {}) { diff --git a/packages/filegrc/test/schedule-set.test.js b/packages/filegrc/test/schedule-set.test.js index 93df00b..52fd0cc 100644 --- a/packages/filegrc/test/schedule-set.test.js +++ b/packages/filegrc/test/schedule-set.test.js @@ -7,7 +7,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { loadModel } from "../model/index.js"; -import { scheduleSetIssues, scheduleSetRecords } from "../src/schedule-set.js"; +import { scheduleSetIssues, scheduleSetRecords, scheduleReviewerConflicts } from "../src/schedule-set.js"; +import { controlReviewerConflictIds } from "../src/collection-revision.js"; +import { partiesIndependent } from "../src/parties.js"; import { collectionRevision } from "../src/collection-revision.js"; import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "../src/applicability-scope.js"; const scheduleSetRevision = (loaded, program) => collectionRevision(loaded, "obligation", { program }); @@ -31,6 +33,36 @@ function fixture() { ]; return { loaded: { resources: records, model: loadModel("12"), root: null }, program }; } + +test("queue ownership separates independent oversight from operational assignments", () => { + const owner = { id: "person-owner", type: "person", status: "active" }; + const reviewer = { id: "person-reviewer", type: "person", status: "active" }; + const operator = { id: "person-operator", type: "person", status: "active" }; + const chair = { id: "appointment-chair", type: "appointment", status: "active", holderId: reviewer.id }; + const team = { id: "team-oversight", type: "team", status: "active", memberIds: [owner.id, reviewer.id, operator.id], chairIds: [chair.id] }; + const control = { id: "control-one", type: "control", status: "implemented", ownerIds: [owner.id] }; + const obligation = { id: "obligation-review", type: "obligation", status: "active", ownerIds: [team.id], controlIds: [control.id] }; + const program = { id: "program-one", type: "program", controlIds: [control.id] }; + const loaded = { model: loadModel("12"), resources: [owner, reviewer, operator, chair, team, control, obligation] }; + assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id]); + assert.deepEqual(controlReviewerConflictIds(loaded, [control]), [owner.id]); + // A mixed-role Team can contain an operator without making its chair one. + obligation.ownerIds.push(operator.id); + assert.deepEqual(scheduleReviewerConflicts(loaded, program), [operator.id, owner.id].sort()); + obligation.ownerIds = [team.id, chair.id]; + assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id, reviewer.id].sort()); + obligation.ownerIds = [team.id]; + control.ownerIds.push(reviewer.id); + assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id, reviewer.id].sort()); + control.ownerIds = [team.id]; + assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id, reviewer.id, operator.id].sort()); + assert.equal(partiesIndependent([team.id], [reviewer.id], new Map(loaded.resources.map((r) => [r.id, r]))), false, + "governed content owned by a Team still cannot be approved by its member"); + control.ownerIds = [owner.id]; + obligation.status = "proposed"; + obligation.ownerIds = [reviewer.id]; + assert.ok(scheduleReviewerConflicts(loaded, program).includes(reviewer.id), "personal proposal assignments still conflict"); +}); const adopted = { obligationId: "calendar", disposition: "adopted", ruleId: "rule-calendar", rationale: "Required periodic review." }; const exclusion = { obligationId: "event", disposition: "inapplicable", rationale: "This proposal duplicates the separately reviewed incident checklist." }; @@ -78,6 +110,14 @@ test("schedule-set review is exact, independent, and available through the CLI", const root = await mkdtemp(join(tmpdir(), "filegrc-schedule-review-")); context.after(() => rm(root, { recursive: true, force: true })); await makeComprehensiveWorkspace(root, "12"); + const initial = await loadWorkspace(root); + const team = initial.entries.find(({ record }) => record.type === "team"); + team.record.memberIds = ["person-example", "person-independent-approver-example"]; + team.record.chairIds = ["person-independent-approver-example"]; + await writeJson(team.path, team.record); + const ownedSchedule = initial.entries.find(({ record }) => record.type === "obligation"); + ownedSchedule.record.ownerIds = [team.record.id]; + await writeJson(ownedSchedule.path, ownedSchedule.record); await initializeExistingModel12Workspace(root); let loaded = await loadWorkspace(root); const program = loaded.resources.find(({ type }) => type === "program"); @@ -90,6 +130,14 @@ test("schedule-set review is exact, independent, and available through the CLI", await assert.rejects(planCollectionReview(root, { ...payload, reviewedByIds: ["person-example"] }), /independent/); const plan = await planCollectionReview(root, payload); assert.deepEqual(plan.changes.create[0].scheduleDecisions, payload.scheduleDecisions); + const browser = await createAppStateSection(loaded, "program", { programId: program.id }); + assert.ok(browser.collectionReviews.obligation.eligibleReviewerIds.includes("person-independent-approver-example")); + const payloadPath = `${root}-schedule-review.json`; + context.after(() => rm(payloadPath, { force: true })); + await writeJson(payloadPath, payload); + const preview = await executeCli(runCli, process.execPath, [cli, "review-collection", "obligation", payloadPath, "--program", program.id, "--root", root, "--preview", "--json"]); + const stableChanges = (changes) => JSON.parse(JSON.stringify(changes, (key, value) => key === "knowledgeCutoffAt" ? undefined : value)); + assert.deepEqual(stableChanges(JSON.parse(preview.stdout).changes), stableChanges(plan.changes)); await applyCollectionReview(root, { ...payload, confirmed: true }); loaded = await loadWorkspace(root); assert.equal(assessCollectionReview(loaded, "obligation", { programId: program.id }).complete, true);