From 963809bc8cb7fc2911c9067e727f45cd0e4899be Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 30 Jul 2026 14:34:09 +1000 Subject: [PATCH] fix: close capability drift, missions dead end, audit verifier usability Bootstrap's administrator grant was a hand-maintained array literal that had already drifted from @muster/authz (brolga.context.read was missing), and because bootstrap rewrites capability_assignments on every boot it silently reverts any migration backfill. Starter actor rows now live in seed-data.ts with the administrator grant derived from starterRoleCapabilities.administrator, guarded by a behavioural test that fails on drift and on any undeclared capability in a starter actor. The Missions empty state stated a fact and left the operator stuck. It now names the governed tool (muster_upsert_mission), the capability and scope its caller needs, the operator command that provisions an MCP installation, and links to Guides. No UI create path is introduced; ADR 0005 keeps mission creation on the governed server path. The audit verifier threw a raw error when MUSTER_AUDIT_ORGANISATION_ID was unset. It now accepts --organisation, lists the organisations in the connected database when none is supplied, prints an operator-readable interpretation alongside the JSON report, and separates usage (64) and unreachable-database (69) from a failed chain (1). No audit row is read differently or written; docs/operations/audit-chain-verification.md documents how to run it and how to interpret legacy-compatible-not-strict without ever repairing history. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/missions/missions-view.test.ts | 21 +++ apps/web/features/missions/missions-view.tsx | 27 +++- docs/operations/audit-chain-verification.md | 113 +++++++++++++++++ docs/operations/incident-recovery.md | 6 +- .../database/src/audit-integrity-cli.test.ts | 108 ++++++++++++++++ packages/database/src/audit-integrity-cli.ts | 103 +++++++++++++++ packages/database/src/bootstrap.ts | 120 +----------------- packages/database/src/seed-data.ts | 88 +++++++++++++ packages/database/src/starter-actors.test.ts | 53 ++++++++ .../database/src/verify-audit-integrity.ts | 71 ++++++++--- 10 files changed, 573 insertions(+), 137 deletions(-) create mode 100644 apps/web/features/missions/missions-view.test.ts create mode 100644 docs/operations/audit-chain-verification.md create mode 100644 packages/database/src/audit-integrity-cli.test.ts create mode 100644 packages/database/src/audit-integrity-cli.ts create mode 100644 packages/database/src/starter-actors.test.ts diff --git a/apps/web/features/missions/missions-view.test.ts b/apps/web/features/missions/missions-view.test.ts new file mode 100644 index 0000000..0e3231f --- /dev/null +++ b/apps/web/features/missions/missions-view.test.ts @@ -0,0 +1,21 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +const viewUrl = new URL("./missions-view.tsx", import.meta.url); + +describe("Missions empty state", () => { + it("names the governed tool and the prerequisites for calling it", async () => { + const source = await readFile(viewUrl, "utf8"); + expect(source).toContain("muster_upsert_mission"); + expect(source).toContain("workflows.manage"); + expect(source).toContain("create-installation"); + expect(source).toContain('href="/guides"'); + }); + + it("does not offer a UI create path", async () => { + const source = await readFile(viewUrl, "utf8"); + expect(source).toContain("no create path by design"); + expect(source).not.toContain("New mission"); + expect(source).not.toContain("useUpsertMission"); + }); +}); diff --git a/apps/web/features/missions/missions-view.tsx b/apps/web/features/missions/missions-view.tsx index de9b3f2..5ab6687 100644 --- a/apps/web/features/missions/missions-view.tsx +++ b/apps/web/features/missions/missions-view.tsx @@ -32,7 +32,32 @@ export function MissionsView() { {missions.data && missions.data.length === 0 ? ( +

+ To get a mission listed here, grant the MCP installation the{" "} + workflows.manage capability + and the{" "} + muster_upsert_mission{" "} + scope, then have Hermes call that tool with a name, + description, and capability envelope. +

+

+ An operator provisions the installation with{" "} + + pnpm --filter @muster/mcp create-installation + + . +

