From 79de2f6d9c3f828e20a8bda2a9dac8565bf72c90 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 14:59:55 +0800 Subject: [PATCH 1/7] feat: add the granular access control engine --- .../evault-core/src/core/acl/acl.spec.ts | 495 ++++++++++++++++++ .../evault-core/src/core/acl/acl.ts | 364 +++++++++++++ .../evault-core/src/core/acl/index.ts | 33 ++ .../evault-core/src/core/acl/storage.ts | 32 ++ .../evault-core/src/core/acl/types.ts | 125 +++++ 5 files changed, 1049 insertions(+) create mode 100644 infrastructure/evault-core/src/core/acl/acl.spec.ts create mode 100644 infrastructure/evault-core/src/core/acl/acl.ts create mode 100644 infrastructure/evault-core/src/core/acl/index.ts create mode 100644 infrastructure/evault-core/src/core/acl/storage.ts create mode 100644 infrastructure/evault-core/src/core/acl/types.ts diff --git a/infrastructure/evault-core/src/core/acl/acl.spec.ts b/infrastructure/evault-core/src/core/acl/acl.spec.ts new file mode 100644 index 000000000..769d2cdd3 --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/acl.spec.ts @@ -0,0 +1,495 @@ +import { describe, expect, it } from "vitest"; +import { + emptyAclBlock, + evaluate, + fromLegacyAcl, + mostSpecificGrant, + normalizeAclBlock, + resolveAclBlock, + validatePerms, +} from "./acl"; +import type { + AclBlock, + Condition, + ConditionEvaluator, + Principal, +} from "./types"; +import { Permission } from "./types"; + +const USER = "@7b9c2e1a-4f30-4c5e-9a21-d8e0f1a2b3c4"; +const PLATFORM = "@2d4f6a8b-1c3e-4d5f-8a9b-0c1d2e3f4a5b"; +const BAD_PLATFORM = "@platform-bad1"; +const GROUP = "@9f0e1d2c-3b4a-5968-7766-554433221100"; +const EREP = "@1a1a1a1a-0000-0000-0000-000000000001"; +const SEC = "@2b2b2b2b-0000-0000-0000-000000000002"; + +const user = (over: Partial = {}): Principal => ({ + ename: USER, + kind: "user", + ...over, +}); +const platform = ( + ename = PLATFORM, + over: Partial = {}, +): Principal => ({ + ename, + kind: "platform", + ...over, +}); + +const block = (over: Partial = {}): AclBlock => ({ + ...emptyAclBlock(), + ...over, +}); + +/** Answers conditions from a flat table of ontology eName -> score. */ +const scores = ( + table: Record>, +): ConditionEvaluator => ({ + async passes(condition: Condition, principal: Principal) { + const value = table[principal.ename]?.[condition.ontology]; + if (typeof value !== "number") return false; + switch (condition.op) { + case ">=": + return value >= condition.value; + case ">": + return value > condition.value; + case "<=": + return value <= condition.value; + case "<": + return value < condition.value; + case "==": + return value === condition.value; + } + }, +}); + +const cond = ( + ontology: string, + op: Condition["op"], + value: number, + path = "$.score", +): Condition => ({ ontology, path, op, value }); + +describe("permission bits", () => { + it("rejects reserved bits on a write", () => { + expect(() => validatePerms(0x10)).toThrow(/reserved/); + expect(() => validatePerms(0xff)).toThrow(/reserved/); + }); + + it("accepts the documented combinations", () => { + expect(validatePerms(0x0f)).toBe(Permission.ALL); + expect(validatePerms(0x01)).toBe(Permission.READ); + // Read plus add-only: create without update. + expect(validatePerms(0x03)).toBe(Permission.READ | Permission.CREATE); + }); + + it("strips reserved bits from stored data rather than trusting them", () => { + const normalized = normalizeAclBlock({ + grants: [{ ename: USER, perms: 0xf1 }], + }); + expect(normalized.grants[0].perms).toBe(Permission.READ); + }); + + it("treats a 0x00 grant as no grant", () => { + const normalized = normalizeAclBlock({ + grants: [{ ename: USER, perms: 0x00 }], + }); + expect(normalized.grants).toEqual([]); + }); +}); + +describe("evaluate: action validation", () => { + it("rejects an action that is not exactly one bit", async () => { + await expect(evaluate(block(), user(), 0x03)).rejects.toThrow( + /exactly one/, + ); + await expect(evaluate(block(), user(), 0x00)).rejects.toThrow( + /exactly one/, + ); + }); +}); + +describe("evaluate: step 2, most specific grant wins", () => { + // Normative example: a group grant of READ+UPDATE and a direct user grant of + // READ leave that user with READ only. + const acl = block({ + grants: [ + { ename: GROUP, perms: 0x05 }, + { ename: USER, perms: 0x01 }, + ], + }); + const member = user({ groups: [GROUP] }); + + it("allows the action the specific grant carries", async () => { + const decision = await evaluate(acl, member, Permission.READ); + expect(decision).toMatchObject({ + allowed: true, + reason: "grant", + perms: 0x01, + }); + }); + + it("does not union the less specific group grant into it", async () => { + const decision = await evaluate(acl, member, Permission.UPDATE); + expect(decision).toMatchObject({ allowed: false, reason: "grant" }); + }); + + it("still applies the group grant to a member with no direct grant", async () => { + const other = { + ename: "@someone-else", + kind: "user" as const, + groups: [GROUP], + }; + const decision = await evaluate(acl, other, Permission.UPDATE); + expect(decision.allowed).toBe(true); + }); + + it("ranks a user grant above a platform grant above a group grant", () => { + const principal = user({ platform: PLATFORM, groups: [GROUP] }); + expect( + mostSpecificGrant( + [ + { ename: GROUP, perms: 0x0f }, + { ename: PLATFORM, perms: 0x07 }, + { ename: USER, perms: 0x01 }, + ], + principal, + ), + ).toMatchObject({ perms: 0x01 }); + + expect( + mostSpecificGrant( + [ + { ename: GROUP, perms: 0x0f }, + { ename: PLATFORM, perms: 0x07 }, + ], + principal, + ), + ).toMatchObject({ perms: 0x07 }); + }); + + it("unions grants tied at the same specificity", () => { + const principal = user({ groups: ["@group-a", "@group-b"] }); + expect( + mostSpecificGrant( + [ + { ename: "@group-a", perms: 0x01 }, + { ename: "@group-b", perms: 0x04 }, + ], + principal, + ), + ).toMatchObject({ perms: 0x05 }); + }); + + it("does not fall through to the ontology when a grant exists but lacks the action", async () => { + const acl = block({ + grants: [{ ename: PLATFORM, perms: 0x01 }], + default_perms: Permission.ALL, + require: [[]], + }); + const decision = await evaluate(acl, platform(), Permission.DELETE); + expect(decision).toMatchObject({ allowed: false, reason: "grant" }); + }); +}); + +describe("evaluate: step 1, denials always win", () => { + it("refuses a party that is both granted and denied by name", async () => { + // Normative example: a grant and a denial naming the same platform. + const acl = block({ + grants: [{ ename: PLATFORM, perms: Permission.READ }], + denials: { enames: [PLATFORM], conditions: [] }, + }); + const decision = await evaluate(acl, platform(), Permission.READ); + expect(decision).toMatchObject({ + allowed: false, + reason: "denied_by_ename", + }); + }); + + it("denies through the platform acting for a user", async () => { + const acl = block({ + grants: [{ ename: USER, perms: Permission.ALL }], + denials: { enames: [BAD_PLATFORM], conditions: [] }, + }); + const decision = await evaluate( + acl, + user({ platform: BAD_PLATFORM }), + Permission.READ, + ); + expect(decision).toMatchObject({ + allowed: false, + reason: "denied_by_ename", + }); + }); + + it("denies through a group the party belongs to", async () => { + const acl = block({ + grants: [{ ename: USER, perms: Permission.ALL }], + denials: { enames: [GROUP], conditions: [] }, + }); + const decision = await evaluate( + acl, + user({ groups: [GROUP] }), + Permission.READ, + ); + expect(decision).toMatchObject({ + allowed: false, + reason: "denied_by_ename", + }); + }); + + it("denies a party that fails a deny condition", async () => { + // A deny condition removes access from anyone who does not clear it. + const acl = block({ + grants: [{ ename: PLATFORM, perms: Permission.ALL }], + denials: { enames: [], conditions: [cond(EREP, ">=", 60)] }, + }); + const evaluator = scores({ [PLATFORM]: { [EREP]: 20 } }); + const decision = await evaluate( + acl, + platform(), + Permission.READ, + evaluator, + ); + expect(decision).toMatchObject({ + allowed: false, + reason: "denied_by_condition", + }); + }); + + it("lets a party that clears the deny condition through to its grant", async () => { + const acl = block({ + grants: [{ ename: PLATFORM, perms: Permission.ALL }], + denials: { enames: [], conditions: [cond(EREP, ">=", 60)] }, + }); + const evaluator = scores({ [PLATFORM]: { [EREP]: 72 } }); + const decision = await evaluate( + acl, + platform(), + Permission.READ, + evaluator, + ); + expect(decision).toMatchObject({ allowed: true, reason: "grant" }); + }); +}); + +describe("evaluate: step 3, the ontology groups", () => { + // Normative example: clear security AND reputation together, or clear a + // higher reputation bar alone. + const acl = block({ + grants: [{ ename: PLATFORM, perms: 0x01 }], + denials: { enames: [BAD_PLATFORM], conditions: [] }, + default_perms: 0x01, + require: [ + [cond(SEC, ">=", 80), cond(EREP, ">=", 60)], + [cond(EREP, ">=", 90)], + ], + }); + + it("runs the end-to-end example", async () => { + const unnamedA = "@platform-unnamed-a"; + const unnamedB = "@platform-unnamed-b"; + const evaluator = scores({ + [unnamedA]: { [SEC]: 84, [EREP]: 72 }, + [unnamedB]: { [EREP]: 95 }, + }); + + // Denied at step 1. + expect( + ( + await evaluate( + acl, + platform(BAD_PLATFORM), + Permission.READ, + evaluator, + ) + ).allowed, + ).toBe(false); + + // Allowed at step 2 -- 0x01 includes READ. + expect( + await evaluate(acl, platform(), Permission.READ, evaluator), + ).toMatchObject({ allowed: true, reason: "grant" }); + + // Denied -- 0x01 lacks DELETE, and step 3 is not reached. + expect( + await evaluate(acl, platform(), Permission.DELETE, evaluator), + ).toMatchObject({ allowed: false, reason: "grant" }); + + // Group A passes, so READ is allowed at step 3. + expect( + await evaluate(acl, platform(unnamedA), Permission.READ, evaluator), + ).toMatchObject({ allowed: true, reason: "ontology" }); + + // Group A fails on the missing security score, but Group B passes. + expect( + await evaluate(acl, platform(unnamedB), Permission.READ, evaluator), + ).toMatchObject({ allowed: true, reason: "ontology" }); + }); + + it("refuses an unnamed party that clears no group", async () => { + const weak = "@platform-weak"; + const evaluator = scores({ [weak]: { [SEC]: 10, [EREP]: 10 } }); + expect( + await evaluate(acl, platform(weak), Permission.READ, evaluator), + ).toMatchObject({ allowed: false, reason: "no_matching_group" }); + }); + + it("caps a passing party at default_perms", async () => { + const strong = "@platform-strong"; + const evaluator = scores({ [strong]: { [EREP]: 99 } }); + expect( + ( + await evaluate( + acl, + platform(strong), + Permission.UPDATE, + evaluator, + ) + ).allowed, + ).toBe(false); + }); + + it("fails a condition whose value is missing rather than passing it", async () => { + const evaluator = scores({}); + expect( + ( + await evaluate( + acl, + platform("@unknown"), + Permission.READ, + evaluator, + ) + ).allowed, + ).toBe(false); + }); + + it("fails closed when no evaluator is wired in", async () => { + expect( + (await evaluate(acl, platform("@unknown"), Permission.READ)) + .allowed, + ).toBe(false); + }); + + it("fails closed when the evaluator throws", async () => { + const throwing: ConditionEvaluator = { + async passes() { + throw new Error("registry unreachable"); + }, + }; + expect( + ( + await evaluate( + acl, + platform("@unknown"), + Permission.READ, + throwing, + ) + ).allowed, + ).toBe(false); + }); + + it("refuses everyone when require is empty", async () => { + const closed = block({ default_perms: Permission.ALL }); + expect( + (await evaluate(closed, platform("@anyone"), Permission.READ)) + .allowed, + ).toBe(false); + }); +}); + +describe("legacy acl arrays", () => { + it("maps the wildcard onto full access for anyone", async () => { + const acl = fromLegacyAcl(["*"]); + for (const action of [ + Permission.READ, + Permission.CREATE, + Permission.UPDATE, + Permission.DELETE, + ]) { + expect( + (await evaluate(acl, platform("@whoever"), action)).allowed, + ).toBe(true); + } + }); + + it("maps a listed eName onto a full grant and admits nobody else", async () => { + const acl = fromLegacyAcl([USER]); + expect((await evaluate(acl, user(), Permission.DELETE)).allowed).toBe( + true, + ); + expect( + (await evaluate(acl, user({ ename: "@other" }), Permission.READ)) + .allowed, + ).toBe(false); + }); + + it("treats an empty or absent array as no access", async () => { + expect( + (await evaluate(fromLegacyAcl([]), user(), Permission.READ)) + .allowed, + ).toBe(false); + expect( + (await evaluate(fromLegacyAcl(undefined), user(), Permission.READ)) + .allowed, + ).toBe(false); + }); + + it("prefers an explicit _acl block over the legacy array", async () => { + const resolved = resolveAclBlock({ + _acl: { v: 1, grants: [{ ename: USER, perms: 0x01 }] }, + acl: ["*"], + }); + expect( + (await evaluate(resolved, user(), Permission.DELETE)).allowed, + ).toBe(false); + expect( + (await evaluate(resolved, user(), Permission.READ)).allowed, + ).toBe(true); + }); + + it("falls back to the legacy array when no block is stored", async () => { + const resolved = resolveAclBlock({ acl: ["*"] }); + expect( + (await evaluate(resolved, platform("@whoever"), Permission.UPDATE)) + .allowed, + ).toBe(true); + }); +}); + +describe("normalizeAclBlock", () => { + it("drops malformed entries rather than trusting them", () => { + const normalized = normalizeAclBlock({ + grants: [ + { ename: USER, perms: 0x01 }, + { perms: 0x0f }, + "nonsense", + null, + ], + denials: { + enames: [PLATFORM, 42], + conditions: [ + { ontology: EREP, path: "$.score", op: "~=", value: 1 }, + ], + }, + default_perms: "lots", + require: [ + [{ ontology: EREP, path: "$.score", op: ">=", value: 60 }], + "nope", + ], + }); + + expect(normalized.grants).toEqual([{ ename: USER, perms: 0x01 }]); + expect(normalized.denials.enames).toEqual([PLATFORM]); + // An unknown operator is not a condition we can honour, so it is dropped. + expect(normalized.denials.conditions).toEqual([]); + expect(normalized.default_perms).toBe(Permission.NONE); + expect(normalized.require).toHaveLength(1); + }); + + it("returns an empty policy for junk input", () => { + expect(normalizeAclBlock(null)).toEqual(emptyAclBlock()); + expect(normalizeAclBlock("nope")).toEqual(emptyAclBlock()); + }); +}); diff --git a/infrastructure/evault-core/src/core/acl/acl.ts b/infrastructure/evault-core/src/core/acl/acl.ts new file mode 100644 index 000000000..78c6d37f4 --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/acl.ts @@ -0,0 +1,364 @@ +import { + type AclBlock, + type Condition, + type ConditionEvaluator, + type ConditionGroup, + type Decision, + type EName, + type Grant, + Permission, + type PermissionBits, + type Principal, + RESERVED_MASK, +} from "./types"; + +/** The wildcard used by the legacy `acl: string[]` model. */ +export const LEGACY_WILDCARD = "*"; + +/** + * A `require` holding one empty group. An AND over zero conditions is + * vacuously true, so every principal reaches `default_perms`. This is how the + * legacy `["*"]` ACL is expressed in the new model. + */ +const OPEN_REQUIREMENT: ConditionGroup[] = [[]]; + +/** Drops reserved bits from stored data, which we accept liberally. */ +export function sanitizePerms(perms: unknown): PermissionBits { + if (typeof perms !== "number" || !Number.isInteger(perms)) + return Permission.NONE; + return perms & ~RESERVED_MASK; +} + +/** + * Validates permission bits arriving from a caller. Reserved bits MUST be zero + * at version 1, so a write that sets them is rejected rather than silently + * narrowed. + */ +export function validatePerms(perms: unknown): PermissionBits { + if ( + typeof perms !== "number" || + !Number.isInteger(perms) || + perms < 0 || + perms > 0xff + ) { + throw new Error("Invalid ACL permissions: expected an unsigned byte"); + } + if ((perms & RESERVED_MASK) !== 0) { + throw new Error( + "Invalid ACL permissions: bits 4-7 are reserved and must be 0", + ); + } + return perms; +} + +/** An empty policy: nothing named, nothing admitted. */ +export function emptyAclBlock(): AclBlock { + return { + v: 1, + grants: [], + denials: { enames: [], conditions: [] }, + default_perms: Permission.NONE, + require: [], + }; +} + +function isOperator(op: unknown): op is Condition["op"] { + return ( + op === ">=" || op === ">" || op === "<=" || op === "<" || op === "==" + ); +} + +function normalizeCondition(raw: unknown): Condition | null { + if (typeof raw !== "object" || raw === null) return null; + const c = raw as Record; + if (typeof c.ontology !== "string" || typeof c.path !== "string") + return null; + if (!isOperator(c.op)) return null; + if (typeof c.value !== "number" || !Number.isFinite(c.value)) return null; + return { ontology: c.ontology, path: c.path, op: c.op, value: c.value }; +} + +function normalizeConditions(raw: unknown): Condition[] { + if (!Array.isArray(raw)) return []; + return raw + .map(normalizeCondition) + .filter((c): c is Condition => c !== null); +} + +/** + * Coerces stored or caller-supplied data into a well-formed {@link AclBlock}. + * Malformed entries are dropped rather than trusted — an unparseable grant must + * never widen access. + */ +export function normalizeAclBlock(raw: unknown): AclBlock { + if (typeof raw !== "object" || raw === null) return emptyAclBlock(); + const block = raw as Record; + + const grants: Grant[] = Array.isArray(block.grants) + ? block.grants + .map((g): Grant | null => { + if (typeof g !== "object" || g === null) return null; + const entry = g as Record; + if (typeof entry.ename !== "string") return null; + const perms = sanitizePerms(entry.perms); + // 0x00 is meaningless and is treated as no grant at all. + if (perms === Permission.NONE) return null; + return { ename: entry.ename, perms }; + }) + .filter((g): g is Grant => g !== null) + : []; + + const rawDenials = + typeof block.denials === "object" && block.denials !== null + ? (block.denials as Record) + : {}; + + const require: ConditionGroup[] = Array.isArray(block.require) + ? block.require.filter(Array.isArray).map(normalizeConditions) + : []; + + return { + v: 1, + grants, + denials: { + enames: Array.isArray(rawDenials.enames) + ? rawDenials.enames.filter( + (e): e is string => typeof e === "string", + ) + : [], + conditions: normalizeConditions(rawDenials.conditions), + }, + default_perms: sanitizePerms(block.default_perms), + require, + }; +} + +/** + * Interprets a legacy `acl: string[]` as an {@link AclBlock}. + * + * `"*"` admitted anyone to everything, so it maps to full `default_perms` + * behind an always-passing requirement. A listed eName was likewise + * unrestricted, so it maps to a full grant. This keeps every record written + * before the `_acl` block behaving exactly as it did. + */ +export function fromLegacyAcl( + acl: readonly string[] | null | undefined, +): AclBlock { + const block = emptyAclBlock(); + if (!Array.isArray(acl)) return block; + + for (const entry of acl) { + if (typeof entry !== "string" || entry.length === 0) continue; + if (entry === LEGACY_WILDCARD) { + block.default_perms = Permission.ALL; + block.require = OPEN_REQUIREMENT.map((group) => [...group]); + continue; + } + block.grants.push({ ename: entry, perms: Permission.ALL }); + } + return block; +} + +/** + * Picks the policy for a record. An explicit `_acl` block always wins; a record + * carrying only the legacy array is interpreted through {@link fromLegacyAcl}. + */ +export function resolveAclBlock(record: { + _acl?: unknown; + acl?: readonly string[] | null; +}): AclBlock { + if (record._acl !== undefined && record._acl !== null) { + return normalizeAclBlock(record._acl); + } + return fromLegacyAcl(record.acl); +} + +/** + * How specifically a grant's eName matches the principal: a user grant beats a + * platform grant, which beats a grant to a group the party belongs to. `0` + * means the grant does not apply. + */ +function specificityOf(ename: EName, principal: Principal): number { + if (ename === principal.ename) return principal.kind === "user" ? 3 : 2; + if (principal.platform !== undefined && ename === principal.platform) + return 2; + if (principal.groups?.includes(ename)) return 1; + return 0; +} + +/** + * The single most specific grant applying to `principal`, or `null`. + * + * Less specific grants never add to a more specific one. Grants tied at the + * same specificity — duplicates, or two groups the party belongs to — are + * unioned, since nothing in the design orders one above the other and picking + * arbitrarily would make the outcome depend on storage order. + */ +export function mostSpecificGrant( + grants: readonly Grant[], + principal: Principal, +): { perms: PermissionBits; enames: EName[] } | null { + let bestRank = 0; + let perms: PermissionBits = Permission.NONE; + let enames: EName[] = []; + + for (const grant of grants) { + const rank = specificityOf(grant.ename, principal); + if (rank === 0) continue; + if (rank > bestRank) { + bestRank = rank; + perms = grant.perms; + enames = [grant.ename]; + } else if (rank === bestRank) { + perms |= grant.perms; + enames.push(grant.ename); + } + } + + if (bestRank === 0 || perms === Permission.NONE) return null; + return { perms, enames }; +} + +/** + * Every identity a denial by name can match: the party itself, the platform + * acting for it, and each group it belongs to. + */ +function identitiesOf(principal: Principal): Set { + const identities = new Set([principal.ename]); + if (principal.platform !== undefined) identities.add(principal.platform); + for (const group of principal.groups ?? []) identities.add(group); + return identities; +} + +/** + * A condition with no evaluator wired in cannot be shown to hold, and the + * design is explicit that an unresolvable condition fails rather than passes. + */ +async function conditionPasses( + condition: Condition, + principal: Principal, + evaluator: ConditionEvaluator | undefined, +): Promise { + if (!evaluator) return false; + try { + return await evaluator.passes(condition, principal); + } catch { + return false; + } +} + +/** A group passes when every condition in it passes. An empty group is vacuously true. */ +async function groupPasses( + group: ConditionGroup, + principal: Principal, + evaluator: ConditionEvaluator | undefined, +): Promise { + for (const condition of group) { + if (!(await conditionPasses(condition, principal, evaluator))) + return false; + } + return true; +} + +/** Rejects an action that is not exactly one permission bit. */ +function validateAction(action: PermissionBits): void { + if ( + !Number.isInteger(action) || + action === Permission.NONE || + (action & RESERVED_MASK) !== 0 || + (action & (action - 1)) !== 0 + ) { + throw new Error( + "Invalid ACL action: expected exactly one permission bit", + ); + } +} + +/** + * Decides whether `principal` may perform `action` under `acl`. + * + * The order is fixed: denials, then a direct grant, then the ontology groups. + * A direct grant is final — a party named in `grants` never falls through to + * `default_perms`, whether the grant allowed the action or not. + * + * `require` is evaluated in order and short-circuits on the first passing group. + */ +export async function evaluate( + acl: AclBlock, + principal: Principal, + action: PermissionBits, + evaluator?: ConditionEvaluator, +): Promise { + validateAction(action); + + // 1. Denials win over everything, with no exceptions. + const identities = identitiesOf(principal); + for (const denied of acl.denials.enames) { + if (identities.has(denied)) { + return { allowed: false, reason: "denied_by_ename" }; + } + } + for (const condition of acl.denials.conditions) { + if (!(await conditionPasses(condition, principal, evaluator))) { + return { allowed: false, reason: "denied_by_condition" }; + } + } + + // 2. A direct grant decides the outcome on its own. + const grant = mostSpecificGrant(acl.grants, principal); + if (grant !== null) { + return { + allowed: (grant.perms & action) !== 0, + reason: "grant", + perms: grant.perms, + matchedGrant: grant.enames[0], + }; + } + + // 3. Otherwise the party must clear one of the ontology groups. + for (const group of acl.require) { + if (await groupPasses(group, principal, evaluator)) { + return { + allowed: (acl.default_perms & action) !== 0, + reason: "ontology", + perms: acl.default_perms, + }; + } + } + + return { allowed: false, reason: "no_matching_group" }; +} + +/** + * Coerces a caller-supplied policy into a block, or `undefined` when the caller + * supplied none — which is not the same as an empty policy, and must leave the + * record on its legacy array rather than locking it. + * + * Unlike stored data, caller input is validated strictly: a grant that sets a + * reserved bit is rejected rather than silently narrowed, so a client writing + * against a newer version of the spec fails loudly instead of getting weaker + * permissions than it asked for. + */ +export function aclBlockFromInput(raw: unknown): AclBlock | undefined { + if (raw === null || raw === undefined) return undefined; + if (typeof raw !== "object") { + throw new Error("Invalid _acl: expected an object"); + } + const block = raw as Record; + + if (block.v !== undefined && block.v !== 1) { + throw new Error(`Unsupported _acl version: ${String(block.v)}`); + } + if (Array.isArray(block.grants)) { + for (const grant of block.grants) { + if (typeof grant === "object" && grant !== null) { + validatePerms((grant as Record).perms); + } + } + } + if (block.default_perms !== undefined) { + validatePerms(block.default_perms); + } + + return normalizeAclBlock(raw); +} diff --git a/infrastructure/evault-core/src/core/acl/index.ts b/infrastructure/evault-core/src/core/acl/index.ts new file mode 100644 index 000000000..53717da44 --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/index.ts @@ -0,0 +1,33 @@ +export { + type AclBlock, + type ComparisonOperator, + type Condition, + type ConditionEvaluator, + type ConditionGroup, + type Decision, + type DecisionReason, + type Denials, + type EName, + type Grant, + Permission, + type PermissionBits, + type Principal, + type PrincipalKind, + type Requirement, + RESERVED_MASK, +} from "./types"; + +export { + aclBlockFromInput, + emptyAclBlock, + evaluate, + fromLegacyAcl, + LEGACY_WILDCARD, + mostSpecificGrant, + normalizeAclBlock, + resolveAclBlock, + sanitizePerms, + validatePerms, +} from "./acl"; + +export { parseStoredAclBlock, serializeAclBlock } from "./storage"; diff --git a/infrastructure/evault-core/src/core/acl/storage.ts b/infrastructure/evault-core/src/core/acl/storage.ts new file mode 100644 index 000000000..d31e75b39 --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/storage.ts @@ -0,0 +1,32 @@ +import { normalizeAclBlock } from "./acl"; +import type { AclBlock } from "./types"; + +/** + * Neo4j properties hold only primitives and arrays of primitives, so the `_acl` + * block is persisted as a JSON string on the `:MetaEnvelope` node. The legacy + * `acl` array stays alongside it untouched, so a record written before this + * change keeps behaving as it did. + */ +export function serializeAclBlock( + block: AclBlock | null | undefined, +): string | null { + if (!block) return null; + return JSON.stringify(normalizeAclBlock(block)); +} + +/** + * Reads a stored block back. Returns `undefined` — not an empty policy — when + * nothing is stored, so callers can tell "no block, fall back to the legacy + * array" apart from "an explicit block that grants nothing". + */ +export function parseStoredAclBlock(raw: unknown): AclBlock | undefined { + if (raw === null || raw === undefined) return undefined; + if (typeof raw === "object") return normalizeAclBlock(raw); + if (typeof raw !== "string" || raw.length === 0) return undefined; + try { + return normalizeAclBlock(JSON.parse(raw)); + } catch { + // A corrupt block must not fall back to a permissive legacy array. + return normalizeAclBlock(null); + } +} diff --git a/infrastructure/evault-core/src/core/acl/types.ts b/infrastructure/evault-core/src/core/acl/types.ts new file mode 100644 index 000000000..6d021334c --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/types.ts @@ -0,0 +1,125 @@ +/** + * Access control: granular permissions and the Resource Link Ontology. + * + * This module carries the *list* layer of the design: named grants, denials, + * and the decision order that combines them. Condition evaluation (the + * Resource Link Ontology — how a platform's score is fetched and compared) is + * delegated to an injected {@link ConditionEvaluator}. Nothing here resolves a + * score, reads an ontology, or talks to the network. + */ + +/** A party reference, written `@`. Identifies a user, platform, group, or ontology. */ +export type EName = string; + +/** Permission bits. Independent, combined by union. */ +export const Permission = { + NONE: 0x00, + READ: 0x01, + CREATE: 0x02, + UPDATE: 0x04, + DELETE: 0x08, + /** Read + Create + Update + Delete. */ + ALL: 0x0f, +} as const; + +/** An unsigned byte holding a union of {@link Permission} bits. */ +export type PermissionBits = number; + +/** Bits 4-7 are reserved and MUST be zero at version 1. */ +export const RESERVED_MASK = 0xf0; + +/** The only operators defined today. All are numeric. */ +export type ComparisonOperator = ">=" | ">" | "<=" | "<" | "=="; + +/** + * A numeric requirement on a value found at `path` inside the value described + * by the ontology `ontology`. + */ +export interface Condition { + /** The ontology's eName, e.g. eReputation. */ + ontology: EName; + /** JSONPath into the ontology value, e.g. `$.score`. */ + path: string; + op: ComparisonOperator; + value: number; +} + +/** All conditions must pass (AND). */ +export type ConditionGroup = Condition[]; + +/** Any group passing is enough (OR). Disjunctive normal form. */ +export type Requirement = ConditionGroup[]; + +/** One named party and the permissions it holds. */ +export interface Grant { + ename: EName; + perms: PermissionBits; +} + +/** Access removals. A denial always wins over any grant. */ +export interface Denials { + /** Deny by identity. */ + enames: EName[]; + /** Deny a party that fails the check. */ + conditions: Condition[]; +} + +/** The `_acl` block, stored inside the record it protects. */ +export interface AclBlock { + v: 1; + grants: Grant[]; + denials: Denials; + /** Applied to unnamed principals that pass a `require` group. */ + default_perms: PermissionBits; + require: Requirement; +} + +/** What kind of party an eName denotes, for grant specificity. */ +export type PrincipalKind = "user" | "platform"; + +/** The party requesting an action, as resolved at check time. */ +export interface Principal { + /** The acting party's own eName. */ + ename: EName; + kind: PrincipalKind; + /** The platform acting on the party's behalf, when distinct from `ename`. */ + platform?: EName; + /** Group eNames the party belongs to. Groups resolve to members at check time. */ + groups?: EName[]; +} + +/** Why {@link evaluate} reached its verdict. */ +export type DecisionReason = + /** Step 1: the party, its platform, or one of its groups is denied by name. */ + | "denied_by_ename" + /** Step 1: a deny condition applied. */ + | "denied_by_condition" + /** Step 2: a direct grant decided the outcome. */ + | "grant" + /** Step 3: a `require` group passed and `default_perms` decided the outcome. */ + | "ontology" + /** Step 3: no group passed. */ + | "no_matching_group"; + +export interface Decision { + allowed: boolean; + reason: DecisionReason; + /** The bits the action was tested against, when a grant or `default_perms` applied. */ + perms?: PermissionBits; + /** The eName of the grant that decided a step-2 outcome. */ + matchedGrant?: EName; +} + +/** + * Resolves the Resource Link Ontology half of the design. + * + * Implementations fetch the value the `condition.ontology` describes for + * `principal` — per the design, the score lives on the eVault of the platform + * that is its subject — resolve `condition.path` against it, and compare. + * + * A path that is missing, resolves to multiple nodes, or resolves to a + * non-numeric value MUST return `false`. A condition never fails open. + */ +export interface ConditionEvaluator { + passes(condition: Condition, principal: Principal): Promise; +} From d2af4cf18aac8be171897a7f32ffa40531d13235 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 14:59:55 +0800 Subject: [PATCH 2/7] feat: store and enforce granular access policies in eVault --- .../evault-core/src/core/db/db.service.ts | 45 ++-- .../evault-core/src/core/db/types.ts | 11 + .../src/core/protocol/graphql-server.ts | 24 +++ .../evault-core/src/core/protocol/typedefs.ts | 56 +++++ .../core/protocol/vault-access-guard.spec.ts | 193 ++++++++++++++++++ .../src/core/protocol/vault-access-guard.ts | 126 +++++++++--- 6 files changed, 414 insertions(+), 41 deletions(-) diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index d2493f7a4..bead136f0 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -1,6 +1,7 @@ import neo4j, { type Driver } from "neo4j-driver"; import { W3IDBuilder } from "w3id"; import { timed } from "../utils/timing"; +import { parseStoredAclBlock, serializeAclBlock } from "../acl"; import { deserializeValue, serializeValue } from "./schema"; import type { AppendEnvelopeOperationLogParams, @@ -88,13 +89,14 @@ export class DbService { ); const cypher: string[] = [ - `CREATE (m:MetaEnvelope { id: $metaId, ontology: $ontology, acl: $acl, eName: $eName })`, + `CREATE (m:MetaEnvelope { id: $metaId, ontology: $ontology, acl: $acl, aclBlock: $aclBlock, eName: $eName })`, ]; const envelopeParams: Record = { metaId: w3id.id, ontology: meta.ontology, acl: acl, + aclBlock: serializeAclBlock(meta._acl), eName: eName, }; @@ -144,6 +146,7 @@ export class DbService { id: w3id.id, ontology: meta.ontology, acl: acl, + _acl: meta._acl, }, envelopes: createdEnvelopes, }; @@ -176,13 +179,14 @@ export class DbService { const cypher: string[] = [ "MERGE (m:MetaEnvelope { id: $metaId })", - "ON CREATE SET m.ontology = $ontology, m.acl = $acl, m.eName = $eName", + "ON CREATE SET m.ontology = $ontology, m.acl = $acl, m.aclBlock = $aclBlock, m.eName = $eName", ]; const envelopeParams: Record = { metaId: metaId, ontology: meta.ontology, acl: acl, + aclBlock: serializeAclBlock(meta._acl), eName: eName, }; @@ -262,7 +266,7 @@ export class DbService { END WITH m MATCH (m)-[:LINKS_TO]->(allEnvelopes:Envelope) - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(allEnvelopes) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(allEnvelopes) AS envelopes `, { ontology, term: searchTerm, eName }, ); @@ -295,6 +299,7 @@ export class DbService { id: record.get("id"), ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), envelopes, parsed, }; @@ -321,7 +326,7 @@ export class DbService { ` MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope) WHERE m.id IN $ids - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(e) AS envelopes `, { ids, eName }, ); @@ -354,6 +359,7 @@ export class DbService { id: record.get("id"), ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), envelopes, parsed, }; @@ -378,7 +384,7 @@ export class DbService { const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope) - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(e) AS envelopes `, { id, eName }, ); @@ -413,6 +419,7 @@ export class DbService { id: record.get("id"), ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), envelopes, parsed, }; @@ -436,7 +443,7 @@ export class DbService { const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { ontology: $ontology, eName: $eName })-[:LINKS_TO]->(e:Envelope) - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(e) AS envelopes `, { ontology, eName }, ); @@ -469,6 +476,7 @@ export class DbService { id: record.get("id"), ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), envelopes, parsed, }; @@ -487,7 +495,7 @@ export class DbService { const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { ontology: $ontology })-[:LINKS_TO]->(e:Envelope) - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.eName AS eName, collect(e) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, m.eName AS eName, collect(e) AS envelopes `, { ontology }, ); @@ -520,6 +528,7 @@ export class DbService { id: record.get("id"), ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), eName: record.get("eName"), envelopes, parsed, @@ -606,13 +615,19 @@ export class DbService { const findResult = await tx.run( ` MERGE (m:MetaEnvelope { id: $id, eName: $eName }) - ON CREATE SET m.ontology = $ontology, m.acl = $acl - ON MATCH SET m.ontology = $ontology, m.acl = $acl + ON CREATE SET m.ontology = $ontology, m.acl = $acl, m.aclBlock = $aclBlock + ON MATCH SET m.ontology = $ontology, m.acl = $acl, m.aclBlock = coalesce($aclBlock, m.aclBlock) WITH m OPTIONAL MATCH (m)-[:LINKS_TO]->(e:Envelope) RETURN collect(e) AS envelopes `, - { id, eName, ontology: meta.ontology, acl }, + { + id, + eName, + ontology: meta.ontology, + acl, + aclBlock: serializeAclBlock(meta._acl), + }, ); const envelopeNodes: any[] = ( @@ -736,6 +751,7 @@ export class DbService { id, ontology: meta.ontology, acl, + _acl: meta._acl, }, envelopes: createdEnvelopes, mergedPayload, @@ -765,7 +781,7 @@ export class DbService { const result = await this.runQueryInternal( ` MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope) - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(e) AS envelopes `, { eName }, ); @@ -798,6 +814,7 @@ export class DbService { id: record.get("id"), ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), envelopes, parsed, }; @@ -931,12 +948,13 @@ export class DbService { await targetDbService.runQuery( ` MERGE (m:MetaEnvelope { id: $metaId, eName: $eName }) - SET m.ontology = $ontology, m.acl = $acl + SET m.ontology = $ontology, m.acl = $acl, m.aclBlock = $aclBlock `, { metaId: metaEnvelope.id, ontology: metaEnvelope.ontology, acl: metaEnvelope.acl, + aclBlock: serializeAclBlock(metaEnvelope._acl), eName: eName, }, ); @@ -1349,7 +1367,7 @@ export class DbService { ORDER BY m.id ${orderDirection} LIMIT $limitPlusOne MATCH (m)-[:LINKS_TO]->(e:Envelope) - RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(e) AS envelopes `; params.limitPlusOne = neo4j.int(limit + 1); @@ -1410,6 +1428,7 @@ export class DbService { id, ontology: record.get("ontology"), acl: record.get("acl"), + _acl: parseStoredAclBlock(record.get("aclBlock")), envelopes, parsed, }; diff --git a/infrastructure/evault-core/src/core/db/types.ts b/infrastructure/evault-core/src/core/db/types.ts index 98475e717..926e37041 100644 --- a/infrastructure/evault-core/src/core/db/types.ts +++ b/infrastructure/evault-core/src/core/db/types.ts @@ -1,3 +1,5 @@ +import type { AclBlock } from "../acl"; + /** * Represents a meta-envelope that contains multiple envelopes of data. */ @@ -6,6 +8,12 @@ export type MetaEnvelope = Record> = ontology: string; payload: T; acl: string[]; + /** + * The granular access policy. When present it is authoritative and the + * legacy `acl` array is ignored; when absent the array is interpreted + * through `fromLegacyAcl`. + */ + _acl?: AclBlock; }; /** @@ -29,6 +37,8 @@ export type MetaEnvelopeResult< id: string; ontology: string; acl: string[]; + /** The granular access policy, when the record carries one. */ + _acl?: AclBlock; envelopes: Envelope[]; parsed: T; // eName is stored internally but never returned in API responses @@ -50,6 +60,7 @@ export type StoreMetaEnvelopeResult< id: string; ontology: string; acl: string[]; + _acl?: AclBlock; }; envelopes: Envelope[]; mergedPayload?: Record; diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 9f30fd200..9014c07a0 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -1,4 +1,5 @@ import { Server } from "http"; +import { aclBlockFromInput, Permission } from "../acl"; import axios from "axios"; import type { GraphQLSchema } from "graphql"; import { createSchema, createYoga } from "graphql-yoga"; @@ -361,6 +362,7 @@ export class GraphQLServer { ontology: string; payload: any; acl: string[]; + _acl?: unknown; }; }, context: VaultContext, @@ -383,6 +385,7 @@ export class GraphQLServer { ontology: input.ontology, payload: input.payload, acl: input.acl, + _acl: aclBlockFromInput(input._acl), }, input.acl, context.eName, @@ -483,6 +486,7 @@ export class GraphQLServer { }; } }, + Permission.CREATE, ), // Update an existing MetaEnvelope with structured payload @@ -498,6 +502,7 @@ export class GraphQLServer { ontology: string; payload: any; acl: string[]; + _acl?: unknown; }; }, context: VaultContext, @@ -521,6 +526,7 @@ export class GraphQLServer { ontology: input.ontology, payload: input.payload, acl: input.acl, + _acl: aclBlockFromInput(input._acl), }, input.acl, context.eName, @@ -612,6 +618,7 @@ export class GraphQLServer { }; } }, + Permission.UPDATE, ), // Delete a MetaEnvelope with structured result @@ -701,6 +708,7 @@ export class GraphQLServer { }; } }, + Permission.DELETE, ), // Bulk create MetaEnvelopes (optimized for migrations) @@ -716,6 +724,7 @@ export class GraphQLServer { ontology: string; payload: any; acl: string[]; + _acl?: unknown; }>; skipWebhooks?: boolean; }, @@ -760,6 +769,7 @@ export class GraphQLServer { ontology: input.ontology, payload: input.payload, acl: input.acl, + _acl: aclBlockFromInput(input._acl), }, input.acl, context.eName, @@ -840,6 +850,7 @@ export class GraphQLServer { errors: [], }; }, + Permission.CREATE, ), // ============================================================ @@ -988,6 +999,7 @@ export class GraphQLServer { }; } }, + Permission.CREATE, ), createBindingDocumentSignature: this.accessGuard.middleware( @@ -1095,6 +1107,7 @@ export class GraphQLServer { }; } }, + Permission.UPDATE, ), hashSecurityAnswer: this.accessGuard.middleware( @@ -1217,6 +1230,7 @@ export class GraphQLServer { ontology: string; payload: any; acl: string[]; + _acl?: unknown; }; }, context: VaultContext, @@ -1229,6 +1243,7 @@ export class GraphQLServer { ontology: input.ontology, payload: input.payload, acl: input.acl, + _acl: aclBlockFromInput(input._acl), }, input.acl, context.eName, @@ -1304,6 +1319,7 @@ export class GraphQLServer { metaEnvelope: metaEnvelopeWithParsed, }; }, + Permission.CREATE, ), // Upload a file to object storage and create a File meta-envelope uploadFile: this.accessGuard.middleware( @@ -1317,6 +1333,7 @@ export class GraphQLServer { contentType: string; content: string; acl: string[]; + _acl?: unknown; }; }, context: VaultContext, @@ -1420,6 +1437,7 @@ export class GraphQLServer { ontology: FILE_SCHEMA_ID, payload, acl: input.acl, + _acl: aclBlockFromInput(input._acl), }, input.acl, context.eName, @@ -1514,6 +1532,7 @@ export class GraphQLServer { }; } }, + Permission.CREATE, ), updateMetaEnvelopeById: this.accessGuard.middleware( async ( @@ -1527,6 +1546,7 @@ export class GraphQLServer { ontology: string; payload: any; acl: string[]; + _acl?: unknown; }; }, context: VaultContext, @@ -1541,6 +1561,7 @@ export class GraphQLServer { ontology: input.ontology, payload: input.payload, acl: input.acl, + _acl: aclBlockFromInput(input._acl), }, input.acl, context.eName, @@ -1600,6 +1621,7 @@ export class GraphQLServer { throw error; } }, + Permission.UPDATE, ), deleteMetaEnvelope: this.accessGuard.middleware( async ( @@ -1636,6 +1658,7 @@ export class GraphQLServer { ); return true; }, + Permission.DELETE, ), updateEnvelopeValue: this.accessGuard.middleware( async ( @@ -1686,6 +1709,7 @@ export class GraphQLServer { } return true; }, + Permission.UPDATE, ), }, }; diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index cbfc03178..c4d3af920 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -151,6 +151,8 @@ export const typeDefs = /* GraphQL */ ` content: String! "Access control list for the created File meta-envelope" acl: [String!]! + "The granular access policy. Takes precedence over acl." + _acl: AclBlockInput } type UploadFilePayload { @@ -277,6 +279,54 @@ export const typeDefs = /* GraphQL */ ` getAllEnvelopes: [Envelope!]! } + # ============================================================================ + # Access Control Inputs + # ============================================================================ + + "A numeric requirement on a value inside an ontology, e.g. eReputation >= 60" + input AclConditionInput { + "The ontology's eName" + ontology: String! + "JSONPath into the ontology value, e.g. $.score" + path: String! + "One of >=, >, <=, <, ==" + op: String! + value: Float! + } + + "One named party and the permissions it holds" + input AclGrantInput { + "The party's eName" + ename: String! + "Bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE. Bits 4-7 are reserved and must be 0." + perms: Int! + } + + "Access removals. A denial always wins over any grant." + input AclDenialsInput { + "Deny by identity" + enames: [String!] + "Deny a party that fails the check" + conditions: [AclConditionInput!] + } + + """ + The granular access policy stored inside the record it protects. + + Decisions run in a fixed order: denials, then the most specific grant + (user beats platform beats group), then the require groups. When present + this block is authoritative and the legacy acl array is ignored. + """ + input AclBlockInput { + v: Int + grants: [AclGrantInput!] + denials: AclDenialsInput + "Permissions for unnamed parties that pass a require group" + default_perms: Int + "OR of groups, each an AND of conditions" + require: [[AclConditionInput!]!] + } + # ============================================================================ # Inputs # ============================================================================ @@ -284,7 +334,10 @@ export const typeDefs = /* GraphQL */ ` input MetaEnvelopeInput { ontology: String! payload: JSON! + "Legacy access list. Ignored when _acl is supplied." acl: [String!]! + "The granular access policy. Takes precedence over acl." + _acl: AclBlockInput } "Input for bulk create operations (e.g., migrations)" @@ -293,7 +346,10 @@ export const typeDefs = /* GraphQL */ ` id: ID ontology: String! payload: JSON! + "Legacy access list. Ignored when _acl is supplied." acl: [String!]! + "The granular access policy. Takes precedence over acl." + _acl: AclBlockInput } # ============================================================================ diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts index fdc7a5997..d28ac45f6 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; import { VaultAccessGuard, VaultContext } from "./vault-access-guard"; +import { Permission } from "../acl"; import { DbService } from "../db/db.service"; import { setupTestNeo4j, teardownTestNeo4j } from "../../test-utils/neo4j-setup"; import { Driver } from "neo4j-driver"; @@ -936,4 +937,196 @@ describe("VaultAccessGuard", () => { expect(mockResolver).not.toHaveBeenCalled(); }); }); +describe("granular _acl policies", () => { + const PLATFORM = "@platform-granular"; + const OTHER_PLATFORM = "@platform-other"; + const USER = "@user-granular"; + + /** Stores a record carrying an explicit policy and returns its id. */ + const storeWithPolicy = async (eName: string, _acl: any) => { + const result = await dbService.storeMetaEnvelope( + { ontology: "Test", payload: { field: "value" }, acl: ["*"], _acl }, + ["*"], + eName, + ); + return result.metaEnvelope.id; + }; + + const contextFor = async ( + eName: string, + claims: any, + currentUser: string | null = null, + ) => { + const token = await createValidToken(claims); + return createMockContext({ + eName, + currentUser, + request: { + headers: new Headers({ authorization: `Bearer ${token}` }), + } as any, + }); + }; + + it("closes the platform-token bypass a policy is meant to close", async () => { + const eName = "@vault-granular-1"; + // The legacy array says "*", but the explicit policy names nobody. + const id = await storeWithPolicy(eName, { + v: 1, + grants: [], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + const wrapped = guard.middleware(resolver); + + await expect(wrapped(null, { id }, context)).rejects.toThrow("Access denied"); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("allows the action a grant carries and refuses one it does not", async () => { + const eName = "@vault-granular-2"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: PLATFORM, perms: 0x01 }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver, Permission.READ)(null, { id }, context), + ).resolves.toBeDefined(); + + await expect( + guard.middleware(resolver, Permission.DELETE)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("lets a denial override a grant to the same party", async () => { + const eName = "@vault-granular-3"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: PLATFORM, perms: 0x0f }], + denials: { enames: [PLATFORM], conditions: [] }, + default_perms: 0x0f, + require: [[]], + }); + + const context = await contextFor(eName, { platform: PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("admits an unnamed platform through default_perms when a group passes", async () => { + const eName = "@vault-granular-4"; + // An empty group is an AND over zero conditions, so it always passes. + const id = await storeWithPolicy(eName, { + v: 1, + grants: [], + denials: { enames: [], conditions: [] }, + default_perms: 0x01, + require: [[]], + }); + + const context = await contextFor(eName, { platform: OTHER_PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver, Permission.READ)(null, { id }, context), + ).resolves.toBeDefined(); + await expect( + guard.middleware(resolver, Permission.UPDATE)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("prefers a user grant over the platform grant carrying the request", async () => { + const eName = "@vault-granular-5"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [ + { ename: PLATFORM, perms: 0x0f }, + { ename: USER, perms: 0x01 }, + ], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }, USER); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver, Permission.READ)(null, { id }, context), + ).resolves.toBeDefined(); + // The broader platform grant must not be unioned into the user's. + await expect( + guard.middleware(resolver, Permission.DELETE)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("fails closed on a require group whose conditions have no evaluator", async () => { + const eName = "@vault-granular-6"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [], + denials: { enames: [], conditions: [] }, + default_perms: 0x0f, + require: [[{ ontology: "@erep", path: "$.score", op: ">=", value: 60 }]], + }); + + const context = await contextFor(eName, { platform: OTHER_PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("leaves a record with no policy on its original behaviour", async () => { + const eName = "@vault-granular-7"; + // No _acl: a valid platform token is still sufficient, as before. + const result = await dbService.storeMetaEnvelope( + { ontology: "Test", payload: { field: "value" }, acl: ["*"] }, + ["*"], + eName, + ); + const id = result.metaEnvelope.id; + + const context = await contextFor(eName, { platform: OTHER_PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver, Permission.DELETE)(null, { id }, context), + ).resolves.toBeDefined(); + }); + + it("never returns the policy to the caller", async () => { + const eName = "@vault-granular-8"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: PLATFORM, perms: 0x0f }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }); + const stored = await dbService.findMetaEnvelopeById(id, eName); + expect(stored?._acl).toBeDefined(); + + const resolver = vi.fn(async () => stored); + const returned: any = await guard.middleware(resolver)(null, { id }, context); + expect(returned).not.toHaveProperty("_acl"); + expect(returned).not.toHaveProperty("acl"); + }); + }); }); diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 084b85ba0..6ea9ba119 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -1,6 +1,13 @@ import axios from "axios"; import type { YogaInitialContext } from "graphql-yoga"; import * as jose from "jose"; +import { + type ConditionEvaluator, + evaluate, + Permission, + type PermissionBits, + type Principal, +} from "../acl"; import type { DbService } from "../db/db.service"; import type { MetaEnvelope } from "../db/types"; import { timed } from "../utils/timing"; @@ -21,7 +28,41 @@ const JWKS_FETCH_TIMEOUT_MS = 5000; const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; export class VaultAccessGuard { - constructor(private db: DbService) {} + /** + * @param conditionEvaluator - Resolves Resource Link Ontology conditions. + * Without one, any record whose policy carries conditions is decided + * fail-closed: a `require` group with conditions cannot pass, and a deny + * condition always applies. + */ + constructor( + private db: DbService, + private conditionEvaluator?: ConditionEvaluator, + ) {} + + /** + * The party a request acts as. + * + * A user identity is the party when there is one, with the calling + * platform recorded alongside it so that a platform-level grant or denial + * still applies at its own specificity. Otherwise the platform itself is + * the party. + * + * Group membership is not resolved yet, so a grant or denial naming a + * group matches nothing. For grants that is fail-closed; for denials it is + * fail-open, so group denials are not usable until a membership lookup is + * wired in here. + */ + private principalFor(context: VaultContext): Principal | null { + const platform: string | undefined = + context.tokenPayload?.platform ?? undefined; + if (context.currentUser) { + return { ename: context.currentUser, kind: "user", platform }; + } + if (platform) { + return { ename: platform, kind: "platform" }; + } + return null; + } private bearerToken(context: VaultContext): string | undefined { const authHeader = @@ -199,6 +240,7 @@ export class VaultAccessGuard { private async checkAccess( metaEnvelopeId: string, context: VaultContext, + action: PermissionBits = Permission.READ, ): Promise<{ hasAccess: boolean; exists: boolean }> { // Reuse token payload already validated by validateAuthentication() earlier // in the middleware; only re-validate as a fallback (e.g. store operations @@ -212,21 +254,15 @@ export class VaultAccessGuard { } if (tokenPayload) { - // Token is valid, set platform context and allow access context.tokenPayload = tokenPayload; - // Still need to check if envelope exists - if (!context.eName) { - return { hasAccess: true, exists: false }; - } - const metaEnvelope = await this.db.findMetaEnvelopeById( - metaEnvelopeId, - context.eName, - ); - return { hasAccess: true, exists: metaEnvelope !== null }; } - // Validate eName is present if (!context.eName) { + // With no vault addressed there is no record to load, so a valid + // token is all there is to go on. + if (tokenPayload) { + return { hasAccess: true, exists: false }; + } throw new Error("X-ENAME header is required for access control"); } @@ -235,25 +271,43 @@ export class VaultAccessGuard { context.eName, ); if (!metaEnvelope) { - return { hasAccess: false, exists: false }; + return { hasAccess: tokenPayload !== null, exists: false }; } - // Fallback to original ACL logic if no valid token - if (!context.currentUser) { - if (metaEnvelope.acl.includes("*")) { - return { hasAccess: true, exists: true }; + // A record carrying an explicit policy is decided by that policy, for + // every caller. A platform token does not bypass the owner's rules -- + // that bypass is exactly what the granular model exists to close. + if (metaEnvelope._acl) { + const principal = this.principalFor(context); + if (!principal) { + return { hasAccess: false, exists: true }; } - return { hasAccess: false, exists: true }; + const decision = await evaluate( + metaEnvelope._acl, + principal, + action, + this.conditionEvaluator, + ); + return { hasAccess: decision.allowed, exists: true }; } - // If ACL contains "*", anyone can access - if (metaEnvelope.acl.includes("*")) { + // Records written before the _acl block keep their original behaviour + // unchanged: a valid platform token suffices, otherwise the legacy + // array decides. Nothing is narrowed until an owner sets a policy. + if (tokenPayload) { return { hasAccess: true, exists: true }; } - // Check if the current user's ID is in the ACL - const hasAccess = metaEnvelope.acl.includes(context.currentUser); - return { hasAccess, exists: true }; + if (metaEnvelope.acl.includes("*")) { + return { hasAccess: true, exists: true }; + } + if (!context.currentUser) { + return { hasAccess: false, exists: true }; + } + return { + hasAccess: metaEnvelope.acl.includes(context.currentUser), + exists: true, + }; } /** @@ -267,7 +321,7 @@ export class VaultAccessGuard { if (typeof metaEnvelope !== "object") { return metaEnvelope; } - const { acl, ...filtered } = metaEnvelope; + const { acl, _acl, ...filtered } = metaEnvelope; return filtered; } @@ -281,11 +335,26 @@ export class VaultAccessGuard { envelopes: MetaEnvelope[], context: VaultContext, ): Promise { + const principal = this.principalFor(context); const filteredEnvelopes = []; for (const envelope of envelopes) { - const hasAccess = - envelope.acl.includes("*") || - envelope.acl.includes(context.currentUser ?? ""); + let hasAccess: boolean; + if (envelope._acl) { + hasAccess = principal + ? ( + await evaluate( + envelope._acl, + principal, + Permission.READ, + this.conditionEvaluator, + ) + ).allowed + : false; + } else { + hasAccess = + envelope.acl.includes("*") || + envelope.acl.includes(context.currentUser ?? ""); + } if (hasAccess) { filteredEnvelopes.push(this.filterACL(envelope)); } @@ -304,6 +373,7 @@ export class VaultAccessGuard { args: Args, context: VaultContext, ) => Promise, + action: PermissionBits = Permission.READ, ) { return async (parent: T, args: Args, context: VaultContext) => { // Check if this is storeMetaEnvelope operation (has input with ontology, payload, acl) @@ -358,7 +428,7 @@ export class VaultAccessGuard { // Check if envelope exists and user has access const { hasAccess, exists } = await timed("guard.checkAccess", () => - this.checkAccess(metaEnvelopeId, context), + this.checkAccess(metaEnvelopeId, context, action), ); // For update operations with input, allow in-place creation if envelope doesn't exist From 8d199f1e10521dc90b97b11ee60bb45edf01e8dd Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 14:59:55 +0800 Subject: [PATCH 3/7] docs: document access control and how platforms implement it --- docs/docs/Infrastructure/eVault.md | 31 ++-- .../Post Platform Guide/access-control.md | 146 +++++++++++++++ .../Post Platform Guide/ai-agent-skill.md | 2 +- .../ecurrency-accounts-and-ledger.md | 2 +- .../platform-evault-registration.md | 2 +- .../pp-auth-demonstrator.md | 2 +- docs/docs/Post Platform Guide/pp-auth.md | 2 +- docs/docs/W3DS Basics/Access-Policy.md | 9 + docs/docs/W3DS Basics/glossary.md | 4 +- docs/docs/W3DS Protocol/Access-Control.md | 170 ++++++++++++++++++ skills/w3ds/SKILL.md | 2 +- skills/w3ds/reference/evault.md | 35 +++- skills/w3ds/reference/identity.md | 3 +- 13 files changed, 386 insertions(+), 24 deletions(-) create mode 100644 docs/docs/Post Platform Guide/access-control.md create mode 100644 docs/docs/W3DS Protocol/Access-Control.md diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index d98241566..fa2137efb 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -65,7 +65,8 @@ A **MetaEnvelope** is the top-level container for an entity (post, user, message - **id**: Unique identifier (W3ID). Note: Only IDs registered in the Registry are guaranteed to be globally unique. - **ontology**: Schema identifier (W3ID, e.g., "550e8400-e29b-41d4-a716-446655440001"). Schema W3IDs can be resolved to their schema definitions via the [Ontology](/docs/Infrastructure/Ontology) service. See [W3DS Basics](/docs/W3DS%20Basics/getting-started) for more information on ontology schemas. -- **acl**: Access Control List (who can access this data) +- **acl**: Legacy access control list (who can access this data) +- **_acl**: The granular access policy — grants, denials, and ontology conditions. Takes precedence over `acl` when present. See [Access Control](/docs/W3DS%20Protocol/Access-Control). - **envelopes**: Array of individual Envelope nodes ### Envelopes @@ -83,7 +84,7 @@ Each field in a MetaEnvelope becomes a separate **Envelope** node in Neo4j: In Neo4j, the structure looks like: ```cypher -(MetaEnvelope {id, ontology, acl}) -[:LINKS_TO]-> (Envelope {id, value, valueType}) +(MetaEnvelope {id, ontology, acl, aclBlock}) -[:LINKS_TO]-> (Envelope {id, value, valueType}) ``` This flat graph structure allows: @@ -603,26 +604,36 @@ curl -X GET "http://localhost:4000/logs?limit=20&cursor=2025-02-04T12:00:00.000Z ## Access Control -eVault uses **Access Control Lists (ACLs)** to determine who can access data. +A MetaEnvelope can carry access rules two ways. The granular `_acl` policy is the current model; the legacy `acl` array predates it and still works. -### ACL Format +### The `_acl` policy -ACLs are arrays of W3IDs or special values: +`_acl` holds grants (an eName plus a READ/CREATE/UPDATE/DELETE bitmask), denials, and Resource Link Ontology conditions. Decisions run in a fixed order — denials, then the most specific grant, then the ontology groups — and the most specific grant wins without unioning less specific ones. + +Full model, wire format, and current limits: [Access Control](/docs/W3DS%20Protocol/Access-Control). + +It is stored on the MetaEnvelope node as the `aclBlock` property (JSON), so the policy travels with the record when it syncs. + +### Legacy ACL format + +Arrays of W3IDs or special values: - `["*"]`: Public read access (anyone can read, but only the eVault owner can write) - `["@user-a.w3id"]`: Only User A can access (read and write) - `["@user-a.w3id", "@user-b.w3id"]`: User A and User B can access (read and write) -**Prototype Limitation**: In the current prototype implementation, ACLs provide all-or-nothing access. There is no read-only access without write access (except for `["*"]` which provides read-only access for everyone). More granular permissions are planned for future versions. +The array is all-or-nothing: there is no read-only-without-write except `["*"]`. That is what `_acl` replaces. A record with an `_acl` block ignores its array entirely; a record without one behaves exactly as it always has. ### Access Enforcement -The Access Guard middleware enforces ACLs: +The Access Guard middleware enforces access on every operation, with the permission the operation needs (read for queries, create/update/delete for the corresponding mutations): 1. **Extract W3ID**: From `X-ENAME` header or [Bearer token](/docs/W3DS%20Protocol/Authentication) -2. **Check ACL**: Verify the requesting W3ID is in the MetaEnvelope's ACL -3. **Filter Results**: Remove ACL field from responses (security) -4. **Allow/Deny**: Grant or deny access based on ACL +2. **Check the policy**: If the record carries `_acl`, decide by it. Otherwise fall back to the legacy array. +3. **Filter Results**: Remove `acl` and `_acl` from responses (security) +4. **Allow/Deny** + +A valid Registry-issued platform token satisfies the *legacy* path — but it does **not** bypass an `_acl` policy. A record carrying a policy is decided by that policy for every caller. ### Special Cases diff --git a/docs/docs/Post Platform Guide/access-control.md b/docs/docs/Post Platform Guide/access-control.md new file mode 100644 index 000000000..648830944 --- /dev/null +++ b/docs/docs/Post Platform Guide/access-control.md @@ -0,0 +1,146 @@ +--- +sidebar_position: 6 +--- + +# Implementing Access Control + +Your platform writes records into a user's eVault. By default those records are wide open — anything that syncs to a platform can be read by it. This page is how you narrow that. + +For the model itself — the bitmask, specificity, the decision order — see [Access Control](/docs/W3DS%20Protocol/Access-Control) in the protocol section. This page is the practical side: what to send, what comes back, and what will bite you. + +## What you get if you do nothing + +The Web3 Adapter writes every record with `acl: ["*"]`. That means anyone, everything, and it is what all existing platform data looks like today. + +Nothing about that changes on its own. Records with no `_acl` block keep behaving exactly as they always have, including the part where any platform holding a valid Registry-issued token can reach them. You opt in per record by sending a policy. + +## Setting a policy + +`_acl` is an optional field on the same inputs you already use. + +```graphql +mutation { + createMetaEnvelope(input: { + ontology: "550e8400-e29b-41d4-a716-446655440001" + payload: { content: "…", authorId: "…" } + acl: ["*"] + _acl: { + v: 1 + grants: [ + { ename: "@7b9c2e1a-4f30-4c5e-9a21-d8e0f1a2b3c4", perms: 15 } + { ename: "@2d4f6a8b-1c3e-4d5f-8a9b-0c1d2e3f4a5b", perms: 1 } + ] + denials: { enames: [], conditions: [] } + default_perms: 0 + require: [] + } + }) { + metaEnvelope { id } + errors { message } + } +} +``` + +The owner gets `15` (`0x0F`, everything); one platform gets `1` (`0x01`, read). Nobody else is admitted: `require: []` means no group can pass, so step 3 always refuses. + +Send `acl` as well. It is still required by the schema, and it is what any record without a policy falls back to — but where `_acl` is present it is ignored entirely, so its value does not matter. + +Available on `createMetaEnvelope`, `storeMetaEnvelope`, `updateMetaEnvelope`, `updateMetaEnvelopeById`, `bulkCreateMetaEnvelopes`, and `uploadFile`. + +## Permission values + +| Want | `perms` | Hex | +|---|---|---| +| Read only | `1` | `0x01` | +| Read + add, but not edit | `3` | `0x03` | +| Read + edit | `5` | `0x05` | +| Everything | `15` | `0x0F` | + +Bits: `1` READ, `2` CREATE, `4` UPDATE, `8` DELETE. Union them. + +Two values to avoid sending by accident: + +- **`0`** is not "no permissions", it is *no grant at all* — the party falls through to the ontology step as though you had never named them. To actually give someone nothing, leave them out and let the default refuse them. +- **Anything above `15`** is rejected outright. Bits 4–7 are reserved, and a write that sets one fails loudly rather than being quietly narrowed. + +## Common shapes + +**Owner-only.** Nothing but the owner, no fallback. + +```json +{ "v": 1, + "grants": [ { "ename": "@owner", "perms": 15 } ], + "denials": { "enames": [], "conditions": [] }, + "default_perms": 0, + "require": [] } +``` + +**Public read, owner writes.** The empty group always passes, so anyone reaches `default_perms`. + +```json +{ "v": 1, + "grants": [ { "ename": "@owner", "perms": 15 } ], + "denials": { "enames": [], "conditions": [] }, + "default_perms": 1, + "require": [ [] ] } +``` + +This is the closest equivalent of the legacy `["*"]`, except that everyone other than the owner is now read-only rather than able to write. + +**Public read, one platform excluded.** + +```json +{ "v": 1, + "grants": [ { "ename": "@owner", "perms": 15 } ], + "denials": { "enames": ["@2d4f6a8b-1c3e-4d5f-8a9b-0c1d2e3f4a5b"], "conditions": [] }, + "default_perms": 1, + "require": [ [] ] } +``` + +A denial beats everything, including a grant to the same party. This is how a user shuts out a platform they do not trust without having to enumerate the ones they do. + +**Append-only log.** A collaborator may add entries but never rewrite or remove one. + +```json +{ "v": 1, + "grants": [ { "ename": "@owner", "perms": 15 }, + { "ename": "@collaborator", "perms": 3 } ], + "denials": { "enames": [], "conditions": [] }, + "default_perms": 0, + "require": [] } +``` + +## Things that will bite you + +**A grant is final.** If your platform is named in `grants`, that grant decides the answer on its own. It never falls through to `default_perms` — so a platform granted `1` on a record whose `default_perms` is `15` has read access, not full access. Being named is not always an upgrade. + +**The most specific grant wins outright.** A grant to a user beats one to a platform, and they are not combined. If a record grants your platform `15` and the acting user `1`, a request carrying that user identity gets `1`. The platform's broader grant is not consulted. + +**A valid platform token does not open a policied record.** It still works on records with no `_acl`. That bypass is exactly what a policy exists to close, so do not rely on your token to reach data a user has locked down — handle the refusal instead. + +**Updates preserve the policy.** An `updateMetaEnvelope` that omits `_acl` leaves the stored policy alone rather than clearing it. To change a policy, send the new one in full — it replaces, it does not merge. + +**You never read a policy back.** `_acl` is stripped from every response, like `acl` always has been. If your platform needs to show a user their own sharing settings, keep that state on your side; you cannot query it out of the eVault. + +**Refusals look like two different things.** A record you may not touch raises `Access denied`. A record that does not exist for that eName returns `null`. Do not treat the second as the first — retrying will not help, and neither will asking for a different verb. + +## The adapter does not do this yet + +`EVaultClient` hardcodes `acl: ["*"]` and has no `_acl` parameter, so records written through `handleChange` cannot carry a policy today. To set one, call the eVault GraphQL endpoint directly for that record. + +Everything else about your integration is unchanged — mapping, webhooks, and the Awareness Protocol do not interact with the policy. The policy is stored inside the record, so it travels with the data when it syncs, without your webhook controller doing anything. + +## Not usable yet + +Two parts of the protocol document are specified but not connected, and a policy relying on them will not behave as written: + +- **Groups.** Group membership is not resolved, so a grant or denial naming a group matches nobody. A group grant simply fails to apply; a **group denial silently fails to deny**, which is the dangerous direction. Name parties individually for now. +- **Ontology conditions.** No evaluator is wired in, so any condition fails. A `require` group containing conditions can never pass, and a deny condition always fires and refuses everyone. Until that lands, use only `grants`, `denials.enames`, and `require: []` or `require: [[]]`. + +Enforcement is eVault-side. Platforms and the adapter do not evaluate policies themselves, so do not treat a policy as a reason to skip your own authorization checks. + +## See also + +- [Access Control](/docs/W3DS%20Protocol/Access-Control) — the protocol model and wire format +- [eVault](/docs/Infrastructure/eVault) — where policies are stored and enforced +- [Webhook Controller](/docs/Post%20Platform%20Guide/webhook-controller) — the inbound side, unaffected by policies diff --git a/docs/docs/Post Platform Guide/ai-agent-skill.md b/docs/docs/Post Platform Guide/ai-agent-skill.md index 74e953f31..1fff42614 100644 --- a/docs/docs/Post Platform Guide/ai-agent-skill.md +++ b/docs/docs/Post Platform Guide/ai-agent-skill.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 --- # AI Agent Skill diff --git a/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md b/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md index ec24392c6..9e22650c4 100644 --- a/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md +++ b/docs/docs/Post Platform Guide/ecurrency-accounts-and-ledger.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # eCurrency: Accounts and Ledger MetaEnvelopes diff --git a/docs/docs/Post Platform Guide/platform-evault-registration.md b/docs/docs/Post Platform Guide/platform-evault-registration.md index 936b968f6..1147eb6e9 100644 --- a/docs/docs/Post Platform Guide/platform-evault-registration.md +++ b/docs/docs/Post Platform Guide/platform-evault-registration.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 --- # Registering a Platform eVault diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md index a25e721db..267fd04a3 100644 --- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 10 --- # PP Auth demonstrator diff --git a/docs/docs/Post Platform Guide/pp-auth.md b/docs/docs/Post Platform Guide/pp-auth.md index 7d6ba68a1..13d7a14e8 100644 --- a/docs/docs/Post Platform Guide/pp-auth.md +++ b/docs/docs/Post Platform Guide/pp-auth.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 --- # Authenticating your platform diff --git a/docs/docs/W3DS Basics/Access-Policy.md b/docs/docs/W3DS Basics/Access-Policy.md index ebcdaeb08..b0714f518 100644 --- a/docs/docs/W3DS Basics/Access-Policy.md +++ b/docs/docs/W3DS Basics/Access-Policy.md @@ -63,6 +63,15 @@ The three gates run in order, and each can only narrow the one before it: A grant cannot widen a certificate. Permitting `health:Read` to a platform never certified for `health` changes nothing. +## Where the record's own rules fit + +The policy above is a signed statement about a *subject* — which platforms an owner will deal with at all. It is not stored in the data it protects. + +[Access control](/docs/W3DS%20Protocol/Access-Control) is the other half: an `_acl` block inside each record, naming parties and the verbs they hold, plus ontology conditions admitting platforms that were never named. That block is what the eVault evaluates on each request, and it travels with the record when it syncs. + +The two are separate gates and neither can widen the other. + ## See also +- [Access Control](/docs/W3DS%20Protocol/Access-Control) — the per-record `_acl` policy the eVault enforces - [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication) — how a platform proves which release it is running diff --git a/docs/docs/W3DS Basics/glossary.md b/docs/docs/W3DS Basics/glossary.md index 60bb697ef..5cd61d3d1 100644 --- a/docs/docs/W3DS Basics/glossary.md +++ b/docs/docs/W3DS Basics/glossary.md @@ -10,7 +10,7 @@ Definitions of key terms used across the W3DS and MetaState documentation. Where ## Access -The ability to retrieve or interact with data or services based on permissions and [authentication](#authentication). In W3DS, access to eVault data is governed by [ACLs](/docs/Infrastructure/eVault#access-control) and [resolution](/docs/Infrastructure/Registry#get-resolve) of identities. +The ability to retrieve or interact with data or services based on permissions and [authentication](#authentication). In W3DS, access to eVault data is governed by the record's own access policy — [granular access control](/docs/W3DS%20Protocol/Access-Control) or the legacy [ACL](/docs/Infrastructure/eVault#access-control) array — and by [resolution](/docs/Infrastructure/Registry#get-resolve) of identities. --- @@ -58,7 +58,7 @@ A non-human object within the MetaState such as an organization, a platform, a b ## Envelope -The smallest unit of data in an [eVault](#evault), addressable by its unique identifier and [ontology](/docs/Infrastructure/Ontology) reference. Each envelope has an attached ontological definition and ACL that defines who is allowed to access it. See [eVault — Data Model](/docs/Infrastructure/eVault#data-model) for how Envelopes are stored and used. +The smallest unit of data in an [eVault](#evault), addressable by its unique identifier and [ontology](/docs/Infrastructure/Ontology) reference. Each envelope has an attached ontological definition, and its MetaEnvelope carries the access policy that defines who is allowed to do what with it. See [eVault — Data Model](/docs/Infrastructure/eVault#data-model) for how Envelopes are stored and used. --- diff --git a/docs/docs/W3DS Protocol/Access-Control.md b/docs/docs/W3DS Protocol/Access-Control.md new file mode 100644 index 000000000..cccb272f8 --- /dev/null +++ b/docs/docs/W3DS Protocol/Access-Control.md @@ -0,0 +1,170 @@ +--- +sidebar_position: 7 +--- + +# Access Control + +An eVault record carries its own access rules. They say which parties may read it, add to it, change it, or delete it — and, separately, what a platform must *be* before it may read at all. + +The rules live inside the record, not in a table beside it. Data syncs between platforms, and a central rules table would not follow it; the protection would be lost the moment the data moved. Keeping the policy in the record keeps it attached. + +## Why the second half exists + +Naming every acceptable platform by hand does not scale, and it misses what an owner actually wants to say: *any platform may read this, provided it is reputable enough*. So a policy has two halves. Grants and denials name parties. The **Resource Link Ontology** sets quality conditions that admit a platform that was never named individually — and refuse one that was. + +## Parties + +Every party is an eName, `@`. The same form identifies a user, a platform, or a group; a group stands for the set of its members and is resolved to them at check time. Ontologies are parties too and carry their own eName. + +``` +@7b9c2e1a-4f30-4c5e-9a21-d8e0f1a2b3c4 a user +@2d4f6a8b-1c3e-4d5f-8a9b-0c1d2e3f4a5b a platform +@9f0e1d2c-3b4a-5968-7766-554433221100 a group +@1a1a1a1a-0000-0000-0000-000000000001 an ontology (eReputation) +``` + +## Permissions + +Four permissions, held as a bitmask in a single unsigned byte. Bits are independent and combine by union. + +| Bit | Value | Permission | Meaning | +|---|---|---|---| +| 0 | `0x01` | READ | May view the data. | +| 1 | `0x02` | CREATE | May add new records. | +| 2 | `0x04` | UPDATE | May change existing content. | +| 3 | `0x08` | DELETE | May remove the data. | +| 4–7 | — | reserved | Must be `0`. | + +`0x0F` is full access. `0x01` is read-only. `0x03` is read plus **add-only** — a party that may add new records but not change existing ones. `0x00` is meaningless and counts as no grant at all. + +A write that sets a reserved bit is rejected rather than quietly narrowed, so a client built against a later version of this spec fails loudly instead of silently receiving weaker permissions than it asked for. + +## Grants + +A record carries a list of grants, each naming one party and the permissions it holds. + +Where several grants could apply to the same request, **the most specific one wins**: a grant to a user beats one to a platform, which beats one to a group the party belongs to. Only that grant is used. Less specific grants do not add to it. + +``` +grants: [ + { ename: @9f0e…1100, perms: 0x05 }, // a group: READ + UPDATE + { ename: @7b9c…b3c4, perms: 0x01 } // a member of it: READ +] +``` + +That member has **READ only**. The direct grant is more specific, so the group's UPDATE is never consulted for them. + +Grants tied at the same specificity — duplicates, or two groups the party belongs to — are unioned. Nothing orders one above the other, and picking arbitrarily would make the outcome depend on storage order. + +A direct grant is final. A named party never falls through to the ontology half, whether its grant allowed the action or not. + +## Denials + +A denial removes access regardless of any grant. **Deny always wins**, with no exceptions — it is the one place where specificity does not decide the outcome. + +A denial names a party, or states a condition. A denial by name matches the party itself, the platform carrying its request, or any group it belongs to. A denial by condition applies to anyone who **fails** the check. + +``` +grants: [ { ename: @2d4f…4a5b, perms: 0x01 } ] +denials.enames: [ @2d4f…4a5b ] +``` + +Refused. The denial overrides the grant. + +## Conditions + +An ontology is a structured description of some quality, published as a JSON Schema, referenced by its eName. To use one, point a JSONPath into it and attach a numeric requirement to the value found there. + +``` +{ ontology: @1a1a1a1a-…-0001, path: "$.score", op: ">=", value: 60 } +``` + +Operators are numeric only: `>=`, `>`, `<=`, `<`, `==`. The score is held on the eVault of the platform that is its subject. + +A path that is missing, resolves to several nodes, or resolves to a non-numeric value is a **failed** condition — never a passing one. A condition never fails open. + +## Combining conditions + +Conditions are organised into groups. Within a group all conditions must pass; across groups any one group passing is enough. It is an OR of ANDs. + +``` +require: [ + [ { @sec, $.score, >=, 80 }, { @erep, $.score, >=, 60 } ], // Group A + [ { @erep, $.score, >=, 90 } ] // Group B +] +``` + +A platform is admitted if it clears security *and* reputation together, or clears a higher reputation bar on its own. Groups are evaluated in order and the first passing group decides. + +An empty group is an AND over zero conditions, so it always passes — that is how a policy says "admit anyone, subject to the denials". + +## How a decision is reached + +For a party **P** requesting action **A** on record **R**: + +1. **Denials.** If any denial applies — by eName, or by a failing deny condition — refuse. Nothing below overrides this. +2. **A direct grant.** If P is named, take the single most specific applicable grant and allow only if its bitmask includes A. A grant decides the outcome on its own; step 3 is not reached. +3. **The ontology.** If at least one group in `require` passes for P, allow if `default_perms` includes A. Otherwise refuse. + +``` +grants: { @platform-2d4f: 0x01 } +denials.enames: [ @platform-bad1 ] +default_perms: 0x01 +require: [ [ {@sec,$.score,>=,80}, {@erep,$.score,>=,60} ], + [ {@erep,$.score,>=,90} ] ] + +@platform-bad1 asks READ -> refused at step 1. +@platform-2d4f asks READ -> allowed at step 2 (0x01 includes READ). +@platform-2d4f asks DELETE -> refused (0x01 lacks DELETE); step 3 not reached. +unnamed, sec 84 + erep 72 -> Group A passes; READ allowed at step 3. +unnamed, erep 95, no sec -> Group A fails on the missing score, Group B passes. +``` + +## The `_acl` block + +The policy sits beside the payload in the record it protects. + +```json +{ + "...payload...": "...", + "_acl": { + "v": 1, + "grants": [ { "ename": "@", "perms": 1 } ], + "denials": { + "enames": ["@"], + "conditions": [] + }, + "default_perms": 1, + "require": [ [ { "ontology": "@", "path": "$.score", "op": ">=", "value": 60 } ] ] + } +} +``` + +Supply it on `createMetaEnvelope`, `storeMetaEnvelope`, `bulkCreateMetaEnvelopes`, `updateMetaEnvelope`, `updateMetaEnvelopeById`, or `uploadFile`. Like the legacy `acl` array, it is never returned to callers. + +An update that does not carry `_acl` leaves the stored policy alone rather than clearing it. + +## Relationship to the legacy `acl` array + +The older `acl: ["*"]` array still works and is unchanged. Where a record has no `_acl`, the array is read as before. Where a record has one, **`_acl` is authoritative and the array is ignored**. + +A legacy array is interpreted as: + +- `["*"]` → `default_perms` of `0x0F` behind an always-passing group: anyone, everything. +- `["@some-ename"]` → a `0x0F` grant to that eName, and nobody else admitted. + +This matters for one behaviour in particular. Under the legacy model, any platform holding a valid Registry-issued token could reach any record. **A record carrying an `_acl` block is decided by that block for every caller, token or not.** Closing that bypass is the point of the model. Records without a policy keep their existing behaviour exactly, so nothing narrows until an owner sets one. + +## Current limits + +- **Group membership is not resolved yet.** A grant or denial naming a group matches nothing. For grants that is fail-closed; for denials it is fail-**open**, so group denials are not usable yet. +- **Condition evaluation is a seam, not yet connected.** eVault accepts an evaluator but none is wired in, so conditions currently fail closed: a `require` group containing conditions cannot pass, and a deny condition always fires. Until an evaluator is connected, write policies that use `grants`, `denials.enames`, and empty-group `require` only. +- **Enforcement is eVault-side.** The Web3 Adapter and platforms do not evaluate `_acl` yet. +- `default_perms` above READ for unnamed parties is unsettled under the current sync model. + +## See also + +- [Implementing Access Control](/docs/Post%20Platform%20Guide/access-control) — how a platform sets a policy, with worked examples and the current gotchas +- [Access Policy](/docs/W3DS%20Basics/Access-Policy) — the owner's signed statement about *which platforms they will deal with at all*. That runs before this; the two are separate gates and neither can widen the other. +- [eVault](/docs/Infrastructure/eVault) — where the policy is stored and enforced. +- [eName](/docs/W3DS%20Basics/eName) — the party identifier. diff --git a/skills/w3ds/SKILL.md b/skills/w3ds/SKILL.md index 16208c040..ce3d5ee5d 100644 --- a/skills/w3ds/SKILL.md +++ b/skills/w3ds/SKILL.md @@ -85,7 +85,7 @@ Common confusion points — internalize these once: - Always resolve the eVault URL for a user via the Registry before hitting `/graphql` or `/whois`. Do not hardcode eVault URLs. - Every GraphQL and HTTP call to eVault needs `X-ENAME`. Missing this header is the most common cause of 400s. -- ACLs in the prototype are all-or-nothing except for `["*"]`. There is no read-only-without-write yet. +- Two ACL models coexist. The `_acl` block gives per-verb grants (READ/CREATE/UPDATE/DELETE bitmask), denials, and ontology conditions, and is authoritative where present. The legacy `acl` string array is all-or-nothing except `["*"]` and still applies to records with no `_acl`. Do not describe ACLs as all-or-nothing without that distinction — see [reference/evault.md](reference/evault.md). - Webhook delivery is fire-and-forget and prototype-level: no retries, no ordering, no at-least-once. Design your platform's webhook controller to be **idempotent** on global `id`. - After `storeMetaEnvelope` there is a 3-second delay before webhook fanout to prevent ping-pong. `updateMetaEnvelopeById` fanout is immediate. - If the user is running things locally, refer them to [reference/dev-setup.md](reference/dev-setup.md) before troubleshooting — most sync bugs come from a service that isn't running or a missing env var. diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index c5da4a952..a810e8bbb 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -272,21 +272,46 @@ Wallet endpoint for key sync. Body: `{ publicKey }`. Headers: `X-ENAME` (require ## Access control -ACLs are string arrays on each MetaEnvelope: +Two models. `_acl` is current; the `acl` string array predates it and still works. + +### `_acl` (granular) + +An optional input field on `createMetaEnvelope`, `storeMetaEnvelope`, `bulkCreateMetaEnvelopes`, `updateMetaEnvelope`, `updateMetaEnvelopeById`, and `uploadFile`. Stored on the MetaEnvelope node as the `aclBlock` JSON property, so it travels with the record. + +``` +_acl: { + v: 1, + grants: [ { ename: "@", perms: 1 } ], // u8 bitmask + denials: { enames: ["@"], conditions: [] }, + default_perms: 1, // unnamed parties that pass a group + require: [ [ { ontology: "@", path: "$.score", op: ">=", value: 60 } ] ] +} +``` + +Perms bitmask: `0x01` READ, `0x02` CREATE, `0x04` UPDATE, `0x08` DELETE. `0x0F` full, `0x03` read + add-only. Bits 4-7 reserved, must be 0 — a write that sets them is rejected. `0x00` counts as no grant. + +Decision order, fixed: (1) denials — by eName or a **failing** condition — always win; (2) the single most specific grant (user > platform > group, no union across specificity) decides on its own; (3) otherwise a passing `require` group admits at `default_perms`. `require` is an OR of groups, each an AND of conditions; an empty group always passes. + +Never guess these: a missing/multi-valued/non-numeric condition path **fails**, never passes. A named party never falls through from step 2 to step 3. + +### Legacy `acl` array - `["*"]` — anyone can read; only the eVault owner can write. - `["@user-a.w3id"]` — user A can read AND write. -- `["@user-a.w3id", "@user-b.w3id"]` — both can read and write. -Prototype limitation: no read-only-without-write except for `["*"]`. Fine-grained perms are on the roadmap. +All-or-nothing: no read-only-without-write except `["*"]`. Where a record has `_acl`, the array is ignored entirely; where it does not, the array behaves exactly as before. Access enforcement flow: 1. Extract W3ID from `X-ENAME` header or Bearer token. -2. Check requester's W3ID is in the ACL. -3. Strip the ACL field from the response (security). +2. If the record carries `_acl`, decide by it, against the permission the operation needs. Otherwise check the requester's W3ID against the legacy array. +3. Strip `acl` and `_acl` from the response (security). 4. Grant or deny. +A valid platform Bearer token satisfies the legacy path but does **not** bypass an `_acl` policy. + +Not yet wired: group membership is not resolved (group grants match nothing — fail-closed; group denials also match nothing — fail-**open**), and no condition evaluator is connected, so any `require` group containing conditions fails closed. Write policies using `grants`, `denials.enames`, and empty-group `require` only. Full model: `docs/docs/W3DS Protocol/Access-Control.md`. + Special cases: - `storeMetaEnvelope` (legacy `createMetaEnvelope` alias): requires only `X-ENAME`, no Bearer token. diff --git a/skills/w3ds/reference/identity.md b/skills/w3ds/reference/identity.md index 76df25843..e0d5b6e46 100644 --- a/skills/w3ds/reference/identity.md +++ b/skills/w3ds/reference/identity.md @@ -47,7 +47,7 @@ Determines: which eVault to route the request to, ACL enforcement, log ownership - **Users, groups**: each has a persistent eName that anchors keys and (via binding documents) physical identity. - **eVaults**: an eVault has its own internal W3ID (used for clone sync). The owner's eName identifies the "owner" for ACL / whois purposes. - **MetaEnvelopes**: `id` is a W3ID. Ownership is by the eVault whose owner-eName was in `X-ENAME` at creation time. -- **ACLs**: arrays of eNames (or `["*"]`). See [evault.md](evault.md#access-control). +- **ACLs**: the `_acl` policy names parties by eName in its grants and denials; the legacy `acl` array is eNames (or `["*"]`). See [evault.md](evault.md#access-control). - **Key binding certificates**: JWTs whose payload binds an eName to a public key. ## Binding Documents @@ -122,4 +122,5 @@ The W3ID system supports binding an identity to a passport or other physical doc - eName vs W3ID: `docs/docs/W3DS Basics/eName.md` - Binding document types + operations: `docs/docs/W3DS Basics/Binding-Documents.md` - ACL semantics: `docs/docs/Infrastructure/eVault.md` (§ Access Control) +- Granular `_acl` permissions: `docs/docs/W3DS Protocol/Access-Control.md` - Key binding certificates: `docs/docs/Infrastructure/eVault-Key-Delegation.md` From 32d037148d0a9c5d80ada9d0473adcb5198fbc0c Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 15:30:40 +0800 Subject: [PATCH 4/7] feat: let a platform declare the user it acts for, and expose the policy in force --- .../src/core/protocol/graphql-server.ts | 17 +- .../evault-core/src/core/protocol/typedefs.ts | 47 +++++ .../core/protocol/vault-access-guard.spec.ts | 163 +++++++++++++++++- .../src/core/protocol/vault-access-guard.ts | 45 ++++- infrastructure/evault-core/src/index.ts | 4 +- 5 files changed, 266 insertions(+), 10 deletions(-) diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 9014c07a0..d3692a552 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -1,5 +1,5 @@ import { Server } from "http"; -import { aclBlockFromInput, Permission } from "../acl"; +import { aclBlockFromInput, Permission, resolveAclBlock } from "../acl"; import axios from "axios"; import type { GraphQLSchema } from "graphql"; import { createSchema, createYoga } from "graphql-yoga"; @@ -175,6 +175,14 @@ export class GraphQLServer { fieldKey: (parent: any) => parent.ontology, }, + MetaEnvelope: { + // Always answer with the policy actually enforced, so a record + // carrying only a legacy array reads back the same shape as one + // carrying an explicit block. + _acl: (parent: any) => + resolveAclBlock({ _acl: parent?._acl, acl: parent?.acl }), + }, + Query: { // ============================================================ // NEW IDIOMATIC API @@ -1732,6 +1740,10 @@ export class GraphQLServer { request.headers.get("x-ename") ?? request.headers.get("X-ENAME") ?? null; + const onBehalfOf = + request.headers.get("x-on-behalf-of") ?? + request.headers.get("X-ON-BEHALF-OF") ?? + null; if (token) { try { @@ -1739,12 +1751,14 @@ export class GraphQLServer { return { currentUser: id ?? null, eName: eName, + onBehalfOf, }; } catch (error) { // Invalid JWT token - ignore and continue without currentUser return { currentUser: null, eName: eName, + onBehalfOf, }; } } @@ -1752,6 +1766,7 @@ export class GraphQLServer { return { currentUser: null, eName: eName, + onBehalfOf, }; }, }); diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index c4d3af920..afc8c3617 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -12,12 +12,59 @@ export const typeDefs = /* GraphQL */ ` valueType: String } + # ------------------------------------------------------------------ + # Access control (output) + # ------------------------------------------------------------------ + + "A numeric requirement on a value inside an ontology" + type AclCondition { + "The ontology's eName" + ontology: String! + "JSONPath into the ontology value, e.g. $.score" + path: String! + "One of >=, >, <=, <, ==" + op: String! + value: Float! + } + + "One named party and the permissions it holds" + type AclGrant { + ename: String! + "Bitmask: 0x01 READ, 0x02 CREATE, 0x04 UPDATE, 0x08 DELETE" + perms: Int! + } + + "Access removals. A denial always wins over any grant." + type AclDenials { + enames: [String!]! + conditions: [AclCondition!]! + } + + """ + The access policy enforced for a record. + + Always the policy actually in force: a record carrying only the legacy + \`acl\` array is reported as the block that array is interpreted as, so + callers see one shape regardless of how the record was written. + """ + type AclBlock { + v: Int! + grants: [AclGrant!]! + denials: AclDenials! + "Permissions for unnamed parties that pass a require group" + default_perms: Int! + "OR of groups, each an AND of conditions" + require: [[AclCondition!]!]! + } + type MetaEnvelope { id: String! "The ontology schema ID (W3ID)" ontology: String! envelopes: [Envelope!]! parsed: JSON + "The access policy in force for this record" + _acl: AclBlock } "Result type for legacy storeMetaEnvelope and updateMetaEnvelopeById mutations" diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts index d28ac45f6..acfb23e70 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts @@ -1109,7 +1109,7 @@ describe("granular _acl policies", () => { ).resolves.toBeDefined(); }); - it("never returns the policy to the caller", async () => { + it("returns the policy but never the legacy array", async () => { const eName = "@vault-granular-8"; const id = await storeWithPolicy(eName, { v: 1, @@ -1125,8 +1125,167 @@ describe("granular _acl policies", () => { const resolver = vi.fn(async () => stored); const returned: any = await guard.middleware(resolver)(null, { id }, context); - expect(returned).not.toHaveProperty("_acl"); expect(returned).not.toHaveProperty("acl"); + expect(returned._acl.grants).toEqual([ + { ename: PLATFORM, perms: 0x0f }, + ]); + }); + + it("reports a legacy record as the policy it is actually enforced as", async () => { + const eName = "@vault-granular-9"; + const result = await dbService.storeMetaEnvelope( + { ontology: "Test", payload: { field: "value" }, acl: ["*"] }, + ["*"], + eName, + ); + const id = result.metaEnvelope.id; + + const context = await contextFor(eName, { platform: PLATFORM }); + const stored = await dbService.findMetaEnvelopeById(id, eName); + const resolver = vi.fn(async () => stored); + const returned: any = await guard.middleware(resolver)(null, { id }, context); + + // ["*"] is everyone, everything -- expressed as an always-passing group. + expect(returned).not.toHaveProperty("acl"); + expect(returned._acl.default_perms).toBe(0x0f); + expect(returned._acl.require).toEqual([[]]); + }); + }); + + describe("delegated identity (X-ON-BEHALF-OF)", () => { + const PLATFORM = "@platform-delegating"; + const USER = "@user-delegated"; + + const storeWithPolicy = async (eName: string, _acl: any) => { + const result = await dbService.storeMetaEnvelope( + { ontology: "Test", payload: { field: "value" }, acl: ["*"], _acl }, + ["*"], + eName, + ); + return result.metaEnvelope.id; + }; + + const contextFor = async ( + eName: string, + claims: any, + extra: Partial = {}, + ) => { + const token = await createValidToken(claims); + return createMockContext({ + eName, + request: { + headers: new Headers({ authorization: `Bearer ${token}` }), + } as any, + ...extra, + }); + }; + + it("takes the asserted user as the party, outranking the platform grant", async () => { + const eName = "@vault-delegated-1"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [ + { ename: PLATFORM, perms: 0x01 }, + { ename: USER, perms: 0x0f }, + ], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }, { + onBehalfOf: USER, + }); + const resolver = vi.fn(async () => ({ id })); + + // The user grant is more specific, so it decides -- even though the + // platform carrying the request holds only READ. + await expect( + guard.middleware(resolver, Permission.DELETE)(null, { id }, context), + ).resolves.toBeDefined(); + }); + + it("falls back to the platform when no user is asserted", async () => { + const eName = "@vault-delegated-2"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [ + { ename: PLATFORM, perms: 0x01 }, + { ename: USER, perms: 0x0f }, + ], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver, Permission.DELETE)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("cannot be used to escape a denial on the carrying platform", async () => { + const eName = "@vault-delegated-3"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: USER, perms: 0x0f }], + denials: { enames: [PLATFORM], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }, { + onBehalfOf: USER, + }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("ignores a currentUser that is a signing-key id rather than a party", async () => { + const eName = "@vault-delegated-4"; + // A Registry platform token's JWT kid is "entropy-key-1", which the + // context surfaces as currentUser. It must not authorize anything. + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: "entropy-key-1", perms: 0x0f }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }, { + currentUser: "entropy-key-1", + }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver)(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("ignores an asserted identity that is not an eName", async () => { + const eName = "@vault-delegated-5"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: "not-an-ename", perms: 0x0f }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, { platform: PLATFORM }, { + onBehalfOf: "not-an-ename", + }); + const resolver = vi.fn(async () => ({ id })); + + await expect( + guard.middleware(resolver)(null, { id }, context), + ).rejects.toThrow("Access denied"); }); }); }); diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 6ea9ba119..fe63b73d0 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -7,6 +7,7 @@ import { Permission, type PermissionBits, type Principal, + resolveAclBlock, } from "../acl"; import type { DbService } from "../db/db.service"; import type { MetaEnvelope } from "../db/types"; @@ -16,8 +17,22 @@ export type VaultContext = YogaInitialContext & { currentUser: string | null; tokenPayload?: any; eName: string | null; + /** + * The user a platform declares it is acting for, from `X-ON-BEHALF-OF`. + * An assertion, not a proof — see {@link VaultAccessGuard.principalFor}. + */ + onBehalfOf?: string | null; }; +/** + * Party references are `@`. Anything else is not an identity we will + * authorize against — notably a JWT `kid`, which for a Registry platform token + * is a signing-key id rather than a party. + */ +function isEName(value: unknown): value is string { + return typeof value === "string" && value.startsWith("@") && value.length > 1; +} + type CachedJWKS = { jwks: ReturnType; expiresAt: number; @@ -53,10 +68,26 @@ export class VaultAccessGuard { * wired in here. */ private principalFor(context: VaultContext): Principal | null { - const platform: string | undefined = - context.tokenPayload?.platform ?? undefined; - if (context.currentUser) { - return { ename: context.currentUser, kind: "user", platform }; + const platform = isEName(context.tokenPayload?.platform) + ? context.tokenPayload.platform + : undefined; + + // A platform may declare the user it acts for. The token proves the + // platform, not the user, so this is the platform's assertion and is + // only as trustworthy as the platform making it. It cannot be used to + // escape a denial: denials match the carrying platform too. + const asserted = isEName(context.onBehalfOf) + ? context.onBehalfOf + : undefined; + + // currentUser comes from the JWT `kid`. For a wallet-signed token that + // is the user's W3ID; for a Registry platform token it is a key id, so + // it is only accepted when it actually looks like a party. + const user = + asserted ?? (isEName(context.currentUser) ? context.currentUser : undefined); + + if (user) { + return { ename: user, kind: "user", platform }; } if (platform) { return { ename: platform, kind: "platform" }; @@ -322,7 +353,11 @@ export class VaultAccessGuard { return metaEnvelope; } const { acl, _acl, ...filtered } = metaEnvelope; - return filtered; + if (acl === undefined && _acl === undefined) return filtered; + // The policy is readable by anyone who may read the record. The legacy + // array is not surfaced as such — a record carrying only an array is + // shown as the policy it is actually enforced as. + return { ...filtered, _acl: resolveAclBlock({ _acl, acl }) }; } /** diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index 483042feb..1378395f2 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -41,7 +41,7 @@ expressApp.use( cors({ origin: "*", methods: ["GET", "POST", "OPTIONS", "PATCH"], - allowedHeaders: ["Content-Type", "Authorization", "X-ENAME", "x-shared-secret"], + allowedHeaders: ["Content-Type", "Authorization", "X-ENAME", "X-ON-BEHALF-OF", "x-shared-secret"], credentials: true, }), ); @@ -188,7 +188,7 @@ const initializeEVault = async ( await fastifyServer.register(fastifyCors, { origin: true, // Allow all origins methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization", "X-ENAME", "x-shared-secret"], + allowedHeaders: ["Content-Type", "Authorization", "X-ENAME", "X-ON-BEHALF-OF", "x-shared-secret"], credentials: true, }); From 3eed971d238ae142e80a33dc43a0b13cd327ea72 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 15:30:40 +0800 Subject: [PATCH 5/7] docs: document the on-behalf-of header and reading policies back --- docs/docs/Infrastructure/eVault.md | 2 +- .../Post Platform Guide/access-control.md | 42 +++++++++++++++++- docs/docs/W3DS Protocol/Access-Control.md | 44 ++++++++++++++++++- skills/w3ds/reference/evault.md | 6 ++- skills/w3ds/reference/identity.md | 2 + 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index fa2137efb..7d89c3283 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -630,7 +630,7 @@ The Access Guard middleware enforces access on every operation, with the permiss 1. **Extract W3ID**: From `X-ENAME` header or [Bearer token](/docs/W3DS%20Protocol/Authentication) 2. **Check the policy**: If the record carries `_acl`, decide by it. Otherwise fall back to the legacy array. -3. **Filter Results**: Remove `acl` and `_acl` from responses (security) +3. **Filter Results**: Remove the legacy `acl` array from responses; `_acl` is returned as the policy in force 4. **Allow/Deny** A valid Registry-issued platform token satisfies the *legacy* path — but it does **not** bypass an `_acl` policy. A record carrying a policy is decided by that policy for every caller. diff --git a/docs/docs/Post Platform Guide/access-control.md b/docs/docs/Post Platform Guide/access-control.md index 648830944..827466f06 100644 --- a/docs/docs/Post Platform Guide/access-control.md +++ b/docs/docs/Post Platform Guide/access-control.md @@ -110,6 +110,46 @@ A denial beats everything, including a grant to the same party. This is how a us "require": [] } ``` +## Acting on behalf of a user + +Your platform's token proves your platform. It says nothing about which of your users a request is for, which matters as soon as a policy grants anything at user level. + +Send the user's eName in `X-ON-BEHALF-OF`: + +```http +POST /graphql +Authorization: Bearer +X-ENAME: @ +X-ON-BEHALF-OF: @ +``` + +That user becomes the party the policy is evaluated against, and your platform is recorded alongside them — so a user grant applies at user specificity while a grant to your platform still applies at platform specificity. Omit the header and your platform is the party. + +Two things to be clear about: + +- **It is your assertion, not a proof.** The eVault has no way to check it, so it trusts you. That also means it will let you reach what the user was granted, which may be broader than your own grant. Do not send a user's eName on a request that user did not actually initiate. +- **It will not get you past a denial.** Denials match your platform as well as the asserted user, so a policy that excludes your platform still excludes it whatever name you send. + +Only `@`-prefixed eNames count as parties. Anything else is ignored rather than treated as an identity. + +## Reading a policy back + +`_acl` is a field on `MetaEnvelope`: + +```graphql +query { + metaEnvelope(id: "…") { + _acl { + grants { ename perms } + denials { enames } + default_perms + } + } +} +``` + +You always get the policy actually in force. A record written with only `acl: ["*"]` reports `default_perms: 15` behind an always-passing group rather than returning the array, so you can render one consistent view without caring how the record was written. + ## Things that will bite you **A grant is final.** If your platform is named in `grants`, that grant decides the answer on its own. It never falls through to `default_perms` — so a platform granted `1` on a record whose `default_perms` is `15` has read access, not full access. Being named is not always an upgrade. @@ -120,7 +160,7 @@ A denial beats everything, including a grant to the same party. This is how a us **Updates preserve the policy.** An `updateMetaEnvelope` that omits `_acl` leaves the stored policy alone rather than clearing it. To change a policy, send the new one in full — it replaces, it does not merge. -**You never read a policy back.** `_acl` is stripped from every response, like `acl` always has been. If your platform needs to show a user their own sharing settings, keep that state on your side; you cannot query it out of the eVault. +**A policy is visible to everyone who can read the record.** `_acl` is returned, not stripped — so your denial list tells any permitted reader which platforms the user excluded, and your grant list tells them who else has access. Do not put anything in a policy you would not show to its readers. **Refusals look like two different things.** A record you may not touch raises `Access denied`. A record that does not exist for that eName returns `null`. Do not treat the second as the first — retrying will not help, and neither will asking for a different verb. diff --git a/docs/docs/W3DS Protocol/Access-Control.md b/docs/docs/W3DS Protocol/Access-Control.md index cccb272f8..b2bd21fce 100644 --- a/docs/docs/W3DS Protocol/Access-Control.md +++ b/docs/docs/W3DS Protocol/Access-Control.md @@ -120,6 +120,26 @@ unnamed, sec 84 + erep 72 -> Group A passes; READ allowed at step 3. unnamed, erep 95, no sec -> Group A fails on the missing score, Group B passes. ``` +## Who the requesting party is + +A request reaches the eVault carrying a platform's token. That token proves the platform. It does not say which of the platform's users the request is for, and many requests are made on a user's behalf. + +The `X-ON-BEHALF-OF` header carries that: an eName the platform declares it is acting for. + +``` +Authorization: Bearer +X-ENAME: @ +X-ON-BEHALF-OF: @ +``` + +When present, that user is the party, and the platform carrying the request is recorded alongside it — so a grant to the user applies at user specificity, and a grant to the platform still applies at platform specificity. When absent, the platform itself is the party. + +**This is an assertion, not a proof.** The platform's token does not attest to the user, so the claim is exactly as trustworthy as the platform making it. A platform can therefore reach what a user was granted, including permissions broader than its own. That is deliberate: specificity is what makes a user grant mean anything, and a platform that can write to a vault can already act as its users in other ways. + +What the header cannot do is escape a denial. Denials match the party, the platform carrying the request, **and** the party's groups, so a denied platform stays denied no matter whose name it puts in the header. + +Only an `@`-prefixed eName is accepted as a party. Anything else — notably a JWT `kid`, which for a Registry-issued platform token is a signing-key id rather than a party — is ignored. + ## The `_acl` block The policy sits beside the payload in the record it protects. @@ -140,10 +160,32 @@ The policy sits beside the payload in the record it protects. } ``` -Supply it on `createMetaEnvelope`, `storeMetaEnvelope`, `bulkCreateMetaEnvelopes`, `updateMetaEnvelope`, `updateMetaEnvelopeById`, or `uploadFile`. Like the legacy `acl` array, it is never returned to callers. +Supply it on `createMetaEnvelope`, `storeMetaEnvelope`, `bulkCreateMetaEnvelopes`, `updateMetaEnvelope`, `updateMetaEnvelopeById`, or `uploadFile`. An update that does not carry `_acl` leaves the stored policy alone rather than clearing it. +### Reading it back + +`MetaEnvelope` exposes `_acl`, readable by anyone permitted to read the record. + +```graphql +query { + metaEnvelope(id: "…") { + id + _acl { + grants { ename perms } + denials { enames conditions { ontology path op value } } + default_perms + require { ontology path op value } + } + } +} +``` + +What comes back is always the policy **actually in force**. A record carrying only a legacy `acl` array reports the block that array is interpreted as, so callers see one shape regardless of how the record was written. The legacy array itself is never returned. + +Because the policy is readable by any permitted reader, treat its contents as visible to them: a denial names the parties an owner has excluded, and the grant list names who else holds access. + ## Relationship to the legacy `acl` array The older `acl: ["*"]` array still works and is unchanged. Where a record has no `_acl`, the array is read as before. Where a record has one, **`_acl` is authoritative and the array is ignored**. diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index a810e8bbb..3928660c1 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -290,6 +290,8 @@ _acl: { Perms bitmask: `0x01` READ, `0x02` CREATE, `0x04` UPDATE, `0x08` DELETE. `0x0F` full, `0x03` read + add-only. Bits 4-7 reserved, must be 0 — a write that sets them is rejected. `0x00` counts as no grant. +Readable back: `MetaEnvelope._acl` is exposed to anyone permitted to read the record, and always reports the policy in force — a record with only a legacy array reports the block that array maps to. The legacy `acl` array itself is never returned. + Decision order, fixed: (1) denials — by eName or a **failing** condition — always win; (2) the single most specific grant (user > platform > group, no union across specificity) decides on its own; (3) otherwise a passing `require` group admits at `default_perms`. `require` is an OR of groups, each an AND of conditions; an empty group always passes. Never guess these: a missing/multi-valued/non-numeric condition path **fails**, never passes. A named party never falls through from step 2 to step 3. @@ -305,11 +307,13 @@ Access enforcement flow: 1. Extract W3ID from `X-ENAME` header or Bearer token. 2. If the record carries `_acl`, decide by it, against the permission the operation needs. Otherwise check the requester's W3ID against the legacy array. -3. Strip `acl` and `_acl` from the response (security). +3. Strip the legacy `acl` array from the response; `_acl` is returned. 4. Grant or deny. A valid platform Bearer token satisfies the legacy path but does **not** bypass an `_acl` policy. +**`X-ON-BEHALF-OF`** — optional header naming the user eName a platform is acting for. That user becomes the party (at user specificity) with the platform recorded alongside it; without it the platform is the party. It is the platform's assertion, not a proof, so it can reach what the user was granted — but it cannot escape a denial, since denials match the carrying platform too. Only `@`-prefixed eNames are accepted as parties; a JWT `kid` is not. + Not yet wired: group membership is not resolved (group grants match nothing — fail-closed; group denials also match nothing — fail-**open**), and no condition evaluator is connected, so any `require` group containing conditions fails closed. Write policies using `grants`, `denials.enames`, and empty-group `require` only. Full model: `docs/docs/W3DS Protocol/Access-Control.md`. Special cases: diff --git a/skills/w3ds/reference/identity.md b/skills/w3ds/reference/identity.md index e0d5b6e46..cc397e883 100644 --- a/skills/w3ds/reference/identity.md +++ b/skills/w3ds/reference/identity.md @@ -42,6 +42,8 @@ X-ENAME: @e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a Determines: which eVault to route the request to, ACL enforcement, log ownership. Missing header = 400. +`X-ON-BEHALF-OF: @` is an optional companion: the user a platform declares it is acting for, used as the party when evaluating an `_acl` policy. An assertion, not a proof — see [evault.md](evault.md#access-control). + ## Where W3IDs / eNames appear - **Users, groups**: each has a persistent eName that anchors keys and (via binding documents) physical identity. From 3068aa5d0a7eb05a1112d64e000ae36771462fc9 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 17:04:25 +0800 Subject: [PATCH 6/7] fix: reject a malformed policy on write instead of silently dropping entries --- .../evault-core/src/core/acl/acl.spec.ts | 70 ++++++++++++++ .../evault-core/src/core/acl/acl.ts | 93 ++++++++++++++++++- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/infrastructure/evault-core/src/core/acl/acl.spec.ts b/infrastructure/evault-core/src/core/acl/acl.spec.ts index 769d2cdd3..3537cb850 100644 --- a/infrastructure/evault-core/src/core/acl/acl.spec.ts +++ b/infrastructure/evault-core/src/core/acl/acl.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + aclBlockFromInput, emptyAclBlock, evaluate, fromLegacyAcl, @@ -493,3 +494,72 @@ describe("normalizeAclBlock", () => { expect(normalizeAclBlock("nope")).toEqual(emptyAclBlock()); }); }); + +describe("aclBlockFromInput: caller input is validated strictly", () => { + const ok = { + v: 1, + grants: [{ ename: USER, perms: 0x01 }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }; + + it("accepts a well-formed block and returns undefined for none", () => { + expect(aclBlockFromInput(ok)?.grants).toEqual([{ ename: USER, perms: 0x01 }]); + expect(aclBlockFromInput(undefined)).toBeUndefined(); + expect(aclBlockFromInput(null)).toBeUndefined(); + }); + + it("rejects a deny condition it cannot parse rather than dropping it", () => { + // Dropping this would remove a denial and widen access. + expect(() => + aclBlockFromInput({ + ...ok, + denials: { + enames: [], + conditions: [{ ontology: EREP, path: "$.score", op: "~=", value: 60 }], + }, + }), + ).toThrow(/unknown operator/); + }); + + it("rejects a malformed require condition, naming where it is", () => { + expect(() => + aclBlockFromInput({ + ...ok, + require: [[{ ontology: EREP, path: "$.score", op: ">=", value: "sixty" }]], + }), + ).toThrow(/require\[0\]\[0\] needs a finite numeric value/); + }); + + it("rejects a grant with no ename", () => { + expect(() => aclBlockFromInput({ ...ok, grants: [{ perms: 0x01 }] })).toThrow( + /each grant needs an ename/, + ); + }); + + it("rejects reserved permission bits", () => { + expect(() => + aclBlockFromInput({ ...ok, grants: [{ ename: USER, perms: 0x10 }] }), + ).toThrow(/reserved/); + expect(() => aclBlockFromInput({ ...ok, default_perms: 0xff })).toThrow(/reserved/); + }); + + it("rejects a version it does not understand", () => { + expect(() => aclBlockFromInput({ ...ok, v: 2 })).toThrow(/Unsupported _acl version: 2/); + }); + + it("rejects wrong container shapes", () => { + expect(() => aclBlockFromInput([])).toThrow(/expected an object/); + expect(() => aclBlockFromInput({ ...ok, grants: {} })).toThrow(/grants must be an array/); + expect(() => aclBlockFromInput({ ...ok, require: [{}] })).toThrow( + /require\[0\] must be an array/, + ); + }); + + it("still reads malformed *stored* data liberally", () => { + // Stored data is normalised, not rejected -- a corrupt record must stay + // readable, and dropping an unparseable grant there only narrows access. + expect(normalizeAclBlock({ grants: [{ perms: 0x01 }] }).grants).toEqual([]); + }); +}); diff --git a/infrastructure/evault-core/src/core/acl/acl.ts b/infrastructure/evault-core/src/core/acl/acl.ts index 78c6d37f4..1d93b4311 100644 --- a/infrastructure/evault-core/src/core/acl/acl.ts +++ b/infrastructure/evault-core/src/core/acl/acl.ts @@ -339,26 +339,109 @@ export async function evaluate( * against a newer version of the spec fails loudly instead of getting weaker * permissions than it asked for. */ +function validateConditionInput(raw: unknown, where: string): void { + if (typeof raw !== "object" || raw === null) { + throw new Error(`Invalid _acl: ${where} must be an object`); + } + const c = raw as Record; + if (typeof c.ontology !== "string" || c.ontology.length === 0) { + throw new Error(`Invalid _acl: ${where} needs an ontology eName`); + } + if (typeof c.path !== "string" || c.path.length === 0) { + throw new Error(`Invalid _acl: ${where} needs a path`); + } + if (!isOperator(c.op)) { + throw new Error( + `Invalid _acl: ${where} has an unknown operator ${JSON.stringify(c.op)}; expected one of >=, >, <=, <, ==`, + ); + } + if (typeof c.value !== "number" || !Number.isFinite(c.value)) { + throw new Error(`Invalid _acl: ${where} needs a finite numeric value`); + } +} + +/** + * Coerces a caller-supplied policy into a block, or `undefined` when the caller + * supplied none — which is not the same as an empty policy, and must leave the + * record on its legacy array rather than locking it. + * + * Caller input is validated strictly and rejected on anything malformed, while + * stored data is read liberally. Dropping an entry we cannot parse is safe for + * a grant but not for a denial: a deny condition silently discarded would + * *widen* access, and a caller would have no way to tell its policy was not + * the one being enforced. + */ export function aclBlockFromInput(raw: unknown): AclBlock | undefined { if (raw === null || raw === undefined) return undefined; - if (typeof raw !== "object") { + if (typeof raw !== "object" || Array.isArray(raw)) { throw new Error("Invalid _acl: expected an object"); } const block = raw as Record; if (block.v !== undefined && block.v !== 1) { - throw new Error(`Unsupported _acl version: ${String(block.v)}`); + throw new Error( + `Unsupported _acl version: ${String(block.v)}; this eVault understands version 1`, + ); } - if (Array.isArray(block.grants)) { + + if (block.grants !== undefined) { + if (!Array.isArray(block.grants)) { + throw new Error("Invalid _acl: grants must be an array"); + } for (const grant of block.grants) { - if (typeof grant === "object" && grant !== null) { - validatePerms((grant as Record).perms); + if (typeof grant !== "object" || grant === null) { + throw new Error("Invalid _acl: each grant must be an object"); } + const entry = grant as Record; + if (typeof entry.ename !== "string" || entry.ename.length === 0) { + throw new Error("Invalid _acl: each grant needs an ename"); + } + validatePerms(entry.perms); } } + + if (block.denials !== undefined) { + if (typeof block.denials !== "object" || block.denials === null) { + throw new Error("Invalid _acl: denials must be an object"); + } + const denials = block.denials as Record; + if (denials.enames !== undefined) { + if (!Array.isArray(denials.enames)) { + throw new Error("Invalid _acl: denials.enames must be an array"); + } + for (const ename of denials.enames) { + if (typeof ename !== "string" || ename.length === 0) { + throw new Error( + "Invalid _acl: each denials.enames entry must be a non-empty string", + ); + } + } + } + if (denials.conditions !== undefined) { + if (!Array.isArray(denials.conditions)) { + throw new Error("Invalid _acl: denials.conditions must be an array"); + } + denials.conditions.forEach((c, i) => + validateConditionInput(c, `denials.conditions[${i}]`), + ); + } + } + if (block.default_perms !== undefined) { validatePerms(block.default_perms); } + if (block.require !== undefined) { + if (!Array.isArray(block.require)) { + throw new Error("Invalid _acl: require must be an array of groups"); + } + block.require.forEach((group, g) => { + if (!Array.isArray(group)) { + throw new Error(`Invalid _acl: require[${g}] must be an array of conditions`); + } + group.forEach((c, i) => validateConditionInput(c, `require[${g}][${i}]`)); + }); + } + return normalizeAclBlock(raw); } From 84da8a483ee92ceacbe64a4466ca2437695acf36 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 17:04:25 +0800 Subject: [PATCH 7/7] docs: document policy validation, errors, versioning and rollback --- docs/docs/Infrastructure/eVault.md | 8 +++- .../Post Platform Guide/access-control.md | 23 +++++++++++ docs/docs/W3DS Basics/glossary.md | 18 ++++++++ docs/docs/W3DS Protocol/Access-Control.md | 41 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 7d89c3283..7e0c56019 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -612,7 +612,13 @@ A MetaEnvelope can carry access rules two ways. The granular `_acl` policy is th Full model, wire format, and current limits: [Access Control](/docs/W3DS%20Protocol/Access-Control). -It is stored on the MetaEnvelope node as the `aclBlock` property (JSON), so the policy travels with the record when it syncs. +It is stored on the MetaEnvelope node as the `aclBlock` property (JSON), so the policy travels with the record when it syncs. No migration is needed to start using it: the property is optional, and a node without one is read through its legacy array exactly as before. + +:::caution Rolling back + +Once records begin carrying policies, treat the deployment as forward-only. Earlier builds do not read `aclBlock` and fall back to the `acl` array — which platforms write as `["*"]` — so a record an owner had locked down would become world-readable again on a rollback. + +::: ### Legacy ACL format diff --git a/docs/docs/Post Platform Guide/access-control.md b/docs/docs/Post Platform Guide/access-control.md index 827466f06..19e56c8df 100644 --- a/docs/docs/Post Platform Guide/access-control.md +++ b/docs/docs/Post Platform Guide/access-control.md @@ -170,6 +170,29 @@ You always get the policy actually in force. A record written with only `acl: [" Everything else about your integration is unchanged — mapping, webhooks, and the Awareness Protocol do not interact with the policy. The policy is stored inside the record, so it travels with the data when it syncs, without your webhook controller doing anything. +## Errors you will get + +A malformed policy is rejected whole — nothing is quietly dropped and stored in a weaker form. Every message is prefixed `Invalid _acl:` unless noted. + +| Message | Cause | +|---|---| +| `bits 4-7 are reserved and must be 0` | A `perms` or `default_perms` above `15`. | +| `expected an unsigned byte` | `perms` was not an integer in 0-255. | +| `each grant needs an ename` | A grant object missing its `ename`. | +| `unknown operator "…"` | A condition `op` outside `>=`, `>`, `<=`, `<`, `==`. | +| `needs a finite numeric value` | A condition `value` that is not a number. | +| `grants must be an array` (and similar) | A container sent as the wrong shape. | +| `Unsupported _acl version: n` | `v` set to anything but `1`. | + +Condition errors name the position — `require[0][1]`, `denials.conditions[0]` — so you can find the offending entry directly. + +Two runtime outcomes worth distinguishing, neither of which is a validation error: + +- **`Access denied`** — the record exists and the policy refused you. Retrying will not help; asking for a different verb might. +- **`null`** — no record with that id for that `X-ENAME`. Not a permissions problem. + +List queries behave differently again: a record you may not read is **omitted from the results**, not reported. So a list can come back shorter than you expect with no error and no indication that anything was withheld. Do not treat a list's length as a count of what exists. + ## Not usable yet Two parts of the protocol document are specified but not connected, and a policy relying on them will not behave as written: diff --git a/docs/docs/W3DS Basics/glossary.md b/docs/docs/W3DS Basics/glossary.md index 5cd61d3d1..2b3b4640d 100644 --- a/docs/docs/W3DS Basics/glossary.md +++ b/docs/docs/W3DS Basics/glossary.md @@ -14,6 +14,12 @@ The ability to retrieve or interact with data or services based on permissions a --- +## Access Control List (ACL) + +The rules stored inside a record saying who may do what with it. Held in the record's `_acl` block as [grants](#grant), [denials](#denial), and ontology conditions, so the rules travel with the data when it syncs rather than living in a table beside it. The older `acl` string array is the same idea without per-verb granularity. See [Access Control](/docs/W3DS%20Protocol/Access-Control). + +--- + ## Authentication The process of verifying the identity of a user or identifier. In W3DS, users authenticate using their [W3ID](/docs/W3DS%20Basics/W3ID) via the `w3ds://auth` protocol; see [Authentication](/docs/W3DS%20Protocol/Authentication) for details. @@ -38,6 +44,12 @@ A set of data relating to an identifier that is signed by an issuing party (e.g. --- +## Denial + +An entry in an [ACL](#access-control-list-acl) that removes access from a party, either by naming its [eName](#web-30-identifier-w3id--ename) or by stating a condition it must clear. A denial overrides any grant — it is the one place where a more specific rule does not win, because deny always does. + +--- + ## eID (ePassport) A document, similar to X.509, which binds a user's [W3ID](#web-30-identifier-w3id-ename) and the user's [Public Key](#public-key). It is signed by a [digital] notary participating in PKI. See [eID Wallet](/docs/Infrastructure/eID-Wallet) for how the prototype uses eID and key binding. @@ -68,6 +80,12 @@ A secure storage location or server for the management of data and credentials o --- +## Grant + +An entry in an [ACL](#access-control-list-acl) pairing a party's [eName](#web-30-identifier-w3id--ename) with the permissions it holds, as a bitmask of Read, Create, Update and Delete. Where several grants could apply, only the most specific is used — a grant to a user beats one to a platform, which beats one to a group — and less specific grants do not add to it. + +--- + ## Group An [Entity](#entity): a reference to a number of users within the MetaState that holds its own [W3ID](#web-30-identifier-w3id-ename) and [eVault](#evault). It is often seen as a "group" in social networks. diff --git a/docs/docs/W3DS Protocol/Access-Control.md b/docs/docs/W3DS Protocol/Access-Control.md index b2bd21fce..8ee1e893a 100644 --- a/docs/docs/W3DS Protocol/Access-Control.md +++ b/docs/docs/W3DS Protocol/Access-Control.md @@ -197,6 +197,47 @@ A legacy array is interpreted as: This matters for one behaviour in particular. Under the legacy model, any platform holding a valid Registry-issued token could reach any record. **A record carrying an `_acl` block is decided by that block for every caller, token or not.** Closing that bypass is the point of the model. Records without a policy keep their existing behaviour exactly, so nothing narrows until an owner sets one. +## Reads that return nothing + +A refusal surfaces differently depending on how the record was asked for. + +| Request | Refused | +|---|---| +| A record by id | `Access denied` | +| A record by id that does not exist for that vault | `null` | +| A list or connection | The record is omitted from the results | + +Filtering a list silently is what keeps a policy from leaking the existence of records it protects, but it means a caller cannot tell "withheld" from "not there". Anything needing that distinction must ask for the record directly. + +## Validation + +A policy you send is checked strictly and rejected whole if any part of it is malformed. A policy already stored is read liberally. + +The asymmetry is deliberate. Discarding an entry that cannot be parsed is safe for a grant — it only narrows access — but not for a denial: a deny condition silently dropped would *widen* access, and the caller would never learn that the policy being enforced was not the one it wrote. So nothing is dropped on the way in. + +Rejected on write: + +| Sent | Result | +|---|---| +| `perms` or `default_perms` with bits 4-7 set | `bits 4-7 are reserved and must be 0` | +| `perms` that is not an unsigned byte | `expected an unsigned byte` | +| A grant with no `ename` | `each grant needs an ename` | +| A condition with an unknown operator | `unknown operator …; expected one of >=, >, <=, <, ==` | +| A condition with a missing or non-numeric `value` | `needs a finite numeric value` | +| `grants`, `require`, or `denials.enames` that is not an array | `must be an array` | +| A `require` entry that is not a group | `require[n] must be an array of conditions` | +| `v` other than `1` | `Unsupported _acl version: n` | + +Condition errors name their position — `require[0][1]`, `denials.conditions[0]` — so a rejected policy points at the entry that caused it. + +Read liberally when already stored: an unparseable grant or condition is dropped, and reserved bits are masked off, so a record written by a future version or corrupted in place stays readable rather than becoming inaccessible. + +### Versioning + +`v` is the policy format version, and `1` is the only value this eVault accepts. A block declaring anything else is rejected rather than interpreted, so a policy written against a later format is never enforced as though it were version 1. Omitting `v` is treated as `1`. + +Reserved permission bits exist for the same reason: they are refused today so that a client written against a future version that uses them fails loudly here instead of silently receiving weaker permissions than it asked for. + ## Current limits - **Group membership is not resolved yet.** A grant or denial naming a group matches nothing. For grants that is fail-closed; for denials it is fail-**open**, so group denials are not usable yet.