diff --git a/infrastructure/evault-core/package.json b/infrastructure/evault-core/package.json index 1e4c80989..fdfab0738 100644 --- a/infrastructure/evault-core/package.json +++ b/infrastructure/evault-core/package.json @@ -21,6 +21,7 @@ "@fastify/formbody": "^8.0.2", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", + "@metastate-foundation/delegation": "workspace:*", "@types/multer": "^1.4.13", "argon2": "^0.44.0", "axios": "^1.6.7", diff --git a/infrastructure/evault-core/src/core/db/migrations/add-delegation-grant-constraint.ts b/infrastructure/evault-core/src/core/db/migrations/add-delegation-grant-constraint.ts new file mode 100644 index 000000000..b80fd1d3a --- /dev/null +++ b/infrastructure/evault-core/src/core/db/migrations/add-delegation-grant-constraint.ts @@ -0,0 +1,21 @@ +import type { Driver } from "neo4j-driver"; + +/** + * Each signed company-authority grant may be used for one record only. The + * constraint makes the delegation guard's claim (a MERGE) race-safe. + */ +export async function createDelegationGrantConstraint( + driver: Driver, +): Promise { + const session = driver.session(); + try { + await session.run( + "CREATE CONSTRAINT delegation_grant_payload IF NOT EXISTS FOR (g:DelegationGrant) REQUIRE (g.eName, g.payloadSha256) IS UNIQUE", + ); + await session.run( + "CREATE INDEX delegation_grant_claim IF NOT EXISTS FOR (g:DelegationGrant) ON (g.claimToken)", + ); + } finally { + await session.close(); + } +} diff --git a/infrastructure/evault-core/src/core/delegation/delegation-write-guard.spec.ts b/infrastructure/evault-core/src/core/delegation/delegation-write-guard.spec.ts new file mode 100644 index 000000000..7e241547f --- /dev/null +++ b/infrastructure/evault-core/src/core/delegation/delegation-write-guard.spec.ts @@ -0,0 +1,590 @@ +import { createHash } from "node:crypto"; +import { + buildGrantPayload, + COMPANY_ONTOLOGY, + DELEGATION_ONTOLOGY, + ROLE_ONTOLOGY, + SHAREHOLDING_ONTOLOGY, +} from "@metastate-foundation/delegation"; +import type { Driver } from "neo4j-driver"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + setupTestNeo4j, + teardownTestNeo4j, +} from "../../test-utils/neo4j-setup"; +import { DbService } from "../db/db.service"; +import { createDelegationGrantConstraint } from "../db/migrations/add-delegation-grant-constraint"; +import { DelegationWriteGuard } from "./delegation-write-guard"; + +const NDA = "@esigner:nda"; +const INVOICE = "ontology:11111111-2222-4333-8444-555555555555"; +const T = "2026-10-08T12:00:00.000Z"; + +/** A stand-in signature: only `sign` produces one `verify` accepts. */ +const fakeSig = (eName: string, payload: string) => + `sig:${eName}:${createHash("sha256").update(payload).digest("hex")}`; +const verify = async (eName: string, payload: string, signature: string) => + signature === fakeSig(eName, payload); + +describe("DelegationWriteGuard", () => { + let driver: Driver; + let db: DbService; + let guard: DelegationWriteGuard; + + beforeAll(async () => { + const setup = await setupTestNeo4j(); + driver = setup.driver; + db = new DbService(driver); + guard = new DelegationWriteGuard(db, verify); + await createDelegationGrantConstraint(driver); + }, 120000); + + afterAll(async () => { + await teardownTestNeo4j(); + }); + + /** Signs a record as `signer` for the company `vault`. */ + async function sign( + vault: string, + ontology: string, + signer: string, + record: Record, + ) { + const { authorization: _drop, ...rest } = record; + const signedPayload = await buildGrantPayload({ + ontology, + companyEName: vault, + signerEName: signer, + record: rest, + }); + return { + ...rest, + authorization: { + signerEName: signer, + signedPayload, + signature: fakeSig(signer, signedPayload), + signedAt: T, + }, + }; + } + + /** Writes like the GraphQL resolvers do: guard, write, then after-write. */ + async function write( + vault: string, + ontology: string, + payload: Record, + id?: string, + ): Promise { + const ticket = await guard.beforeWrite(vault, { + ontology, + payload, + id, + }); + let landed: string; + try { + landed = id + ? ( + await db.updateMetaEnvelopeById( + id, + { ontology, payload, acl: ["*"] }, + ["*"], + vault, + ) + ).metaEnvelope.id + : ( + await db.storeMetaEnvelope( + { ontology, payload, acl: ["*"] }, + ["*"], + vault, + ) + ).metaEnvelope.id; + } catch (error) { + await guard.abortWrite(ticket); + throw error; + } + await guard.afterWrite(vault, ticket, landed); + return landed; + } + + const parsed = async (vault: string, id: string) => + (await db.findMetaEnvelopeById(id, vault))?.parsed as Record< + string, + any + >; + + const company = (vault: string, directors: string[]) => ({ + id: vault, + eName: vault, + directors, + createdAt: T, + updatedAt: T, + }); + + const role = (vault: string, over: Record = {}) => ({ + companyEName: vault, + title: "Head of Finance", + scopes: [NDA, INVOICE], + mayRedelegate: true, + status: "active", + createdBy: "@dir", + createdAt: T, + updatedAt: T, + ...over, + }); + + const delegation = (vault: string, over: Record = {}) => ({ + companyEName: vault, + delegateEName: "@bob", + title: "Head of Finance", + scopes: [NDA, INVOICE], + mayRedelegate: true, + grantedBy: "@dir", + status: "active", + createdAt: T, + updatedAt: T, + ...over, + }); + + /** A company with one director (@dir), one role and Bob holding it. */ + async function setUp(vault: string) { + const companyId = await write( + vault, + COMPANY_ONTOLOGY, + await sign( + vault, + COMPANY_ONTOLOGY, + "@dir", + company(vault, ["@dir"]), + ), + ); + const roleId = await write( + vault, + ROLE_ONTOLOGY, + await sign(vault, ROLE_ONTOLOGY, "@dir", role(vault)), + ); + const bobId = await write( + vault, + DELEGATION_ONTOLOGY, + await sign( + vault, + DELEGATION_ONTOLOGY, + "@dir", + delegation(vault, { roleId }), + ), + ); + return { companyId, roleId, bobId }; + } + + const rejects = (promise: Promise, code: string) => + expect(promise).rejects.toMatchObject({ code }); + + describe("company", () => { + it("lets the creator set the first board", async () => { + const { companyId } = await setUp("@co-create"); + expect((await parsed("@co-create", companyId)).directors).toEqual([ + "@dir", + ]); + }); + + it("refuses unsigned, wrongly signed and outsider-signed boards", async () => { + const vault = "@co-bad"; + await rejects( + write(vault, COMPANY_ONTOLOGY, company(vault, ["@dir"])), + "UNSIGNED", + ); + const signed = await sign( + vault, + COMPANY_ONTOLOGY, + "@dir", + company(vault, ["@dir"]), + ); + await rejects( + write(vault, COMPANY_ONTOLOGY, { + ...signed, + directors: ["@mallory"], + }), + "BAD_AUTHORIZATION", + ); + await rejects( + write( + vault, + COMPANY_ONTOLOGY, + await sign( + vault, + COMPANY_ONTOLOGY, + "@mallory", + company(vault, ["@dir"]), + ), + ), + "NOT_AUTHORIZED", + ); + }); + + it("allows one board per vault, changed only by an existing director", async () => { + const vault = "@co-board"; + const { companyId } = await setUp(vault); + await rejects( + write( + vault, + COMPANY_ONTOLOGY, + await sign( + vault, + COMPANY_ONTOLOGY, + "@eve", + company(vault, ["@eve"]), + ), + ), + "COMPANY_EXISTS", + ); + await rejects( + write( + vault, + COMPANY_ONTOLOGY, + await sign(vault, COMPANY_ONTOLOGY, "@eve", { + ...company(vault, ["@dir", "@eve"]), + updatedAt: "2026-10-09T00:00:00.000Z", + }), + companyId, + ), + "NOT_AUTHORIZED", + ); + await write( + vault, + COMPANY_ONTOLOGY, + await sign(vault, COMPANY_ONTOLOGY, "@dir", { + ...company(vault, ["@dir", "@eve"]), + updatedAt: "2026-10-09T00:00:00.000Z", + }), + companyId, + ); + expect((await parsed(vault, companyId)).directors).toEqual([ + "@dir", + "@eve", + ]); + }); + }); + + describe("roles and delegations", () => { + it("needs a company and a director", async () => { + await rejects( + write( + "@no-co", + ROLE_ONTOLOGY, + await sign("@no-co", ROLE_ONTOLOGY, "@dir", role("@no-co")), + ), + "NO_COMPANY", + ); + const vault = "@rd-director"; + await setUp(vault); + await rejects( + write( + vault, + ROLE_ONTOLOGY, + await sign( + vault, + ROLE_ONTOLOGY, + "@bob", + role(vault, { createdBy: "@bob" }), + ), + ), + "NOT_AUTHORIZED", + ); + }); + + it("refuses core scopes and wider-than-role delegations", async () => { + const vault = "@rd-scopes"; + const { roleId } = await setUp(vault); + await rejects( + write( + vault, + ROLE_ONTOLOGY, + await sign( + vault, + ROLE_ONTOLOGY, + "@dir", + role(vault, { scopes: ["@w3ds:auth"] }), + ), + ), + "INVALID_RECORD", + ); + await rejects( + write( + vault, + DELEGATION_ONTOLOGY, + await sign( + vault, + DELEGATION_ONTOLOGY, + "@dir", + delegation(vault, { + roleId, + scopes: ["@esigner:invoice"], + }), + ), + ), + "INVALID_RECORD", + ); + }); + + it("lets a delegate re-delegate a narrower part, and nobody else", async () => { + const vault = "@rd-redelegate"; + const { bobId } = await setUp(vault); + const carol = delegation(vault, { + parentDelegationId: bobId, + delegateEName: "@carol", + grantedBy: "@bob", + scopes: [NDA], + mayRedelegate: false, + }); + await write( + vault, + DELEGATION_ONTOLOGY, + await sign(vault, DELEGATION_ONTOLOGY, "@bob", carol), + ); + await rejects( + write( + vault, + DELEGATION_ONTOLOGY, + await sign(vault, DELEGATION_ONTOLOGY, "@mallory", { + ...carol, + grantedBy: "@mallory", + }), + ), + "NOT_AUTHORIZED", + ); + }); + }); + + describe("single-use grants", () => { + it("refuses an authorization copied onto another record", async () => { + const vault = "@grant-copy"; + const { roleId } = await setUp(vault); + const signed = await sign( + vault, + DELEGATION_ONTOLOGY, + "@dir", + delegation(vault, { roleId, delegateEName: "@dan" }), + ); + await write(vault, DELEGATION_ONTOLOGY, signed); + await rejects( + write(vault, DELEGATION_ONTOLOGY, signed), + "REPLAYED_GRANT", + ); + }); + + it("refuses writing back an older signed state", async () => { + const vault = "@grant-rollback"; + await setUp(vault); + const v1 = await sign( + vault, + ROLE_ONTOLOGY, + "@dir", + role(vault, { title: "Signer" }), + ); + const id = await write(vault, ROLE_ONTOLOGY, v1); + const v2 = await sign(vault, ROLE_ONTOLOGY, "@dir", { + ...role(vault, { title: "Signer" }), + scopes: [NDA], + updatedAt: "2026-10-09T00:00:00.000Z", + }); + await write(vault, ROLE_ONTOLOGY, v2, id); + await rejects( + write(vault, ROLE_ONTOLOGY, v1, id), + "REPLAYED_GRANT", + ); + }); + + it("lets an identical re-send through", async () => { + const vault = "@grant-resend"; + const { roleId } = await setUp(vault); + const current = await parsed(vault, roleId); + await expect( + write(vault, ROLE_ONTOLOGY, current, roleId), + ).resolves.toBe(roleId); + }); + + it("frees the grant when the write fails", async () => { + const vault = "@grant-abort"; + const { roleId } = await setUp(vault); + const signed = await sign( + vault, + DELEGATION_ONTOLOGY, + "@dir", + delegation(vault, { roleId, delegateEName: "@fay" }), + ); + const ticket = await guard.beforeWrite(vault, { + ontology: DELEGATION_ONTOLOGY, + payload: signed, + }); + await guard.abortWrite(ticket); + await expect( + write(vault, DELEGATION_ONTOLOGY, signed), + ).resolves.toBeTruthy(); + }); + }); + + describe("revocation", () => { + it("lets the grantor or a director revoke, and nobody else", async () => { + const vault = "@revoke-who"; + const { bobId } = await setUp(vault); + const current = await parsed(vault, bobId); + const revoked = (by: string) => ({ + ...current, + status: "revoked", + revokedAt: T, + revokedBy: by, + revocationReason: "revoked", + }); + await rejects( + write( + vault, + DELEGATION_ONTOLOGY, + await sign( + vault, + DELEGATION_ONTOLOGY, + "@bob", + revoked("@bob"), + ), + bobId, + ), + "NOT_AUTHORIZED", + ); + await write( + vault, + DELEGATION_ONTOLOGY, + await sign(vault, DELEGATION_ONTOLOGY, "@dir", revoked("@dir")), + bobId, + ); + expect((await parsed(vault, bobId)).status).toBe("revoked"); + }); + + it("makes revocation final", async () => { + const vault = "@revoke-final"; + const { bobId } = await setUp(vault); + const current = await parsed(vault, bobId); + const revoked = { + ...current, + status: "revoked", + revokedAt: T, + revokedBy: "@dir", + revocationReason: "revoked", + }; + await write( + vault, + DELEGATION_ONTOLOGY, + await sign(vault, DELEGATION_ONTOLOGY, "@dir", revoked), + bobId, + ); + await rejects( + write( + vault, + DELEGATION_ONTOLOGY, + await sign(vault, DELEGATION_ONTOLOGY, "@dir", { + ...current, + updatedAt: "2026-10-10T00:00:00.000Z", + }), + bobId, + ), + "IMMUTABLE", + ); + }); + + it("cascades a revoked role down the whole chain", async () => { + const vault = "@cascade-role"; + const { roleId, bobId } = await setUp(vault); + const carolId = await write( + vault, + DELEGATION_ONTOLOGY, + await sign( + vault, + DELEGATION_ONTOLOGY, + "@bob", + delegation(vault, { + parentDelegationId: bobId, + delegateEName: "@carol", + grantedBy: "@bob", + scopes: [NDA], + mayRedelegate: false, + }), + ), + ); + const current = await parsed(vault, roleId); + await write( + vault, + ROLE_ONTOLOGY, + await sign(vault, ROLE_ONTOLOGY, "@dir", { + ...current, + status: "revoked", + revokedAt: T, + revokedBy: "@dir", + }), + roleId, + ); + for (const id of [bobId, carolId]) { + expect(await parsed(vault, id)).toMatchObject({ + status: "revoked", + revocationReason: "cascade", + revokedBy: "@dir", + }); + } + }); + + it("cascades narrowing only to what it no longer covers", async () => { + const vault = "@cascade-narrow"; + const { roleId, bobId } = await setUp(vault); + const ndaOnly = await write( + vault, + DELEGATION_ONTOLOGY, + await sign( + vault, + DELEGATION_ONTOLOGY, + "@dir", + delegation(vault, { + roleId, + delegateEName: "@gus", + scopes: [NDA], + }), + ), + ); + const current = await parsed(vault, roleId); + await write( + vault, + ROLE_ONTOLOGY, + await sign(vault, ROLE_ONTOLOGY, "@dir", { + ...current, + scopes: [NDA], + updatedAt: "2026-10-09T00:00:00.000Z", + }), + roleId, + ); + expect((await parsed(vault, bobId)).status).toBe("revoked"); + expect((await parsed(vault, ndaOnly)).status).toBe("active"); + }); + }); + + describe("other writes", () => { + it("refuses deleting a governed record", async () => { + const vault = "@other-delete"; + const { bobId } = await setUp(vault); + await rejects( + guard.assertNotGoverned(vault, bobId, "Deleting"), + "IMMUTABLE", + ); + }); + + it("ignores copies naming another company and unrelated ontologies", async () => { + const vault = "@other-copy"; + await expect( + write( + vault, + DELEGATION_ONTOLOGY, + delegation("@someone-else", { roleId: "r" }), + ), + ).resolves.toBeTruthy(); + await expect( + write(vault, SHAREHOLDING_ONTOLOGY, { + companyEName: "@someone-else", + }), + ).resolves.toBeTruthy(); + }); + }); +}); diff --git a/infrastructure/evault-core/src/core/delegation/delegation-write-guard.ts b/infrastructure/evault-core/src/core/delegation/delegation-write-guard.ts new file mode 100644 index 000000000..e4ad7f539 --- /dev/null +++ b/infrastructure/evault-core/src/core/delegation/delegation-write-guard.ts @@ -0,0 +1,664 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + COMPANY_ONTOLOGY, + canonicalJson, + checkGrantAuthorization, + checkScopes, + DELEGATION_ONTOLOGY, + type DelegationRecord, + evaluateDelegation, + isScopeSubset, + isWindowWithin, + ROLE_ONTOLOGY, + type RoleRecord, + SHAREHOLDING_ONTOLOGY, + type VerifySignature, +} from "@metastate-foundation/delegation"; +import type { AwarenessWriteContext, DbService } from "../db/db.service"; + +/** Records whose writes decide who may sign for a company. */ +export const GOVERNED_ONTOLOGIES = [ + COMPANY_ONTOLOGY, + ROLE_ONTOLOGY, + DELEGATION_ONTOLOGY, + SHAREHOLDING_ONTOLOGY, +]; + +export type DelegationWriteErrorCode = + | "UNSIGNED" + | "BAD_AUTHORIZATION" + | "NOT_AUTHORIZED" + | "REPLAYED_GRANT" + | "NO_COMPANY" + | "COMPANY_EXISTS" + | "INVALID_RECORD" + | "IMMUTABLE"; + +export class DelegationWriteError extends Error { + constructor( + readonly code: DelegationWriteErrorCode, + message: string, + ) { + super(message); + this.name = "DelegationWriteError"; + } +} + +type Record_ = Record; + +type Existing = { id: string; ontology: string; parsed: Record_ }; + +/** What a write intends; `id` is known for updates and id-preserving creates. */ +export type IntendedWrite = { + ontology: string; + payload: Record_; + id?: string; +}; + +/** Handed back by `before*` and passed to `after` once the write landed. */ +export type GuardTicket = { + governed: boolean; + claimToken?: string; + previous?: Existing | null; + next?: { ontology: string; parsed: Record_ }; +}; + +const UNGOVERNED: GuardTicket = { governed: false }; + +/** + * Enforces company signing authority on writes to a company's own eVault. + * + * A Company with `directors`, and every Role, Delegation and Shareholding + * naming the vault, must carry a `w3ds-grant/v1` authorization from someone + * entitled to make that change: a director, or for a re-delegation the parent + * delegation's delegate. Each signed grant is single-use, so an authorization + * cannot be copied onto another record or replayed to undo a later change. + * Governed records are never deleted, rolled back or edited field by field; + * they are revoked, and revoking or narrowing one revokes what was handed on + * from it. + * + * Copies of these records in other vaults carry no authority (verifiers only + * read the company's own vault) and pass through untouched. + */ +export class DelegationWriteGuard { + constructor( + private db: DbService, + private verify: VerifySignature, + ) {} + + /** Call before any create or update of a whole MetaEnvelope. */ + async beforeWrite( + eName: string, + write: IntendedWrite, + ): Promise { + const previous = write.id ? await this.load(write.id, eName) : null; + const next = { ontology: write.ontology, parsed: write.payload ?? {} }; + + const wasGoverned = previous ? this.governs(eName, previous) : false; + const isGoverned = this.governs(eName, next); + if (!wasGoverned && !isGoverned) return UNGOVERNED; + + if (previous && wasGoverned && previous.ontology !== next.ontology) { + throw new DelegationWriteError( + "IMMUTABLE", + "a governed record cannot change ontology", + ); + } + if (wasGoverned && !isGoverned) { + throw new DelegationWriteError( + "IMMUTABLE", + "a governed record cannot leave the company", + ); + } + if ( + previous && + canonicalJson(previous.parsed) === canonicalJson(next.parsed) + ) { + // An identical re-send (e.g. a retry) changes nothing. + return { governed: true, previous, next }; + } + + const signer = await this.checkSignature(eName, next); + switch (next.ontology) { + case COMPANY_ONTOLOGY: + await this.checkCompany(eName, previous, next.parsed, signer); + break; + case ROLE_ONTOLOGY: + await this.checkRole(eName, previous, next.parsed, signer); + break; + case DELEGATION_ONTOLOGY: + await this.checkDelegation( + eName, + previous, + next.parsed, + signer, + ); + break; + case SHAREHOLDING_ONTOLOGY: + await this.requireDirector(eName, signer); + break; + } + + const claimToken = await this.claimGrant( + eName, + next.parsed.authorization.signedPayload, + previous?.id ?? null, + ); + return { governed: true, claimToken, previous, next }; + } + + /** Call once the write landed, with the id it landed under. */ + async afterWrite( + eName: string, + ticket: GuardTicket, + id: string, + awareness?: AwarenessWriteContext, + ): Promise { + if (!ticket.governed || !ticket.next) return; + if (ticket.claimToken) await this.bindGrant(ticket.claimToken, id); + const signer = ticket.next.parsed.authorization?.signerEName ?? null; + if ( + ticket.next.ontology === ROLE_ONTOLOGY || + ticket.next.ontology === DELEGATION_ONTOLOGY + ) { + await this.cascade(eName, id, signer, awareness); + } + } + + /** Call if the write failed, so the signed grant can be used again. */ + async abortWrite(ticket: GuardTicket): Promise { + if (!ticket.claimToken) return; + await this.db.runQuery( + `MATCH (g:DelegationGrant { claimToken: $token }) + WHERE g.metaEnvelopeId IS NULL + DELETE g`, + { token: ticket.claimToken }, + ); + } + + /** Deletes, rollbacks and single-field edits are refused on governed records. */ + async assertNotGoverned( + eName: string, + id: string, + action: string, + ): Promise { + const existing = await this.load(id, eName); + if (existing && this.governs(eName, existing)) { + throw new DelegationWriteError( + "IMMUTABLE", + `${action} is not allowed on company authority records; revoke it instead`, + ); + } + } + + // ------------------------------------------------------------------ + + private governs( + eName: string, + record: { ontology: string; parsed: Record_ }, + ): boolean { + const r = record.parsed ?? {}; + switch (record.ontology) { + case COMPANY_ONTOLOGY: + return r.eName === eName && r.directors !== undefined; + case ROLE_ONTOLOGY: + case DELEGATION_ONTOLOGY: + case SHAREHOLDING_ONTOLOGY: + return r.companyEName === eName; + default: + return false; + } + } + + private async checkSignature( + eName: string, + next: { ontology: string; parsed: Record_ }, + ): Promise { + if (!next.parsed.authorization) { + throw new DelegationWriteError( + "UNSIGNED", + "company authority records must carry an authorization", + ); + } + const problem = await checkGrantAuthorization( + next.ontology, + eName, + next.parsed, + this.verify, + ); + if (problem) { + throw new DelegationWriteError( + "BAD_AUTHORIZATION", + `authorization rejected: ${problem.code}`, + ); + } + return next.parsed.authorization.signerEName; + } + + private async checkCompany( + eName: string, + previous: Existing | null, + next: Record_, + signer: string, + ): Promise { + const directors = next.directors; + if (!Array.isArray(directors) || directors.length === 0) { + throw new DelegationWriteError( + "INVALID_RECORD", + "a company needs at least one director", + ); + } + const current = await this.governingCompany(eName); + if (current && current.id !== previous?.id) { + throw new DelegationWriteError( + "COMPANY_EXISTS", + "this eVault already has a company with directors", + ); + } + // Changing the board takes an existing director; the first board is + // set by its own first director, the creator. + const entitled = current + ? (current.parsed.directors as string[]) + : directors; + if (!entitled.includes(signer)) { + throw new DelegationWriteError( + "NOT_AUTHORIZED", + current + ? "only an existing director may change the company" + : "the creator must be one of the directors", + ); + } + } + + private async checkRole( + eName: string, + previous: Existing | null, + next: Record_, + signer: string, + ): Promise { + await this.requireDirector(eName, signer); + this.requireScopes(next.scopes); + if (previous) { + this.requireLive(previous.parsed); + this.requireUnchanged(previous.parsed, next, [ + "companyEName", + "createdBy", + ]); + this.requireRevocationBy(next, signer); + } else { + this.requireActive(next); + if (next.createdBy !== signer) { + throw new DelegationWriteError( + "INVALID_RECORD", + "createdBy must be the signing director", + ); + } + } + } + + private async checkDelegation( + eName: string, + previous: Existing | null, + next: Record_, + signer: string, + ): Promise { + this.requireScopes(next.scopes); + const hasRole = typeof next.roleId === "string" && next.roleId !== ""; + const hasParent = + typeof next.parentDelegationId === "string" && + next.parentDelegationId !== ""; + if (hasRole === hasParent) { + throw new DelegationWriteError( + "INVALID_RECORD", + "a delegation needs exactly one of roleId or parentDelegationId", + ); + } + + if (previous) { + this.requireLive(previous.parsed); + this.requireUnchanged(previous.parsed, next, [ + "companyEName", + "delegateEName", + "roleId", + "parentDelegationId", + "grantedBy", + ]); + if (next.status === "revoked") { + // Revoking takes a director or whoever granted it. + this.requireRevocationBy(next, signer); + if (signer !== previous.parsed.grantedBy) { + await this.requireDirector(eName, signer); + } + return; + } + } else { + this.requireActive(next); + } + + if (next.grantedBy !== signer) { + throw new DelegationWriteError( + "INVALID_RECORD", + "grantedBy must be the signer", + ); + } + + if (hasRole) { + await this.requireDirector(eName, signer); + const role = await this.load(next.roleId, eName); + if (!role || role.ontology !== ROLE_ONTOLOGY) { + throw new DelegationWriteError( + "INVALID_RECORD", + "role not found in this eVault", + ); + } + const r = role.parsed as RoleRecord; + if (r.companyEName !== eName || r.status !== "active") { + throw new DelegationWriteError( + "INVALID_RECORD", + "role is not active for this company", + ); + } + this.requireNarrowing(next, r, r.mayRedelegate, "role"); + return; + } + + const parentId = next.parentDelegationId as string; + const chain = await evaluateDelegation( + parentId, + this.chainSource(eName), + ); + if (!chain.ok) { + throw new DelegationWriteError( + "INVALID_RECORD", + `parent delegation is not usable: ${chain.code}`, + ); + } + const parent = (await this.load(parentId, eName)) + ?.parsed as DelegationRecord; + if (signer !== parent.delegateEName) { + throw new DelegationWriteError( + "NOT_AUTHORIZED", + "only the parent's delegate may re-delegate it", + ); + } + if (!parent.mayRedelegate) { + throw new DelegationWriteError( + "NOT_AUTHORIZED", + "the parent does not allow re-delegation", + ); + } + this.requireNarrowing(next, parent, parent.mayRedelegate, "parent"); + } + + private requireNarrowing( + next: Record_, + parent: { + scopes: string[]; + validFrom?: string | null; + validUntil?: string | null; + status: "active" | "revoked"; + }, + parentMayRedelegate: boolean, + what: string, + ): void { + if (!isScopeSubset(next.scopes, parent.scopes)) { + throw new DelegationWriteError( + "INVALID_RECORD", + `scopes exceed the ${what}`, + ); + } + if (!isWindowWithin(next as DelegationRecord, parent)) { + throw new DelegationWriteError( + "INVALID_RECORD", + `validity extends beyond the ${what}`, + ); + } + if (next.mayRedelegate && !parentMayRedelegate) { + throw new DelegationWriteError( + "INVALID_RECORD", + `the ${what} does not allow re-delegation`, + ); + } + } + + private requireScopes(scopes: unknown): void { + const problem = checkScopes(scopes); + if (problem) { + throw new DelegationWriteError( + "INVALID_RECORD", + `bad scopes: ${problem.code}`, + ); + } + } + + private requireActive(next: Record_): void { + if (next.status !== "active") { + throw new DelegationWriteError( + "INVALID_RECORD", + "a new record must be active", + ); + } + } + + /** Revocation is final. */ + private requireLive(previous: Record_): void { + if (previous.status === "revoked") { + throw new DelegationWriteError( + "IMMUTABLE", + "a revoked record cannot be changed", + ); + } + } + + private requireRevocationBy(next: Record_, signer: string): void { + if (next.status === "revoked" && next.revokedBy !== signer) { + throw new DelegationWriteError( + "INVALID_RECORD", + "revokedBy must be the signer", + ); + } + } + + private requireUnchanged( + previous: Record_, + next: Record_, + fields: string[], + ): void { + for (const field of fields) { + if (canonicalJson(previous[field]) !== canonicalJson(next[field])) { + throw new DelegationWriteError( + "IMMUTABLE", + `${field} cannot change`, + ); + } + } + } + + private async requireDirector( + eName: string, + signer: string, + ): Promise { + const company = await this.governingCompany(eName); + if (!company) { + throw new DelegationWriteError( + "NO_COMPANY", + "this eVault has no company with directors", + ); + } + if (!(company.parsed.directors as string[]).includes(signer)) { + throw new DelegationWriteError( + "NOT_AUTHORIZED", + "only a director may do this", + ); + } + } + + /** The one Company record in the vault that carries directors. */ + private async governingCompany(eName: string): Promise { + const companies = await this.db.findMetaEnvelopesByOntology( + COMPANY_ONTOLOGY, + eName, + ); + const found = companies.find( + (c) => + c.parsed?.eName === eName && Array.isArray(c.parsed?.directors), + ); + return found + ? { id: found.id, ontology: found.ontology, parsed: found.parsed } + : null; + } + + private chainSource(eName: string) { + return { + role: async (id: string) => { + const r = await this.load(id, eName); + return r?.ontology === ROLE_ONTOLOGY + ? (r.parsed as RoleRecord) + : null; + }, + delegation: async (id: string) => { + const r = await this.load(id, eName); + return r?.ontology === DELEGATION_ONTOLOGY + ? (r.parsed as DelegationRecord) + : null; + }, + }; + } + + private async load(id: string, eName: string): Promise { + const found = await this.db.findMetaEnvelopeById(id, eName); + return found + ? { id: found.id, ontology: found.ontology, parsed: found.parsed } + : null; + } + + // ---- single-use grants ------------------------------------------- + + /** + * Claims a signed grant for one record. A grant already claimed by any + * record, even the same one, is a replay: the guard lets identical + * re-sends through before reaching here, so a claimed grant can only be + * an older state being written back or a copy onto another record. + */ + private async claimGrant( + eName: string, + signedPayload: string, + metaEnvelopeId: string | null, + ): Promise { + const token = randomUUID(); + const result = await this.db.runQuery( + `MERGE (g:DelegationGrant { eName: $eName, payloadSha256: $hash }) + ON CREATE SET g.claimToken = $token, g.metaEnvelopeId = $id, g.createdAt = $now + RETURN g.claimToken = $token AS claimed`, + { + eName, + hash: createHash("sha256").update(signedPayload).digest("hex"), + token, + id: metaEnvelopeId, + now: Date.now(), + }, + ); + if (result.records[0]?.get("claimed") !== true) { + throw new DelegationWriteError( + "REPLAYED_GRANT", + "this authorization has already been used", + ); + } + return token; + } + + private async bindGrant(token: string, id: string): Promise { + await this.db.runQuery( + `MATCH (g:DelegationGrant { claimToken: $token }) + SET g.metaEnvelopeId = $id`, + { token, id }, + ); + } + + // ---- cascade ----------------------------------------------------- + + /** + * Revokes every live delegation handed on from a role or delegation that + * no longer covers it: because the parent was revoked, or because it was + * narrowed below the child's scopes, window or re-delegation right. Each + * revoked child cascades in turn. + */ + private async cascade( + eName: string, + parentId: string, + revokedBy: string | null, + awareness?: AwarenessWriteContext, + ): Promise { + const queue = [parentId]; + const seen = new Set(); + while (queue.length > 0) { + const id = queue.shift() as string; + if (seen.has(id)) continue; + seen.add(id); + + const parent = await this.load(id, eName); + if (!parent) continue; + const p = parent.parsed; + const isRole = parent.ontology === ROLE_ONTOLOGY; + + for (const childId of await this.childrenOf(eName, id, isRole)) { + const child = await this.load(childId, eName); + if (!child || child.parsed.status !== "active") continue; + const c = child.parsed; + const stillCovered = + p.status === "active" && + isScopeSubset(c.scopes, p.scopes) && + isWindowWithin(c as DelegationRecord, p as RoleRecord) && + (isRole + ? !c.mayRedelegate || p.mayRedelegate + : p.mayRedelegate); + if (stillCovered) continue; + + const now = new Date().toISOString(); + const existing = await this.db.findMetaEnvelopeById( + childId, + eName, + ); + await this.db.updateMetaEnvelopeById( + childId, + { + ontology: DELEGATION_ONTOLOGY, + payload: { + ...c, + status: "revoked", + revokedAt: now, + revokedBy: revokedBy ?? c.grantedBy, + revocationReason: "cascade", + updatedAt: now, + }, + acl: existing?.acl ?? ["*"], + _acl: existing?._acl, + }, + existing?.acl ?? ["*"], + eName, + awareness, + ); + queue.push(childId); + } + } + } + + private async childrenOf( + eName: string, + parentId: string, + parentIsRole: boolean, + ): Promise { + const result = await this.db.runQuery( + `MATCH (m:MetaEnvelope { eName: $eName, ontology: $ontology })-[:LINKS_TO]->(e:Envelope { ontology: $field }) + WHERE e.value = $parentId + RETURN m.id AS id`, + { + eName, + ontology: DELEGATION_ONTOLOGY, + field: parentIsRole ? "roleId" : "parentDelegationId", + parentId, + }, + ); + return result.records.map((r) => r.get("id")); + } +} diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 7de803a87..08e8f51c8 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -36,11 +36,18 @@ import { SecurityQuestionService } from "../../services/SecurityQuestionService" import { DeviceToken } from "../../entities/DeviceToken"; import { SecurityAnswerAttempt } from "../../entities/SecurityAnswerAttempt"; import { AppDataSource } from "../../config/database"; +import { verifySignature } from "signature-validator"; +import { + DelegationWriteError, + DelegationWriteGuard, + type IntendedWrite, +} from "../delegation/delegation-write-guard"; export class GraphQLServer { private db: DbService; private accessGuard: VaultAccessGuard; private bindingDocumentService: BindingDocumentService; + private delegationGuard: DelegationWriteGuard; private schema: GraphQLSchema = createSchema({ typeDefs, resolvers: {}, @@ -66,6 +73,7 @@ export class GraphQLServer { new GroupMembershipService(db), ); this.bindingDocumentService = new BindingDocumentService(db); + this.delegationGuard = new DelegationWriteGuard(db, verifyAgainstRegistry); this.evaultPublicKey = evaultPublicKey || process.env.EVAULT_PUBLIC_KEY || null; this.evaultW3ID = evaultW3ID || process.env.W3ID || null; @@ -111,6 +119,36 @@ export class GraphQLServer { }; } + /** + * Runs a whole-MetaEnvelope write under the delegation guard: company + * authority records are checked before the write, and a revoked or + * narrowed grant cascades once it has landed. + */ + private async guardedWrite( + context: VaultContext, + write: IntendedWrite, + run: () => Promise, + idOf: (result: R) => string, + skipAwareness = false, + ): Promise { + const eName = context.eName as string; + const ticket = await this.delegationGuard.beforeWrite(eName, write); + let result: R; + try { + result = await run(); + } catch (error) { + await this.delegationGuard.abortWrite(ticket); + throw error; + } + await this.delegationGuard.afterWrite( + eName, + ticket, + idOf(result), + this.awarenessContext(context, skipAwareness), + ); + return result; + } + /** * The party a write is recorded against: the user a platform declared it * acts for, else the token's own subject when that is an eName. This is @@ -375,16 +413,22 @@ export class GraphQLServer { } try { - const result = await this.db.storeMetaEnvelope( - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - _acl: aclBlockFromInput(input._acl), - }, - input.acl, - context.eName, - this.awarenessContext(context), + const result = await this.guardedWrite( + context, + { ontology: input.ontology, payload: input.payload }, + () => + this.db.storeMetaEnvelope( + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + _acl: aclBlockFromInput(input._acl), + }, + input.acl, + context.eName as string, + this.awarenessContext(context), + ), + (r) => r.metaEnvelope.id, ); // Build parsed from actual written envelopes, not input @@ -472,7 +516,10 @@ export class GraphQLServer { error instanceof Error ? error.message : "Failed to create MetaEnvelope", - code: "CREATE_FAILED", + code: + error instanceof DelegationWriteError + ? error.code + : "CREATE_FAILED", }, ], }; @@ -512,17 +559,23 @@ export class GraphQLServer { } try { - const result = await this.db.updateMetaEnvelopeById( - id, - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - _acl: aclBlockFromInput(input._acl), - }, - input.acl, - context.eName, - this.awarenessContext(context), + const result = await this.guardedWrite( + context, + { ontology: input.ontology, payload: input.payload, id }, + () => + this.db.updateMetaEnvelopeById( + id, + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + _acl: aclBlockFromInput(input._acl), + }, + input.acl, + context.eName as string, + this.awarenessContext(context), + ), + () => id, ); // Build parsed from actual written envelopes, not input @@ -583,7 +636,10 @@ export class GraphQLServer { error instanceof Error ? error.message : "Failed to update MetaEnvelope", - code: "UPDATE_FAILED", + code: + error instanceof DelegationWriteError + ? error.code + : "UPDATE_FAILED", }, ], }; @@ -613,6 +669,11 @@ export class GraphQLServer { } try { + await this.delegationGuard.assertNotGoverned( + context.eName, + id, + "Rolling back", + ); const result = await this.db.rollbackMetaEnvelope( id, context.eName, @@ -723,6 +784,11 @@ export class GraphQLServer { }; } + await this.delegationGuard.assertNotGoverned( + context.eName, + id, + "Deleting", + ); await this.db.deleteMetaEnvelope( id, context.eName, @@ -832,22 +898,34 @@ export class GraphQLServer { for (const input of inputs) { try { - const result = - await this.db.storeMetaEnvelopeWithId( - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - _acl: aclBlockFromInput(input._acl), - }, - input.acl, - context.eName, - input.id, // Preserve ID if provided - this.awarenessContext( - context, - shouldSkipWebhooks, + const result = await this.guardedWrite( + context, + { + ontology: input.ontology, + payload: input.payload, + id: input.id, + }, + () => + this.db.storeMetaEnvelopeWithId( + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + _acl: aclBlockFromInput( + input._acl, + ), + }, + input.acl, + context.eName as string, + input.id, // Preserve ID if provided + this.awarenessContext( + context, + shouldSkipWebhooks, + ), ), - ); + (r) => r.metaEnvelope.id, + shouldSkipWebhooks, + ); results.push({ id: result.metaEnvelope.id, @@ -1037,7 +1115,10 @@ export class GraphQLServer { error instanceof Error ? error.message : "Failed to create binding document", - code: "CREATE_FAILED", + code: + error instanceof DelegationWriteError + ? error.code + : "CREATE_FAILED", }, ], }; @@ -1270,16 +1351,22 @@ export class GraphQLServer { if (!context.eName) { throw new Error("X-ENAME header is required"); } - const result = await this.db.storeMetaEnvelope( - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - _acl: aclBlockFromInput(input._acl), - }, - input.acl, - context.eName, - this.awarenessContext(context), + const result = await this.guardedWrite( + context, + { ontology: input.ontology, payload: input.payload }, + () => + this.db.storeMetaEnvelope( + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + _acl: aclBlockFromInput(input._acl), + }, + input.acl, + context.eName as string, + this.awarenessContext(context), + ), + (r) => r.metaEnvelope.id, ); // Add parsed field to metaEnvelope for GraphQL response @@ -1558,17 +1645,23 @@ export class GraphQLServer { throw new Error("X-ENAME header is required"); } try { - const result = await this.db.updateMetaEnvelopeById( - id, - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - _acl: aclBlockFromInput(input._acl), - }, - input.acl, - context.eName, - this.awarenessContext(context), + const result = await this.guardedWrite( + context, + { ontology: input.ontology, payload: input.payload, id }, + () => + this.db.updateMetaEnvelopeById( + id, + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + _acl: aclBlockFromInput(input._acl), + }, + input.acl, + context.eName as string, + this.awarenessContext(context), + ), + () => id, ); // Log envelope operation best-effort (do not fail mutation) @@ -1621,6 +1714,11 @@ export class GraphQLServer { id, context.eName, ); + await this.delegationGuard.assertNotGoverned( + context.eName, + id, + "Deleting", + ); await this.db.deleteMetaEnvelope( id, context.eName, @@ -1667,6 +1765,13 @@ export class GraphQLServer { envelopeId, context.eName, ); + if (metaInfo) { + await this.delegationGuard.assertNotGoverned( + context.eName, + metaInfo.metaEnvelopeId, + "Editing a single field", + ); + } await this.db.updateEnvelopeValue( envelopeId, newValue, @@ -1758,3 +1863,28 @@ export class GraphQLServer { return yoga; } } + +/** + * Checks a grant signature against the signer's Registry-bound keys. Without a + * Registry nothing can be verified, so every company authority write fails. + */ +async function verifyAgainstRegistry( + eName: string, + payload: string, + signature: string, +): Promise { + const registryBaseUrl = + process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + if (!registryBaseUrl) return false; + try { + const result = await verifySignature({ + eName, + signature, + payload, + registryBaseUrl, + }); + return result.valid; + } catch { + return false; + } +} diff --git a/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts index 9b598cd0e..de9aa2c5c 100644 --- a/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts @@ -145,6 +145,42 @@ describe("Idiomatic GraphQL API", () => { }); }); + describe("company authority records", () => { + it("refuses an unsigned Role in the company's own vault", async () => { + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { id } + errors { message code } + } + } + `; + const result = await makeGraphQLRequest( + server, + mutation, + { + input: { + ontology: "65fd0e21-34b9-43ef-be76-c5b39727010e", + payload: { + companyEName: evault.w3id, + title: "Head of Finance", + scopes: ["@esigner:nda"], + mayRedelegate: false, + status: "active", + createdBy: "@someone", + createdAt: "2026-10-08T12:00:00.000Z", + updatedAt: "2026-10-08T12:00:00.000Z", + }, + acl: ["*"], + }, + }, + getAuthHeaders(), + ); + expect(result.createMetaEnvelope.metaEnvelope).toBeNull(); + expect(result.createMetaEnvelope.errors[0].code).toBe("UNSIGNED"); + }); + }); + describe("metaEnvelope query", () => { let createdId: string; diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index c8b5a909d..82942e5a3 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -166,6 +166,15 @@ const initializeEVault = async ( console.warn("Failed to create awareness outbox indexes:", error); } + try { + const { createDelegationGrantConstraint } = await import( + "./core/db/migrations/add-delegation-grant-constraint" + ); + await createDelegationGrantConstraint(driver); + } catch (error) { + console.warn("Failed to create delegation grant constraint:", error); + } + try { const { createMetaEnvelopeVersionIndexes } = await import( "./core/db/migrations/add-metaenvelope-version-indexes" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bb941518..90cacef5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -505,6 +505,9 @@ importers: '@fastify/swagger-ui': specifier: ^3.0.0 version: 3.1.0 + '@metastate-foundation/delegation': + specifier: workspace:* + version: link:../../packages/delegation '@types/multer': specifier: ^1.4.13 version: 1.4.13