+ + Guides: Missions and Audit + + + } /> ) : null} {missions.data && missions.data.length > 0 ? ( diff --git a/docs/operations/audit-chain-verification.md b/docs/operations/audit-chain-verification.md new file mode 100644 index 0000000..d08672b --- /dev/null +++ b/docs/operations/audit-chain-verification.md @@ -0,0 +1,113 @@ +# Operational runbook: audit chain verification + +Muster's `audit_events` table is a hash-chained, append-only log. This runbook +covers running the verifier and interpreting its result. For the attestation +and evidence-custody steps that follow a non-strict result, continue with +[incident-recovery.md](incident-recovery.md#audit-integrity-verification-and-attestation). + +**The verifier never writes.** Nothing in this runbook asks you to change an +audit row, and nothing ever should. A hash mismatch is investigated, recorded, +and attested — never "repaired". + +## Run it + +Verification is scoped to one organisation. + +```bash +MUSTER_AUDIT_ORGANISATION_ID= pnpm db:verify-audit +# or +pnpm db:verify-audit --organisation= +``` + +If you do not know the organisation id, run the command with neither. It lists +the organisations in the connected database and exits `64` without verifying +anything: + +``` +MUSTER_AUDIT_ORGANISATION_ID is not set and --organisation was not passed. + +Usage: + MUSTER_AUDIT_ORGANISATION_ID= pnpm db:verify-audit + pnpm db:verify-audit --organisation= + +Organisations in this database: + 018f55d8-c4c7-7c3e-88ef-000000000001 muster +``` + +The machine-readable JSON report goes to stdout; the operator interpretation +goes to stderr. Capture both: + +```bash +pnpm db:verify-audit --organisation= >report.json 2>report.txt +``` + +## Exit codes + +| Code | Outcome | Meaning | +| ---- | ------------------------------ | -------------------------------------------------------------------------------------- | +| `0` | `strict-valid` | Every event rehashes exactly. Nothing to do. | +| `1` | `invalid` | A mismatch no known legacy path explains. Treat as an incident. | +| `2` | `legacy-compatible-not-strict` | Only the known pre-normalisation `approvalId` defect. Strict verification still fails. | +| `64` | — | Usage: no organisation id, or not a UUID. Nothing was verified. | +| `69` | — | The database could not be reached. Nothing was verified. | + +`64` and `69` are deliberately distinct from `1`: an operator who forgot an +environment variable, and a database that is down, must not page anyone with +"audit chain invalid". + +## Interpreting `legacy-compatible-not-strict` + +This is the expected result on any workspace that recorded integration actions +before audit metadata normalisation. It is what the homelab reports today, with +strict verification failing at sequence 136. + +What happened: `integration.action.queued` / `.succeeded` / `.failed` events +were hashed with `approvalId: undefined` present in their metadata object. +PostgreSQL JSONB drops undefined properties on write, so the stored metadata no +longer contains the key the stored hash was computed over. The event is +authentic; its hash is simply reproducible only by re-adding the omitted +property, which is what `packages/audit`'s legacy compatibility path does. + +What it does **not** mean: + +- It is not a repair. `historicalChainRepaired` is always `false`. +- It does not make strict verification pass. `strict.valid` stays `false`, and + the report keeps reporting the failing sequence. +- It is not evidence of tampering by itself — but it also is not a clean bill + of health, which is why the exit code is not `0`. + +Scope check before accepting it: every sequence listed in +`legacyApprovalIdOmissions` must be one of those three integration-action +actions, and the sequence in `strict.brokenAt` must be the first of them. The +verifier enforces this; anything outside that set is reported `invalid` +instead. + +Then: + +1. Keep `report.json` and `report.txt` with the incident or restore evidence. +2. Record the report time, application revision, command, `strict.brokenAt`, + and every listed legacy sequence in the recovery attestation described in + [incident-recovery.md](incident-recovery.md#audit-integrity-verification-and-attestation). +3. State explicitly in that attestation that the result is a compatibility + reconstruction only and that no audit event was rewritten or removed. + +The count of affected sequences is fixed history: it can only stay the same or +be exceeded by a genuinely new defect. If a later run reports a legacy sequence +that was not in the previous run, events are still being written with the old +shape — that is a code regression in the integration-action path, and it is a +bug to fix at the writer, never in the stored rows. + +## Interpreting `invalid` + +Something rehashes wrong and no known reconstruction explains it. Preserve the +rows exactly as stored, contain writes if warranted, and follow the incident +path in [incident-recovery.md](incident-recovery.md). Do not run any command +that updates audit hashes, metadata, sequences, or outbox history. + +## When strict verification will pass again + +Never, for a workspace that already holds pre-normalisation events — and that +is correct. Immutable history cannot be made strictly reproducible without +rewriting it. `legacy-compatible-not-strict` is the permanent, honest result +for those workspaces; a workspace bootstrapped after normalisation reports +`strict-valid`. diff --git a/docs/operations/incident-recovery.md b/docs/operations/incident-recovery.md index 40d5f53..3a992e3 100644 --- a/docs/operations/incident-recovery.md +++ b/docs/operations/incident-recovery.md @@ -16,7 +16,11 @@ MUSTER_AUDIT_ORGANISATION_ID= pnpm db:verify-audit The command emits an operator-visible JSON report and does not write, repair, delete, or otherwise alter audit history. It exits `0` only for -`strict-valid`, `2` for `legacy-compatible-not-strict`, and `1` for `invalid`. +`strict-valid`, `2` for `legacy-compatible-not-strict`, and `1` for `invalid`; +`64` and `69` mean nothing was verified (missing organisation id, unreachable +database). Running it, finding the organisation id, and interpreting each +outcome are covered in +[audit-chain-verification.md](audit-chain-verification.md). `legacy-compatible-not-strict` identifies only the known historical defect: an integration-action event was hashed before PostgreSQL JSONB omitted an diff --git a/packages/database/src/audit-integrity-cli.test.ts b/packages/database/src/audit-integrity-cli.test.ts new file mode 100644 index 0000000..958e4e0 --- /dev/null +++ b/packages/database/src/audit-integrity-cli.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { hashAuditEvent, verifyAuditIntegrity } from "@muster/audit"; +import type { HashableAuditEvent } from "@muster/audit"; +import { + RUNBOOK, + auditIntegrityExitCodes, + describeAuditIntegrity, + resolveAuditOrganisationId, +} from "./audit-integrity-cli.ts"; + +const organisationId = "018f55d8-c4c7-7c3e-88ef-000000000001"; +const base: HashableAuditEvent = { + organisationId, + sequence: 1, + actorId: "actor", + actorType: "human", + action: "room.message.created", + targetType: "message", + targetId: "message", + previousHash: "0".repeat(64), + metadata: { safe: true }, + traceId: "trace", + createdAt: "2026-07-26T00:00:00.000Z", +}; + +function legacyEvent() { + const input = { + ...base, + action: "integration.action.queued", + metadata: { operation: "alerts.list", approvalId: undefined }, + }; + return { + ...input, + metadata: { operation: "alerts.list" }, + eventHash: hashAuditEvent(input), + }; +} + +describe("audit organisation id resolution", () => { + it("accepts a UUID from either the environment or the flag", () => { + expect(resolveAuditOrganisationId(` ${organisationId} `)).toEqual({ + organisationId, + }); + }); + + it("lists candidate organisations when none was supplied", () => { + const resolved = resolveAuditOrganisationId(undefined, [ + { id: organisationId, slug: "muster", name: "Muster Workspace" }, + ]); + expect(resolved).not.toHaveProperty("organisationId"); + const usage = "usage" in resolved ? resolved.usage : ""; + expect(usage).toContain("MUSTER_AUDIT_ORGANISATION_ID is not set"); + expect(usage).toContain(organisationId); + expect(usage).toContain("muster"); + expect(usage).toContain(RUNBOOK); + }); + + it("explains a malformed id instead of failing anonymously", () => { + const resolved = resolveAuditOrganisationId("not-a-uuid"); + expect("usage" in resolved && resolved.usage).toContain("not a UUID"); + }); + + it("says so when no organisation could be listed", () => { + const resolved = resolveAuditOrganisationId(undefined, []); + expect("usage" in resolved && resolved.usage).toContain("DATABASE_URL"); + }); +}); + +describe("audit integrity interpretation", () => { + it("reports a clean chain without ambiguity", () => { + const report = verifyAuditIntegrity([ + { ...base, eventHash: hashAuditEvent(base) }, + ]); + expect(auditIntegrityExitCodes[report.outcome]).toBe(0); + expect(describeAuditIntegrity(report)).toContain( + "Strict verification passed", + ); + }); + + it("names the failing sequence and forbids repair for a legacy omission", () => { + const report = verifyAuditIntegrity([legacyEvent()]); + expect(report.outcome).toBe("legacy-compatible-not-strict"); + expect(auditIntegrityExitCodes[report.outcome]).toBe(2); + + const description = describeAuditIntegrity(report); + expect(description).toContain("failed at sequence 1"); + expect(description).toContain("approvalId omission"); + expect(description).toContain("Do not mutate any audit row"); + expect(description).toContain(RUNBOOK); + expect(description).not.toMatch(/repair(ed)? the chain/i); + }); + + it("escalates an unexplained mismatch as a possible incident", () => { + const report = verifyAuditIntegrity([ + { ...base, eventHash: "0".repeat(64) }, + ]); + expect(auditIntegrityExitCodes[report.outcome]).toBe(1); + + const description = describeAuditIntegrity(report); + expect(description).toContain("no known legacy reconstruction"); + expect(description).toContain("Preserve the rows exactly as stored"); + }); + + it("separates operator error and outage from chain failure", () => { + expect(auditIntegrityExitCodes.usage).toBe(64); + expect(auditIntegrityExitCodes.unavailable).toBe(69); + }); +}); diff --git a/packages/database/src/audit-integrity-cli.ts b/packages/database/src/audit-integrity-cli.ts new file mode 100644 index 0000000..a45ea86 --- /dev/null +++ b/packages/database/src/audit-integrity-cli.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import type { AuditIntegrityReport } from "@muster/audit"; + +export const RUNBOOK = "docs/operations/audit-chain-verification.md"; + +/** + * 64/69 are sysexits EX_USAGE/EX_UNAVAILABLE: a missing organisation id or an + * unreachable database must not raise the same alarm as a chain that failed + * verification. + */ +export const auditIntegrityExitCodes = { + "strict-valid": 0, + invalid: 1, + "legacy-compatible-not-strict": 2, + usage: 64, + unavailable: 69, +} as const; + +export interface AuditOrganisationChoice { + id: string; + slug: string | null; + name: string | null; +} + +function usage( + reason: string, + known: ReadonlyArray, +): string { + const lines = [ + reason, + "", + "Usage:", + " MUSTER_AUDIT_ORGANISATION_ID= pnpm db:verify-audit", + " pnpm db:verify-audit --organisation=", + "", + ]; + if (known.length === 0) { + lines.push( + "No organisations could be listed from this database. Check DATABASE_URL.", + ); + } else { + lines.push("Organisations in this database:"); + for (const choice of known) { + lines.push( + ` ${choice.id} ${choice.slug ?? choice.name ?? ""}`.trimEnd(), + ); + } + } + lines.push("", `Runbook: ${RUNBOOK}`); + return lines.join("\n"); +} + +export function resolveAuditOrganisationId( + candidate: string | undefined, + known: ReadonlyArray = [], +): { organisationId: string } | { usage: string } { + const trimmed = candidate?.trim(); + if (!trimmed) { + return { + usage: usage( + "MUSTER_AUDIT_ORGANISATION_ID is not set and --organisation was not passed.", + known, + ), + }; + } + if (!z.string().uuid().safeParse(trimmed).success) { + return { + usage: usage(`"${trimmed}" is not a UUID organisation id.`, known), + }; + } + return { organisationId: trimmed }; +} + +/** + * Operator-readable interpretation of the machine-readable report. Every branch + * states that immutable history stays untouched: the only safe response to a + * hash mismatch is investigation, never a rewrite. + */ +export function describeAuditIntegrity(report: AuditIntegrityReport): string { + const brokenAt = report.strict.valid ? undefined : report.strict.brokenAt; + + if (report.outcome === "strict-valid") { + return `Strict verification passed. The chain is intact and nothing was changed. Runbook: ${RUNBOOK}`; + } + + if (report.outcome === "legacy-compatible-not-strict") { + const sequences = report.legacyApprovalIdOmissions + .map((match) => match.sequence) + .join(", "); + return [ + `Strict verification failed at sequence ${brokenAt}.`, + `Every failing event is a known pre-normalisation approvalId omission (sequence ${sequences}); its stored hash is reproducible only under the legacy compatibility path.`, + "This is expected on workspaces that recorded integration actions before metadata normalisation. The chain still links, and no event was repaired or rewritten.", + `Do not mutate any audit row. Record this outcome against the runbook: ${RUNBOOK}`, + ].join("\n"); + } + + return [ + `Strict verification failed at sequence ${brokenAt} and no known legacy reconstruction explains it.`, + "Treat this as a potential tampering or corruption incident. Preserve the rows exactly as stored.", + `Runbook: ${RUNBOOK}`, + ].join("\n"); +} diff --git a/packages/database/src/bootstrap.ts b/packages/database/src/bootstrap.ts index 61b2448..8a918b1 100644 --- a/packages/database/src/bootstrap.ts +++ b/packages/database/src/bootstrap.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { closeDatabase, database, schema } from "./index.ts"; import { bootstrapEnvironmentConnectors } from "./bootstrap-connectors.ts"; -import { starterIds } from "./seed-data.ts"; +import { starterActorSeeds, starterIds } from "./seed-data.ts"; const db = database(); const organisationName = @@ -10,57 +10,6 @@ const organisationSlug = process.env.MUSTER_ORGANISATION_SLUG?.trim() || "muster"; const administratorEmail = process.env.MUSTER_LOCAL_ADMIN_EMAIL?.trim() || "admin@muster.local"; -const administratorCapabilities = [ - "administration.manage", - "rooms.read", - "rooms.create", - "rooms.manage", - "messages.create", - "messages.moderate", - "tasks.read", - "tasks.create", - "tasks.update", - "tasks.assign", - "alerts.read", - "alerts.acknowledge", - "alerts.dismiss", - "alerts.promote", - "investigations.read", - "investigations.create", - "investigations.update", - "investigations.promote", - "investigations.close", - "kelpie.cases.read", - "kelpie.cases.create", - "kelpie.cases.update", - "tawny.telemetry.read", - "tawny.hunts.execute", - "unifi.network.read", - "tawny.response.kill_process", - "tawny.response.isolate_host", - "bower.fleet.read", - "bower.policy.read", - "bower.policy.propose", - "bower.policy.publish", - "sentinel.query.execute", - "sentinel.rules.read", - "sentinel.rules.publish", - "research.feeds.read", - "agents.read", - "agents.invoke", - "agents.manage", - "agents.cancel", - "agents.handoff", - "workflows.read", - "workflows.execute", - "workflows.approve", - "workflows.manage", - "evidence.read", - "evidence.upload", - "evidence.export", - "audit.read", - "audit.export", -]; await db .insert(schema.organisations) @@ -93,72 +42,7 @@ await db await db .insert(schema.actors) - .values([ - { - id: starterIds.actors.jordan, - organisationId: starterIds.organisation, - actorType: "human", - displayName: "Muster Administrator", - identityReference: administratorEmail, - capabilityAssignments: administratorCapabilities, - }, - { - id: starterIds.actors.triage, - organisationId: starterIds.organisation, - actorType: "agent", - displayName: "Alfie", - identityReference: "agent:alfie-threat-research", - capabilityAssignments: [ - "alerts.read", - "investigations.read", - "kelpie.cases.read", - "sentinel.rules.read", - "research.feeds.read", - "agents.handoff", - ], - }, - { - id: starterIds.actors.tawnyHunt, - organisationId: starterIds.organisation, - actorType: "agent", - displayName: "Jessie", - identityReference: "agent:jessie-hunt", - capabilityAssignments: [ - "alerts.read", - "investigations.read", - "investigations.update", - "kelpie.cases.read", - "kelpie.cases.update", - "tawny.telemetry.read", - "tawny.hunts.execute", - "unifi.network.read", - "sentinel.query.execute", - "agents.handoff", - ], - }, - { - id: starterIds.actors.threatIntel, - organisationId: starterIds.organisation, - actorType: "agent", - displayName: "Parker", - identityReference: "agent:parker-executive", - capabilityAssignments: [ - "alerts.read", - "investigations.read", - "kelpie.cases.read", - "audit.read", - "agents.handoff", - ], - }, - { - id: starterIds.actors.system, - organisationId: starterIds.organisation, - actorType: "system", - displayName: "Muster", - identityReference: "system:muster", - capabilityAssignments: [], - }, - ]) + .values(starterActorSeeds(administratorEmail)) .onConflictDoUpdate({ target: schema.actors.id, set: { diff --git a/packages/database/src/seed-data.ts b/packages/database/src/seed-data.ts index 3ad26b4..17ea5bd 100644 --- a/packages/database/src/seed-data.ts +++ b/packages/database/src/seed-data.ts @@ -1,3 +1,6 @@ +import type { actorTypeValues } from "@muster/contracts"; +import { starterRoleCapabilities, type Capability } from "@muster/authz"; + export const starterIds = { organisation: "018f55d8-c4c7-7c3e-88ef-000000000001", actors: { @@ -50,6 +53,91 @@ export const starterIds = { }, } as const; +export interface StarterActorSeed { + id: string; + organisationId: string; + actorType: (typeof actorTypeValues)[number]; + displayName: string; + identityReference: string; + capabilityAssignments: Capability[]; +} + +/** + * Bootstrap rewrites starter capability assignments on every boot, so any + * hand-maintained copy of the administrator grant silently reverts a migration + * that backfilled a newly declared capability. Derive it from authz instead. + */ +export function starterActorSeeds( + administratorEmail: string, +): StarterActorSeed[] { + return [ + { + id: starterIds.actors.jordan, + organisationId: starterIds.organisation, + actorType: "human", + displayName: "Muster Administrator", + identityReference: administratorEmail, + capabilityAssignments: [...starterRoleCapabilities.administrator], + }, + { + id: starterIds.actors.triage, + organisationId: starterIds.organisation, + actorType: "agent", + displayName: "Alfie", + identityReference: "agent:alfie-threat-research", + capabilityAssignments: [ + "alerts.read", + "investigations.read", + "kelpie.cases.read", + "sentinel.rules.read", + "research.feeds.read", + "agents.handoff", + ], + }, + { + id: starterIds.actors.tawnyHunt, + organisationId: starterIds.organisation, + actorType: "agent", + displayName: "Jessie", + identityReference: "agent:jessie-hunt", + capabilityAssignments: [ + "alerts.read", + "investigations.read", + "investigations.update", + "kelpie.cases.read", + "kelpie.cases.update", + "tawny.telemetry.read", + "tawny.hunts.execute", + "unifi.network.read", + "sentinel.query.execute", + "agents.handoff", + ], + }, + { + id: starterIds.actors.threatIntel, + organisationId: starterIds.organisation, + actorType: "agent", + displayName: "Parker", + identityReference: "agent:parker-executive", + capabilityAssignments: [ + "alerts.read", + "investigations.read", + "kelpie.cases.read", + "audit.read", + "agents.handoff", + ], + }, + { + id: starterIds.actors.system, + organisationId: starterIds.organisation, + actorType: "system", + displayName: "Muster", + identityReference: "system:muster", + capabilityAssignments: [], + }, + ]; +} + /** * Deterministic IDs for demonstration data only. They must never overlap the * clean-install bootstrap namespace above: demo seeding is opt-in and must not diff --git a/packages/database/src/starter-actors.test.ts b/packages/database/src/starter-actors.test.ts new file mode 100644 index 0000000..35cff32 --- /dev/null +++ b/packages/database/src/starter-actors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { capabilities, starterRoleCapabilities } from "@muster/authz"; +import { starterActorSeeds, starterIds } from "./seed-data.ts"; + +const seeds = starterActorSeeds("admin@muster.local"); +const administrator = seeds.find( + (seed) => seed.id === starterIds.actors.jordan, +); +const agents = seeds.filter((seed) => seed.actorType === "agent"); + +describe("bootstrap starter actors", () => { + it("grants the administrator every declared capability", () => { + // Bootstrap overwrites capability_assignments on every boot, so a + // capability missing here is a capability the administrator permanently + // loses, migration backfill included. + expect(administrator).toBeDefined(); + expect(new Set(administrator?.capabilityAssignments)).toEqual( + new Set(capabilities), + ); + expect(new Set(administrator?.capabilityAssignments)).toEqual( + new Set(starterRoleCapabilities.administrator), + ); + }); + + it("assigns only declared capabilities to every starter actor", () => { + const declared = new Set(capabilities); + const undeclared = seeds.flatMap((seed) => + seed.capabilityAssignments.filter( + (capability) => !declared.has(capability), + ), + ); + expect(undeclared).toEqual([]); + }); + + it("keeps starter agents below administrator privilege", () => { + expect(agents).toHaveLength(3); + for (const agent of agents) { + expect(agent.capabilityAssignments).not.toContain( + "administration.manage", + ); + expect(agent.capabilityAssignments.length).toBeLessThan( + capabilities.length, + ); + } + }); + + it("takes the administrator identity from the configured email", () => { + expect(starterActorSeeds("operator@example.test")[0]).toMatchObject({ + actorType: "human", + identityReference: "operator@example.test", + }); + }); +}); diff --git a/packages/database/src/verify-audit-integrity.ts b/packages/database/src/verify-audit-integrity.ts index 2fc60bd..b0fd1d6 100644 --- a/packages/database/src/verify-audit-integrity.ts +++ b/packages/database/src/verify-audit-integrity.ts @@ -1,21 +1,51 @@ import { asc, eq } from "drizzle-orm"; -import { z } from "zod"; import { verifyAuditIntegrity } from "@muster/audit"; +import { + auditIntegrityExitCodes, + describeAuditIntegrity, + resolveAuditOrganisationId, + type AuditOrganisationChoice, +} from "./audit-integrity-cli.ts"; import { closeDatabase, database, schema } from "./index.ts"; -const organisationIdResult = z - .string() - .uuid() - .safeParse(process.env.MUSTER_AUDIT_ORGANISATION_ID); +function arg(name: string): string | undefined { + const prefix = `--${name}=`; + return process.argv + .find((value) => value.startsWith(prefix)) + ?.slice(prefix.length); +} -if (!organisationIdResult.success) { - throw new Error( - "MUSTER_AUDIT_ORGANISATION_ID is required and must be a UUID", - ); +async function organisationChoices(): Promise { + try { + return await database() + .select({ + id: schema.organisations.id, + slug: schema.organisations.slug, + name: schema.organisations.name, + }) + .from(schema.organisations) + .limit(25); + } catch { + // Listing is only an aid for the usage message; an unreachable database + // must not replace the reason the operator is being shown it. + return []; + } } -const organisationId = organisationIdResult.data; -try { +async function main() { + const candidate = + arg("organisation") ?? process.env.MUSTER_AUDIT_ORGANISATION_ID; + const resolved = resolveAuditOrganisationId( + candidate, + candidate ? [] : await organisationChoices(), + ); + if ("usage" in resolved) { + process.stderr.write(`${resolved.usage}\n`); + process.exitCode = auditIntegrityExitCodes.usage; + return; + } + const { organisationId } = resolved; + const events = await database() .select({ organisationId: schema.auditEvents.organisationId, @@ -54,12 +84,19 @@ try { 2, )}\n`, ); - process.exitCode = - report.outcome === "strict-valid" - ? 0 - : report.outcome === "legacy-compatible-not-strict" - ? 2 - : 1; + process.stderr.write(`${describeAuditIntegrity(report)}\n`); + process.exitCode = auditIntegrityExitCodes[report.outcome]; +} + +try { + await main(); +} catch (error) { + process.stderr.write( + `Audit verification could not run: ${ + error instanceof Error ? error.message : "unknown error" + }\n`, + ); + process.exitCode = auditIntegrityExitCodes.unavailable; } finally { await closeDatabase(); }