Skip to content

Commit 0b90cce

Browse files
scotty595claude
andauthored
feat(audit): scope tamper-evident chains per organization (0.18.0) (#25)
Each organizationId now gets its own HMAC chain head, 1..N sequence, and write lock; org-less events share one chain (backward-compatible). Binds organizationId into the per-event hash when present; Postgres adapter now persists organization_id and uses a per-org unique sequence index. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6f1992d commit 0b90cce

13 files changed

Lines changed: 379 additions & 56 deletions

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,24 @@ const chain = await gov.integrityChain!.export();
399399
const { valid, brokenAt, breakDetail } = await verifyAuditIntegrity(chain, process.env.AUDIT_SECRET!);
400400
```
401401

402+
**Per-org (multi-tenant) chains.** Since 0.18, chains are scoped per
403+
`organizationId`: each org gets its own head, its own `1..N` sequence, and
404+
its own write lock, so one tenant's events never interleave with another's.
405+
Pass the org on the context (or via `metadata.organizationId`) and export /
406+
verify a single tenant's contiguous chain:
407+
408+
```typescript
409+
await gov.enforce({ agentId, organizationId: 'org_acme', action: 'tool_call', tool: 'search' });
410+
411+
const acme = await gov.integrityChain!.export({ organizationId: 'org_acme' });
412+
await verifyAuditIntegrity(acme, process.env.AUDIT_SECRET!); // contiguous, standalone-verifiable
413+
```
414+
415+
Events without an `organizationId` share a single org-less chain, byte-for-byte
416+
compatible with chains written before 0.18 — no migration needed. The org is
417+
bound into each event's hash (when present), so an event can't be relabelled
418+
into another tenant's chain without breaking verification.
419+
402420
**What gets chained (when `integrityAudit` is set):**
403421

404422
| Event type | Written by | What it captures |

packages/governance/CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# Changelog
22

3+
## [0.18.0] - 2026-06-26 — Per-org (multi-tenant) audit chains
4+
5+
The tamper-evident audit chain is now scoped **per organization**. Before
6+
this release a single `createGovernance({ integrityAudit })` instance kept
7+
one global hash chain, so every org's events were interleaved into the same
8+
chain and sequence space — you couldn't hand one tenant a clean, contiguous,
9+
independently-verifiable export, and one tenant's volume affected another's
10+
chain. Now each `organizationId` gets its own head, its own 1..N sequence,
11+
and its own write lock; events with no org share a single org-less chain,
12+
byte-for-byte compatible with chains written before this release.
13+
14+
Additive and backward-compatible — org-less usage is unchanged.
15+
16+
### Added
17+
18+
- `EnforcementContext.organizationId`, `AgentRegistration.organizationId`,
19+
`ActionOutcome.organizationId` — supply the tenant to scope its chain.
20+
`enforce()` / `enforceStage()` also fall back to `metadata.organizationId`.
21+
- `integrityChain.stats(organizationId?)` and `integrityChain.export({ organizationId })`
22+
now operate on a single org's chain.
23+
- `GovernanceStorage.getChainHead(organizationId?)` — resume the right org's
24+
chain on restart. The in-memory and Postgres adapters implement it.
25+
26+
### Changed
27+
28+
- `canonicalize()` binds `organizationId` into the per-event hash **only when
29+
present**, so an event cannot be relabelled into another org's chain without
30+
detection. Org-less events hash exactly as before (no migration needed).
31+
- Postgres adapter now persists `organization_id` on agents and audit events
32+
(the columns/indexes already existed but were never written), and the
33+
`integrity_sequence` unique index is now per-org
34+
(`(COALESCE(organization_id,''), integrity_sequence)`). The integrity
35+
migration drops the old global unique index and creates the composite one
36+
— idempotent, safe on existing rows.
37+
338
## [0.17.0] - 2026-05-07 — Custom conditions reachable from `createGovernance()`
439

540
The condition registry (`registerCondition` / `unregisterCondition` /

packages/governance/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,24 @@ const chain = await gov.integrityChain!.export();
399399
const { valid, brokenAt, breakDetail } = await verifyAuditIntegrity(chain, process.env.AUDIT_SECRET!);
400400
```
401401

402+
**Per-org (multi-tenant) chains.** Since 0.18, chains are scoped per
403+
`organizationId`: each org gets its own head, its own `1..N` sequence, and
404+
its own write lock, so one tenant's events never interleave with another's.
405+
Pass the org on the context (or via `metadata.organizationId`) and export /
406+
verify a single tenant's contiguous chain:
407+
408+
```typescript
409+
await gov.enforce({ agentId, organizationId: 'org_acme', action: 'tool_call', tool: 'search' });
410+
411+
const acme = await gov.integrityChain!.export({ organizationId: 'org_acme' });
412+
await verifyAuditIntegrity(acme, process.env.AUDIT_SECRET!); // contiguous, standalone-verifiable
413+
```
414+
415+
Events without an `organizationId` share a single org-less chain, byte-for-byte
416+
compatible with chains written before 0.18 — no migration needed. The org is
417+
bound into each event's hash (when present), so an event can't be relabelled
418+
into another tenant's chain without breaking verification.
419+
402420
**What gets chained (when `integrityAudit` is set):**
403421

404422
| Event type | Written by | What it captures |

packages/governance/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "governance-sdk",
3-
"version": "0.17.0",
3+
"version": "0.18.0",
44
"description": "AI Agent Governance for TypeScript — policy enforcement, scoring, compliance, and audit for AI agents",
55
"type": "module",
66
"main": "./dist/index.js",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Per-org integrity audit chain tests.
3+
*
4+
* Chains are scoped per organization: each org gets an independent HMAC chain
5+
* with its own head and its own 1..N sequence. This means:
6+
* - one org's events never interleave into another org's chain
7+
* - a single org's exported slice is contiguous and verifies standalone
8+
* - relabelling an event into a different org breaks its hash (org is bound
9+
* into the canonical form)
10+
* - events with NO organizationId share one org-less chain, byte-for-byte
11+
* compatible with chains written before per-org scoping existed
12+
*/
13+
14+
import { describe, it } from "node:test";
15+
import assert from "node:assert/strict";
16+
import { createGovernance } from "./index";
17+
import { verifyAuditIntegrity } from "./audit-integrity-verify";
18+
19+
const KEY = "per-org-chain-secret";
20+
21+
describe("integrity chain — per-org scoping", () => {
22+
it("gives each org an independent, contiguous, standalone-verifiable chain", async () => {
23+
const gov = createGovernance({ integrityAudit: { signingKey: KEY } });
24+
25+
// 3 events for org A, 2 for org B, interleaved in time.
26+
await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" });
27+
await gov.enforce({ agentId: "b1", organizationId: "orgB", action: "tool_call", tool: "t" });
28+
await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" });
29+
await gov.enforce({ agentId: "b1", organizationId: "orgB", action: "tool_call", tool: "t" });
30+
await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" });
31+
32+
const chainA = await gov.integrityChain!.export({ organizationId: "orgA" });
33+
const chainB = await gov.integrityChain!.export({ organizationId: "orgB" });
34+
35+
// Each org's slice carries only its own events, with contiguous sequences.
36+
assert.equal(chainA.length, 3);
37+
assert.equal(chainB.length, 2);
38+
assert.deepEqual(chainA.map((e) => e.integrity.sequence), [1, 2, 3]);
39+
assert.deepEqual(chainB.map((e) => e.integrity.sequence), [1, 2]);
40+
assert.ok(chainA.every((e) => e.organizationId === "orgA"));
41+
assert.ok(chainB.every((e) => e.organizationId === "orgB"));
42+
43+
// Each slice verifies cleanly on its own.
44+
const rA = await verifyAuditIntegrity(chainA, KEY);
45+
const rB = await verifyAuditIntegrity(chainB, KEY);
46+
assert.equal(rA.valid, true, rA.breakDetail ?? "");
47+
assert.equal(rB.valid, true, rB.breakDetail ?? "");
48+
49+
// Heads are tracked per-org.
50+
assert.equal(gov.integrityChain!.stats("orgA").latestSequence, 3);
51+
assert.equal(gov.integrityChain!.stats("orgB").latestSequence, 2);
52+
});
53+
54+
it("binds organizationId into the hash — relabelling into another org breaks verification", async () => {
55+
const gov = createGovernance({ integrityAudit: { signingKey: KEY } });
56+
await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" });
57+
const [event] = await gov.integrityChain!.export({ organizationId: "orgA" });
58+
59+
// Untampered verifies.
60+
assert.equal((await verifyAuditIntegrity([event], KEY)).valid, true);
61+
62+
// Move the event into another org without re-signing → hash no longer matches.
63+
const moved = { ...event, organizationId: "orgB" };
64+
const res = await verifyAuditIntegrity([moved], KEY);
65+
assert.equal(res.valid, false);
66+
});
67+
68+
it("resolves org from metadata.organizationId when the field is omitted", async () => {
69+
const gov = createGovernance({ integrityAudit: { signingKey: KEY } });
70+
await gov.enforce({ agentId: "a1", action: "tool_call", tool: "t", metadata: { organizationId: "orgMeta" } });
71+
72+
const chain = await gov.integrityChain!.export({ organizationId: "orgMeta" });
73+
assert.equal(chain.length, 1);
74+
assert.equal(chain[0].organizationId, "orgMeta");
75+
assert.equal(gov.integrityChain!.stats("orgMeta").latestSequence, 1);
76+
});
77+
78+
it("keeps the org-less chain independent from org chains", async () => {
79+
const gov = createGovernance({ integrityAudit: { signingKey: KEY } });
80+
await gov.audit.log({ agentId: "x", eventType: "custom", outcome: "success", severity: "info" });
81+
await gov.enforce({ agentId: "a1", organizationId: "orgA", action: "tool_call", tool: "t" });
82+
await gov.audit.log({ agentId: "x", eventType: "custom", outcome: "success", severity: "info" });
83+
84+
// org-less chain has its own contiguous sequence (2), unaffected by orgA.
85+
const orgless = await gov.integrityChain!.export({});
86+
const orglessOnly = orgless.filter((e) => e.organizationId == null);
87+
assert.deepEqual(orglessOnly.map((e) => e.integrity.sequence), [1, 2]);
88+
assert.equal(gov.integrityChain!.stats().latestSequence, 2);
89+
assert.equal(gov.integrityChain!.stats("orgA").latestSequence, 1);
90+
});
91+
92+
it("register() stamps the org onto the agent record and the agent_registered event", async () => {
93+
const gov = createGovernance({ integrityAudit: { signingKey: KEY } });
94+
const { id } = await gov.register({ name: "alpha", framework: "mastra", owner: "t", organizationId: "orgA" });
95+
96+
const stored = await gov.storage.getAgent(id);
97+
assert.equal(stored?.organizationId, "orgA");
98+
99+
const chainA = await gov.integrityChain!.export({ organizationId: "orgA" });
100+
assert.equal(chainA.length, 1);
101+
assert.equal(chainA[0].eventType, "agent_registered");
102+
assert.equal((await verifyAuditIntegrity(chainA, KEY)).valid, true);
103+
});
104+
105+
it("recordOutcome() lands on the agent's org chain", async () => {
106+
const gov = createGovernance({ integrityAudit: { signingKey: KEY } });
107+
await gov.recordOutcome({ agentId: "a1", organizationId: "orgA", action: "tool_call", success: true });
108+
const chainA = await gov.integrityChain!.export({ organizationId: "orgA" });
109+
assert.equal(chainA.length, 1);
110+
assert.equal(chainA[0].eventType, "action_outcome");
111+
});
112+
});

packages/governance/src/audit-integrity.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ export function deepSortKeys(value: unknown): unknown {
111111
/** Compute the canonical string representation of an audit event for hashing */
112112
export function canonicalize(event: AuditEvent, previousHash: string, sequence: number): string {
113113
// Deterministic serialization: ALL keys sorted recursively (including nested detail)
114+
//
115+
// `organizationId` is bound into the hash ONLY when the event carries one.
116+
// This cryptographically scopes per-org chains (an event cannot be relabelled
117+
// into another org's chain without breaking its hash) while staying byte-for-byte
118+
// backward-compatible with org-less chains written before per-org scoping existed
119+
// — omitting the key produces the identical canonical form as before.
114120
const canonical = deepSortKeys({
115121
agentId: event.agentId,
116122
createdAt: event.createdAt,
@@ -122,6 +128,7 @@ export function canonicalize(event: AuditEvent, previousHash: string, sequence:
122128
previousHash,
123129
sequence,
124130
severity: event.severity,
131+
...(event.organizationId != null ? { organizationId: event.organizationId } : {}),
125132
});
126133

127134
return JSON.stringify(canonical);

0 commit comments

Comments
 (0)