From ca9efa409e46d241137074311087140e59ee23bc Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 18:10:49 +0800 Subject: [PATCH 1/2] feat: resolve group enames to their members when deciding access --- .../evault-core/src/core/acl/acl.spec.ts | 316 ++++++++++++++---- .../evault-core/src/core/acl/acl.ts | 117 ++++++- .../core/acl/group-membership.service.spec.ts | 213 ++++++++++++ .../src/core/acl/group-membership.service.ts | 151 +++++++++ .../evault-core/src/core/acl/index.ts | 8 + .../evault-core/src/core/acl/types.ts | 19 ++ .../src/core/protocol/graphql-server.ts | 13 +- .../core/protocol/vault-access-guard.spec.ts | 134 +++++++- .../src/core/protocol/vault-access-guard.ts | 14 +- 9 files changed, 908 insertions(+), 77 deletions(-) create mode 100644 infrastructure/evault-core/src/core/acl/group-membership.service.spec.ts create mode 100644 infrastructure/evault-core/src/core/acl/group-membership.service.ts diff --git a/infrastructure/evault-core/src/core/acl/acl.spec.ts b/infrastructure/evault-core/src/core/acl/acl.spec.ts index 3537cb850..ca4c306f0 100644 --- a/infrastructure/evault-core/src/core/acl/acl.spec.ts +++ b/infrastructure/evault-core/src/core/acl/acl.spec.ts @@ -146,10 +146,10 @@ describe("evaluate: step 2, most specific grant wins", () => { expect(decision.allowed).toBe(true); }); - it("ranks a user grant above a platform grant above a group grant", () => { + it("ranks a user grant above a platform grant above a group grant", async () => { const principal = user({ platform: PLATFORM, groups: [GROUP] }); expect( - mostSpecificGrant( + await mostSpecificGrant( [ { ename: GROUP, perms: 0x0f }, { ename: PLATFORM, perms: 0x07 }, @@ -160,7 +160,7 @@ describe("evaluate: step 2, most specific grant wins", () => { ).toMatchObject({ perms: 0x01 }); expect( - mostSpecificGrant( + await mostSpecificGrant( [ { ename: GROUP, perms: 0x0f }, { ename: PLATFORM, perms: 0x07 }, @@ -170,10 +170,10 @@ describe("evaluate: step 2, most specific grant wins", () => { ).toMatchObject({ perms: 0x07 }); }); - it("unions grants tied at the same specificity", () => { + it("unions grants tied at the same specificity", async () => { const principal = user({ groups: ["@group-a", "@group-b"] }); expect( - mostSpecificGrant( + await mostSpecificGrant( [ { ename: "@group-a", perms: 0x01 }, { ename: "@group-b", perms: 0x04 }, @@ -247,12 +247,9 @@ describe("evaluate: step 1, denials always win", () => { denials: { enames: [], conditions: [cond(EREP, ">=", 60)] }, }); const evaluator = scores({ [PLATFORM]: { [EREP]: 20 } }); - const decision = await evaluate( - acl, - platform(), - Permission.READ, - evaluator, - ); + const decision = await evaluate(acl, platform(), Permission.READ, { + conditions: evaluator, + }); expect(decision).toMatchObject({ allowed: false, reason: "denied_by_condition", @@ -265,12 +262,9 @@ describe("evaluate: step 1, denials always win", () => { denials: { enames: [], conditions: [cond(EREP, ">=", 60)] }, }); const evaluator = scores({ [PLATFORM]: { [EREP]: 72 } }); - const decision = await evaluate( - acl, - platform(), - Permission.READ, - evaluator, - ); + const decision = await evaluate(acl, platform(), Permission.READ, { + conditions: evaluator, + }); expect(decision).toMatchObject({ allowed: true, reason: "grant" }); }); }); @@ -299,33 +293,38 @@ describe("evaluate: step 3, the ontology groups", () => { // Denied at step 1. expect( ( - await evaluate( - acl, - platform(BAD_PLATFORM), - Permission.READ, - evaluator, - ) + await evaluate(acl, platform(BAD_PLATFORM), Permission.READ, { + conditions: evaluator, + }) ).allowed, ).toBe(false); // Allowed at step 2 -- 0x01 includes READ. expect( - await evaluate(acl, platform(), Permission.READ, evaluator), + await evaluate(acl, platform(), Permission.READ, { + conditions: evaluator, + }), ).toMatchObject({ allowed: true, reason: "grant" }); // Denied -- 0x01 lacks DELETE, and step 3 is not reached. expect( - await evaluate(acl, platform(), Permission.DELETE, evaluator), + await evaluate(acl, platform(), Permission.DELETE, { + conditions: 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), + await evaluate(acl, platform(unnamedA), Permission.READ, { + conditions: 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), + await evaluate(acl, platform(unnamedB), Permission.READ, { + conditions: evaluator, + }), ).toMatchObject({ allowed: true, reason: "ontology" }); }); @@ -333,7 +332,9 @@ describe("evaluate: step 3, the ontology groups", () => { const weak = "@platform-weak"; const evaluator = scores({ [weak]: { [SEC]: 10, [EREP]: 10 } }); expect( - await evaluate(acl, platform(weak), Permission.READ, evaluator), + await evaluate(acl, platform(weak), Permission.READ, { + conditions: evaluator, + }), ).toMatchObject({ allowed: false, reason: "no_matching_group" }); }); @@ -342,12 +343,9 @@ describe("evaluate: step 3, the ontology groups", () => { const evaluator = scores({ [strong]: { [EREP]: 99 } }); expect( ( - await evaluate( - acl, - platform(strong), - Permission.UPDATE, - evaluator, - ) + await evaluate(acl, platform(strong), Permission.UPDATE, { + conditions: evaluator, + }) ).allowed, ).toBe(false); }); @@ -356,12 +354,9 @@ describe("evaluate: step 3, the ontology groups", () => { const evaluator = scores({}); expect( ( - await evaluate( - acl, - platform("@unknown"), - Permission.READ, - evaluator, - ) + await evaluate(acl, platform("@unknown"), Permission.READ, { + conditions: evaluator, + }) ).allowed, ).toBe(false); }); @@ -381,12 +376,9 @@ describe("evaluate: step 3, the ontology groups", () => { }; expect( ( - await evaluate( - acl, - platform("@unknown"), - Permission.READ, - throwing, - ) + await evaluate(acl, platform("@unknown"), Permission.READ, { + conditions: throwing, + }) ).allowed, ).toBe(false); }); @@ -505,7 +497,9 @@ describe("aclBlockFromInput: caller input is validated strictly", () => { }; it("accepts a well-formed block and returns undefined for none", () => { - expect(aclBlockFromInput(ok)?.grants).toEqual([{ ename: USER, perms: 0x01 }]); + expect(aclBlockFromInput(ok)?.grants).toEqual([ + { ename: USER, perms: 0x01 }, + ]); expect(aclBlockFromInput(undefined)).toBeUndefined(); expect(aclBlockFromInput(null)).toBeUndefined(); }); @@ -517,7 +511,14 @@ describe("aclBlockFromInput: caller input is validated strictly", () => { ...ok, denials: { enames: [], - conditions: [{ ontology: EREP, path: "$.score", op: "~=", value: 60 }], + conditions: [ + { + ontology: EREP, + path: "$.score", + op: "~=", + value: 60, + }, + ], }, }), ).toThrow(/unknown operator/); @@ -527,31 +528,49 @@ describe("aclBlockFromInput: caller input is validated strictly", () => { expect(() => aclBlockFromInput({ ...ok, - require: [[{ ontology: EREP, path: "$.score", op: ">=", value: "sixty" }]], + 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/, - ); + 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 }] }), + aclBlockFromInput({ + ...ok, + grants: [{ ename: USER, perms: 0x10 }], + }), ).toThrow(/reserved/); - expect(() => aclBlockFromInput({ ...ok, default_perms: 0xff })).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/); + 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, grants: {} })).toThrow( + /grants must be an array/, + ); expect(() => aclBlockFromInput({ ...ok, require: [{}] })).toThrow( /require\[0\] must be an array/, ); @@ -560,6 +579,189 @@ describe("aclBlockFromInput: caller input is validated strictly", () => { 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([]); + expect(normalizeAclBlock({ grants: [{ perms: 0x01 }] }).grants).toEqual( + [], + ); + }); +}); + +describe("group resolution", () => { + const GROUP_A = "@group-a"; + const GROUP_B = "@group-b"; + + /** Resolves from a fixed table; anything else is an empty group. */ + const groups = (table: Record) => ({ + async membersOf(group: string) { + return table[group] ?? []; + }, + }); + + const failing = { + async membersOf(): Promise { + throw new Error("group vault unreachable"); + }, + }; + + it("applies a grant to a group the party belongs to", async () => { + const acl = block({ grants: [{ ename: GROUP_A, perms: 0x05 }] }); + const resolver = groups({ [GROUP_A]: [USER] }); + expect( + await evaluate(acl, user(), Permission.UPDATE, { + groups: resolver, + }), + ).toMatchObject({ allowed: true, reason: "grant" }); + }); + + it("does not apply a group grant to a non-member", async () => { + const acl = block({ grants: [{ ename: GROUP_A, perms: 0x05 }] }); + const resolver = groups({ [GROUP_A]: ["@someone-else"] }); + expect( + ( + await evaluate(acl, user(), Permission.UPDATE, { + groups: resolver, + }) + ).allowed, + ).toBe(false); + }); + + it("matches a group through the platform carrying the request", async () => { + const acl = block({ grants: [{ ename: GROUP_A, perms: 0x01 }] }); + const resolver = groups({ [GROUP_A]: [PLATFORM] }); + expect( + ( + await evaluate(acl, platform(), Permission.READ, { + groups: resolver, + }) + ).allowed, + ).toBe(true); + }); + + it("keeps a direct grant ahead of a group grant", async () => { + const acl = block({ + grants: [ + { ename: GROUP_A, perms: 0x0f }, + { ename: USER, perms: 0x01 }, + ], + }); + const resolver = groups({ [GROUP_A]: [USER] }); + expect( + ( + await evaluate(acl, user(), Permission.DELETE, { + groups: resolver, + }) + ).allowed, + ).toBe(false); + }); + + it("does not resolve any group when a direct grant already matched", async () => { + let calls = 0; + const counting = { + async membersOf() { + calls++; + return [USER]; + }, + }; + const acl = block({ + grants: [ + { ename: GROUP_A, perms: 0x0f }, + { ename: USER, perms: 0x01 }, + ], + }); + await evaluate(acl, user(), Permission.READ, { groups: counting }); + expect(calls).toBe(0); + }); + + it("unions grants from several groups the party belongs to", async () => { + const acl = block({ + grants: [ + { ename: GROUP_A, perms: 0x01 }, + { ename: GROUP_B, perms: 0x04 }, + ], + }); + const resolver = groups({ [GROUP_A]: [USER], [GROUP_B]: [USER] }); + for (const action of [Permission.READ, Permission.UPDATE]) { + expect( + (await evaluate(acl, user(), action, { groups: resolver })) + .allowed, + ).toBe(true); + } + expect( + ( + await evaluate(acl, user(), Permission.DELETE, { + groups: resolver, + }) + ).allowed, + ).toBe(false); + }); + + it("denies a member of a denied group", async () => { + const acl = block({ + grants: [{ ename: USER, perms: 0x0f }], + denials: { enames: [GROUP_A], conditions: [] }, + }); + const resolver = groups({ [GROUP_A]: [USER] }); + expect( + await evaluate(acl, user(), Permission.READ, { groups: resolver }), + ).toMatchObject({ allowed: false, reason: "denied_by_ename" }); + }); + + it("lets a non-member through a group denial", async () => { + const acl = block({ + grants: [{ ename: USER, perms: 0x0f }], + denials: { enames: [GROUP_A], conditions: [] }, + }); + const resolver = groups({ [GROUP_A]: ["@someone-else"] }); + expect( + (await evaluate(acl, user(), Permission.READ, { groups: resolver })) + .allowed, + ).toBe(true); + }); + + it("holds a group denial when membership cannot be determined", async () => { + // A lookup that failed is not evidence of non-membership, so the denial + // stands rather than being skipped. + const acl = block({ + grants: [{ ename: USER, perms: 0x0f }], + denials: { enames: [GROUP_A], conditions: [] }, + }); + expect( + await evaluate(acl, user(), Permission.READ, { groups: failing }), + ).toMatchObject({ allowed: false, reason: "denied_by_ename" }); + }); + + it("withholds a group grant when membership cannot be determined", async () => { + // The same uncertainty must not hand out access. + const acl = block({ grants: [{ ename: GROUP_A, perms: 0x0f }] }); + expect( + (await evaluate(acl, user(), Permission.READ, { groups: failing })) + .allowed, + ).toBe(false); + }); + + it("resolves each group once per decision", async () => { + const seen: string[] = []; + const counting = { + async membersOf(group: string) { + seen.push(group); + return []; + }, + }; + const acl = block({ + grants: [ + { ename: GROUP_A, perms: 0x01 }, + { ename: GROUP_A, perms: 0x04 }, + ], + denials: { enames: [GROUP_A], conditions: [] }, + }); + await evaluate(acl, user(), Permission.READ, { groups: counting }); + expect(seen).toEqual([GROUP_A]); + }); + + it("still honours a pre-resolved group list with no resolver", async () => { + const acl = block({ grants: [{ ename: GROUP_A, perms: 0x01 }] }); + expect( + (await evaluate(acl, user({ groups: [GROUP_A] }), Permission.READ)) + .allowed, + ).toBe(true); }); }); diff --git a/infrastructure/evault-core/src/core/acl/acl.ts b/infrastructure/evault-core/src/core/acl/acl.ts index 1d93b4311..3370a3cf9 100644 --- a/infrastructure/evault-core/src/core/acl/acl.ts +++ b/infrastructure/evault-core/src/core/acl/acl.ts @@ -5,7 +5,9 @@ import { type ConditionGroup, type Decision, type EName, + type EvaluateDeps, type Grant, + type GroupResolver, Permission, type PermissionBits, type Principal, @@ -174,15 +176,61 @@ export function resolveAclBlock(record: { } /** - * 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. + * Whether the principal belongs to a group. + * + * `unknown` is deliberately distinct from `no`: a lookup that failed is not + * evidence of non-membership. A grant needs proof of membership before it + * applies, while a denial applies unless the party is shown *not* to be a + * member — so an unresolvable group is safe in both directions. + */ +type MembershipAnswer = "yes" | "no" | "unknown"; + +class Membership { + private cache = new Map(); + + constructor( + private principal: Principal, + private resolver?: GroupResolver, + ) {} + + async of(group: EName): Promise { + // A caller that resolved membership itself is taken at its word. + if (this.principal.groups?.includes(group)) return "yes"; + // With no resolver wired in, groups cannot be resolved at all. That is + // the feature being switched off rather than a lookup that failed, so + // it answers plainly instead of holding a denial open. + if (!this.resolver) return "no"; + + const cached = this.cache.get(group); + if (cached !== undefined) return cached; + + let answer: MembershipAnswer; + try { + const members = await this.resolver.membersOf(group); + answer = members.some( + (member) => + member === this.principal.ename || + (this.principal.platform !== undefined && + member === this.principal.platform), + ) + ? "yes" + : "no"; + } catch { + answer = "unknown"; + } + this.cache.set(group, answer); + return answer; + } +} + +/** + * How specifically a grant's eName matches the principal, ignoring groups: a + * user grant beats a platform grant. `0` means no direct match. */ -function specificityOf(ename: EName, principal: Principal): number { +function directSpecificityOf(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; } @@ -194,16 +242,27 @@ function specificityOf(ename: EName, principal: Principal): number { * 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( +export async function mostSpecificGrant( + grants: readonly Grant[], + principal: Principal, + resolver?: GroupResolver, +): Promise<{ perms: PermissionBits; enames: EName[] } | null> { + return selectGrant(grants, principal, new Membership(principal, resolver)); +} + +async function selectGrant( grants: readonly Grant[], principal: Principal, -): { perms: PermissionBits; enames: EName[] } | null { + membership: Membership, +): Promise<{ perms: PermissionBits; enames: EName[] } | null> { let bestRank = 0; let perms: PermissionBits = Permission.NONE; let enames: EName[] = []; + // Direct matches first. A user or platform grant outranks any group grant, + // so finding one means no group needs resolving at all. for (const grant of grants) { - const rank = specificityOf(grant.ename, principal); + const rank = directSpecificityOf(grant.ename, principal); if (rank === 0) continue; if (rank > bestRank) { bestRank = rank; @@ -215,7 +274,18 @@ export function mostSpecificGrant( } } - if (bestRank === 0 || perms === Permission.NONE) return null; + if (bestRank === 0) { + // Nothing named this party directly, so group grants come into play. + // They are all equally specific, and so are unioned. + for (const grant of grants) { + if ((await membership.of(grant.ename)) === "yes") { + perms |= grant.perms; + enames.push(grant.ename); + } + } + } + + if (enames.length === 0 || perms === Permission.NONE) return null; return { perms, enames }; } @@ -287,9 +357,11 @@ export async function evaluate( acl: AclBlock, principal: Principal, action: PermissionBits, - evaluator?: ConditionEvaluator, + deps: EvaluateDeps = {}, ): Promise { validateAction(action); + const evaluator = deps.conditions; + const membership = new Membership(principal, deps.groups); // 1. Denials win over everything, with no exceptions. const identities = identitiesOf(principal); @@ -298,6 +370,13 @@ export async function evaluate( return { allowed: false, reason: "denied_by_ename" }; } } + // A denial naming a group applies unless the party is shown not to be a + // member. A lookup we could not complete is not that proof. + for (const denied of acl.denials.enames) { + if ((await membership.of(denied)) !== "no") { + 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" }; @@ -305,7 +384,7 @@ export async function evaluate( } // 2. A direct grant decides the outcome on its own. - const grant = mostSpecificGrant(acl.grants, principal); + const grant = await selectGrant(acl.grants, principal, membership); if (grant !== null) { return { allowed: (grant.perms & action) !== 0, @@ -407,7 +486,9 @@ export function aclBlockFromInput(raw: unknown): AclBlock | undefined { 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"); + throw new Error( + "Invalid _acl: denials.enames must be an array", + ); } for (const ename of denials.enames) { if (typeof ename !== "string" || ename.length === 0) { @@ -419,7 +500,9 @@ export function aclBlockFromInput(raw: unknown): AclBlock | undefined { } if (denials.conditions !== undefined) { if (!Array.isArray(denials.conditions)) { - throw new Error("Invalid _acl: denials.conditions must be an array"); + throw new Error( + "Invalid _acl: denials.conditions must be an array", + ); } denials.conditions.forEach((c, i) => validateConditionInput(c, `denials.conditions[${i}]`), @@ -437,9 +520,13 @@ export function aclBlockFromInput(raw: unknown): AclBlock | undefined { } block.require.forEach((group, g) => { if (!Array.isArray(group)) { - throw new Error(`Invalid _acl: require[${g}] must be an array of conditions`); + throw new Error( + `Invalid _acl: require[${g}] must be an array of conditions`, + ); } - group.forEach((c, i) => validateConditionInput(c, `require[${g}][${i}]`)); + group.forEach((c, i) => + validateConditionInput(c, `require[${g}][${i}]`), + ); }); } diff --git a/infrastructure/evault-core/src/core/acl/group-membership.service.spec.ts b/infrastructure/evault-core/src/core/acl/group-membership.service.spec.ts new file mode 100644 index 000000000..894a6112b --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/group-membership.service.spec.ts @@ -0,0 +1,213 @@ +import type { Driver } from "neo4j-driver"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + setupTestNeo4j, + teardownTestNeo4j, +} from "../../test-utils/neo4j-setup"; +import { DbService } from "../db/db.service"; +import { GroupMembershipService } from "./group-membership.service"; + +const GROUP_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440003"; +const GROUP_MANIFEST_ONTOLOGY = "a8bfb7cf-3200-4b25-9ea9-ee41100f212e"; +const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +describe("GroupMembershipService", () => { + let driver: Driver; + let db: DbService; + let groups: GroupMembershipService; + + beforeAll(async () => { + const setup = await setupTestNeo4j(); + driver = setup.driver; + db = new DbService(driver); + groups = new GroupMembershipService(db); + }, 120000); + + afterAll(async () => { + await teardownTestNeo4j(); + }); + + /** Stores a profile record and returns the id a group would reference it by. */ + const storeProfile = async ( + vault: string, + payload: Record = {}, + ): Promise => { + const result = await db.storeMetaEnvelope( + { + ontology: USER_ONTOLOGY, + payload: { username: "someone", ...payload }, + acl: ["*"], + }, + ["*"], + vault, + ); + return result.metaEnvelope.id; + }; + + const storeGroup = async ( + vault: string, + payload: Record, + ontology = GROUP_ONTOLOGY, + ): Promise => { + const result = await db.storeMetaEnvelope( + { ontology, payload, acl: ["*"] }, + ["*"], + vault, + ); + return result.metaEnvelope.id; + }; + + describe("members named by eName", () => { + it("resolves a manifest whose members are eNames", async () => { + const group = "@group-by-ename"; + await storeGroup( + group, + { + ename: group, + name: "Ops", + members: ["@alice", "@bob"], + admins: ["@alice"], + owner: "@alice", + }, + GROUP_MANIFEST_ONTOLOGY, + ); + + const members = await groups.membersOf(group); + expect(members.sort()).toEqual(["@alice", "@bob"]); + }); + + it("finds the group by the ename field when it lives in another vault", async () => { + const group = "@group-stated-ename"; + // The record sits in a platform's vault, naming the group itself. + await storeGroup("@some-other-vault", { + ename: group, + name: "Stated", + members: ["@carol"], + }); + + expect(await groups.membersOf(group)).toEqual(["@carol"]); + }); + }); + + describe("members named by profile envelope id", () => { + it("follows a participant id to the eName the profile states", async () => { + const group = "@group-by-id"; + // The profile lives in one vault but states whose it is; the stated + // eName is what a participant id must resolve to. + const profileId = await storeProfile("@holding-vault", { + ename: "@dave", + }); + await storeGroup(group, { + ename: group, + name: "By id", + participantIds: [profileId], + }); + + expect(await groups.membersOf(group)).toEqual(["@dave"]); + }); + + it("falls back to the vault the profile lives in when it states nothing", async () => { + const group = "@group-by-id-fallback"; + const profileId = await storeProfile("@erin"); + await storeGroup(group, { + ename: group, + name: "Fallback", + participantIds: [profileId], + }); + + expect(await groups.membersOf(group)).toEqual(["@erin"]); + }); + + it("ignores an id that resolves to nothing", async () => { + const group = "@group-dangling-id"; + await storeGroup(group, { + ename: group, + name: "Dangling", + participantIds: ["00000000-0000-0000-0000-000000000000"], + }); + + expect(await groups.membersOf(group)).toEqual([]); + }); + }); + + describe("mixed and awkward shapes", () => { + it("resolves a list holding both eNames and profile ids", async () => { + const group = "@group-mixed"; + const frankId = await storeProfile("@frank"); + await storeGroup(group, { + ename: group, + name: "Mixed", + participantIds: [frankId, "@grace"], + }); + + expect((await groups.membersOf(group)).sort()).toEqual([ + "@frank", + "@grace", + ]); + }); + + it("unions every participant field a record carries", async () => { + const group = "@group-many-fields"; + const heidiId = await storeProfile("@heidi"); + await storeGroup(group, { + ename: group, + name: "Many", + members: ["@ivan"], + participantIds: [heidiId], + admins: ["@judy"], + owner: "@judy", + }); + + expect((await groups.membersOf(group)).sort()).toEqual([ + "@heidi", + "@ivan", + "@judy", + ]); + }); + + it("deduplicates a member reachable two ways", async () => { + const group = "@group-dupes"; + const kenId = await storeProfile("@holding", { ename: "@ken" }); + await storeGroup(group, { + ename: group, + name: "Dupes", + members: ["@ken"], + participantIds: [kenId], + admins: ["@ken"], + }); + + expect(await groups.membersOf(group)).toEqual(["@ken"]); + }); + + it("reads a single-value owner field as one member", async () => { + const group = "@group-scalar-owner"; + await storeGroup(group, { + ename: group, + name: "Scalar", + owner: "@leo", + }); + + expect(await groups.membersOf(group)).toEqual(["@leo"]); + }); + + it("returns nothing for an unknown group", async () => { + expect( + await groups.membersOf("@group-that-does-not-exist"), + ).toEqual([]); + }); + + it("returns nothing for a value that is not an eName", async () => { + expect(await groups.membersOf("not-an-ename")).toEqual([]); + }); + + it("ignores records that are not groups", async () => { + const notAGroup = "@not-a-group"; + await storeProfile(notAGroup, { + ename: notAGroup, + members: ["@mallory"], + }); + + expect(await groups.membersOf(notAGroup)).toEqual([]); + }); + }); +}); diff --git a/infrastructure/evault-core/src/core/acl/group-membership.service.ts b/infrastructure/evault-core/src/core/acl/group-membership.service.ts new file mode 100644 index 000000000..e20c39caa --- /dev/null +++ b/infrastructure/evault-core/src/core/acl/group-membership.service.ts @@ -0,0 +1,151 @@ +import type { DbService } from "../db/db.service"; +import { deserializeValue } from "../db/schema"; +import type { EName, GroupResolver } from "./types"; + +/** + * Ontologies whose records describe a group and its participants. + * + * `550e8400-…-440003` is shared by Group and Chat, which is why membership is + * read from whichever participant field a record happens to carry rather than + * from one fixed key. + */ +export const GROUP_ONTOLOGIES = [ + "550e8400-e29b-41d4-a716-446655440003", + "a8bfb7cf-3200-4b25-9ea9-ee41100f212e", +]; + +/** + * Fields that hold participants. Different platforms mapped the same idea onto + * different keys, and a group's members are the union of all of them. + */ +export const MEMBER_FIELDS = [ + "members", + "memberIds", + "participants", + "participantIds", + "admins", + "owner", +]; + +/** The field a group record uses to name itself. */ +const GROUP_ENAME_FIELD = "ename"; + +/** The field a profile record may use to carry its owner's eName. */ +const PROFILE_ENAME_FIELD = "ename"; + +function isEName(value: unknown): value is EName { + return ( + typeof value === "string" && value.startsWith("@") && value.length > 1 + ); +} + +/** + * Resolves a group eName to its members' eNames. + * + * A participant list may name a member either by their eName or by the id of + * their profile record — platforms mapped it both ways — so both are accepted + * and an id is followed to the eName behind it. + */ +export class GroupMembershipService implements GroupResolver { + constructor(private db: DbService) {} + + async membersOf(group: EName): Promise { + if (!isEName(group)) return []; + + // A group record is found either by living in the group's own vault or + // by naming the group in its `ename` field. + const result = await this.db.runQuery( + ` + MATCH (m:MetaEnvelope) + WHERE m.ontology IN $ontologies + AND ( + m.eName = $group + OR EXISTS { + MATCH (m)-[:LINKS_TO]->(n:Envelope { ontology: $enameField }) + WHERE n.value = $group + } + ) + MATCH (m)-[:LINKS_TO]->(e:Envelope) + WHERE e.ontology IN $memberFields + RETURN collect({ value: e.value, valueType: e.valueType }) AS entries + `, + { + ontologies: GROUP_ONTOLOGIES, + group, + enameField: GROUP_ENAME_FIELD, + memberFields: MEMBER_FIELDS, + }, + ); + + const raw: string[] = []; + for (const record of result.records) { + for (const entry of record.get("entries") ?? []) { + collectEntries(entry, raw); + } + } + if (raw.length === 0) return []; + + const enames = new Set(); + const ids: string[] = []; + for (const entry of raw) { + if (isEName(entry)) enames.add(entry); + else ids.push(entry); + } + + for (const resolved of await this.enamesForProfileIds(ids)) { + enames.add(resolved); + } + return [...enames]; + } + + /** + * Follows profile record ids to the eNames behind them, in one query. + * + * A record's own `ename` field is preferred over the vault it sits in: the + * same profile syncs into several vaults, so the vault only identifies the + * subject when the record does not say so itself. + */ + private async enamesForProfileIds(ids: string[]): Promise { + if (ids.length === 0) return []; + + const result = await this.db.runQuery( + ` + MATCH (m:MetaEnvelope) + WHERE m.id IN $ids + OPTIONAL MATCH (m)-[:LINKS_TO]->(e:Envelope { ontology: $enameField }) + RETURN m.id AS id, m.eName AS ownerEName, e.value AS statedEName + `, + { ids, enameField: PROFILE_ENAME_FIELD }, + ); + + const enames: EName[] = []; + for (const record of result.records) { + const stated = record.get("statedEName"); + const owner = record.get("ownerEName"); + if (isEName(stated)) enames.push(stated); + else if (isEName(owner)) enames.push(owner); + } + return enames; + } +} + +/** + * Participant fields hold a single value or a list, and a list of strings may + * have been stored as a JSON blob. Everything is flattened to plain strings. + */ +function collectEntries( + entry: { value: unknown; valueType: unknown }, + into: string[], +): void { + const value = + typeof entry?.valueType === "string" + ? deserializeValue(entry.value, entry.valueType) + : entry?.value; + + const push = (v: unknown) => { + if (typeof v === "string" && v.length > 0) into.push(v); + }; + + if (Array.isArray(value)) value.forEach(push); + else push(value); +} diff --git a/infrastructure/evault-core/src/core/acl/index.ts b/infrastructure/evault-core/src/core/acl/index.ts index 53717da44..f38aef4c3 100644 --- a/infrastructure/evault-core/src/core/acl/index.ts +++ b/infrastructure/evault-core/src/core/acl/index.ts @@ -8,7 +8,9 @@ export { type DecisionReason, type Denials, type EName, + type EvaluateDeps, type Grant, + type GroupResolver, Permission, type PermissionBits, type Principal, @@ -31,3 +33,9 @@ export { } from "./acl"; export { parseStoredAclBlock, serializeAclBlock } from "./storage"; + +export { + GROUP_ONTOLOGIES, + GroupMembershipService, + MEMBER_FIELDS, +} from "./group-membership.service"; diff --git a/infrastructure/evault-core/src/core/acl/types.ts b/infrastructure/evault-core/src/core/acl/types.ts index 6d021334c..2584d5be0 100644 --- a/infrastructure/evault-core/src/core/acl/types.ts +++ b/infrastructure/evault-core/src/core/acl/types.ts @@ -123,3 +123,22 @@ export interface Decision { export interface ConditionEvaluator { passes(condition: Condition, principal: Principal): Promise; } + +/** + * Resolves a group eName to the eNames of its members. + * + * A group's participant list may reference a member either by their eName or + * by the id of their profile record; an implementation is expected to return + * eNames whichever form it found. + */ +export interface GroupResolver { + membersOf(group: EName): Promise; +} + +/** Collaborators {@link evaluate} may call out to. */ +export interface EvaluateDeps { + /** Resolves Resource Link Ontology conditions. */ + conditions?: ConditionEvaluator; + /** Resolves group membership for grants and denials naming a group. */ + groups?: GroupResolver; +} diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index d3692a552..20fd1476e 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -1,5 +1,10 @@ import { Server } from "http"; -import { aclBlockFromInput, Permission, resolveAclBlock } from "../acl"; +import { + aclBlockFromInput, + GroupMembershipService, + Permission, + resolveAclBlock, +} from "../acl"; import axios from "axios"; import type { GraphQLSchema } from "graphql"; import { createSchema, createYoga } from "graphql-yoga"; @@ -44,7 +49,11 @@ export class GraphQLServer { evaultInstance?: any, ) { this.db = db; - this.accessGuard = new VaultAccessGuard(db); + this.accessGuard = new VaultAccessGuard( + db, + undefined, + new GroupMembershipService(db), + ); this.bindingDocumentService = new BindingDocumentService(db); this.evaultPublicKey = evaultPublicKey || process.env.EVAULT_PUBLIC_KEY || null; 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 acfb23e70..ab99f92b3 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,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; import { VaultAccessGuard, VaultContext } from "./vault-access-guard"; -import { Permission } from "../acl"; +import { GroupMembershipService, Permission } from "../acl"; import { DbService } from "../db/db.service"; import { setupTestNeo4j, teardownTestNeo4j } from "../../test-utils/neo4j-setup"; import { Driver } from "neo4j-driver"; @@ -1288,4 +1288,136 @@ describe("granular _acl policies", () => { ).rejects.toThrow("Access denied"); }); }); +describe("group grants and denials end to end", () => { + const GROUP = "@group-guarded"; + const PLATFORM = "@platform-grouped"; + const MEMBER = "@user-in-group"; + const OUTSIDER = "@user-outside-group"; + const GROUP_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440003"; + const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + + let grouped: VaultAccessGuard; + + beforeAll(() => { + grouped = new VaultAccessGuard( + dbService, + undefined, + new GroupMembershipService(dbService), + ); + }); + + 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, onBehalfOf: string) => { + const token = await createValidToken({ platform: PLATFORM }); + return createMockContext({ + eName, + onBehalfOf, + request: { + headers: new Headers({ authorization: `Bearer ${token}` }), + } as any, + }); + }; + + it("admits a member through a group grant, by eName and by profile id", async () => { + // One member is listed by eName, the other by their profile record's + // id -- both forms occur in real group records. + const profile = await dbService.storeMetaEnvelope( + { ontology: USER_ONTOLOGY, payload: { ename: OUTSIDER }, acl: ["*"] }, + ["*"], + "@profile-vault", + ); + await dbService.storeMetaEnvelope( + { + ontology: GROUP_ONTOLOGY, + payload: { + ename: GROUP, + name: "Guarded", + members: [MEMBER], + participantIds: [profile.metaEnvelope.id], + }, + acl: ["*"], + }, + ["*"], + GROUP, + ); + + const eName = "@vault-group-1"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: GROUP, perms: 0x01 }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + for (const member of [MEMBER, OUTSIDER]) { + const context = await contextFor(eName, member); + await expect( + grouped.middleware(vi.fn(async () => ({ id })), Permission.READ)( + null, + { id }, + context, + ), + ).resolves.toBeDefined(); + } + }); + + it("refuses someone who is not in the granted group", async () => { + const eName = "@vault-group-2"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: GROUP, perms: 0x0f }], + denials: { enames: [], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, "@nobody-in-particular"); + await expect( + grouped.middleware(vi.fn(async () => ({ id })))(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("denies a member of a denied group despite a direct grant", async () => { + const eName = "@vault-group-3"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: MEMBER, perms: 0x0f }], + denials: { enames: [GROUP], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, MEMBER); + await expect( + grouped.middleware(vi.fn(async () => ({ id })))(null, { id }, context), + ).rejects.toThrow("Access denied"); + }); + + it("leaves a group denial inert when no resolver is configured", async () => { + // The guard without a resolver cannot resolve groups at all, which + // is the feature switched off rather than a failed lookup. + const eName = "@vault-group-4"; + const id = await storeWithPolicy(eName, { + v: 1, + grants: [{ ename: MEMBER, perms: 0x0f }], + denials: { enames: [GROUP], conditions: [] }, + default_perms: 0x00, + require: [], + }); + + const context = await contextFor(eName, MEMBER); + await expect( + guard.middleware(vi.fn(async () => ({ id })))(null, { id }, context), + ).resolves.toBeDefined(); + }); + }); }); 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 fe63b73d0..df6e31875 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -4,6 +4,7 @@ import * as jose from "jose"; import { type ConditionEvaluator, evaluate, + type GroupResolver, Permission, type PermissionBits, type Principal, @@ -52,8 +53,17 @@ export class VaultAccessGuard { constructor( private db: DbService, private conditionEvaluator?: ConditionEvaluator, + private groupResolver?: GroupResolver, ) {} + /** The collaborators policy evaluation may call out to. */ + private get aclDeps() { + return { + conditions: this.conditionEvaluator, + groups: this.groupResolver, + }; + } + /** * The party a request acts as. * @@ -317,7 +327,7 @@ export class VaultAccessGuard { metaEnvelope._acl, principal, action, - this.conditionEvaluator, + this.aclDeps, ); return { hasAccess: decision.allowed, exists: true }; } @@ -381,7 +391,7 @@ export class VaultAccessGuard { envelope._acl, principal, Permission.READ, - this.conditionEvaluator, + this.aclDeps, ) ).allowed : false; From 6e521f24815e96dc9258fb7cc99359e04f28c786 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 31 Aug 2026 18:10:49 +0800 Subject: [PATCH 2/2] docs: document group resolution and both member reference forms --- .../Post Platform Guide/access-control.md | 22 ++++++++++++- docs/docs/W3DS Basics/glossary.md | 2 +- docs/docs/W3DS Protocol/Access-Control.md | 32 ++++++++++++++++++- skills/w3ds/reference/evault.md | 6 +++- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/docs/Post Platform Guide/access-control.md b/docs/docs/Post Platform Guide/access-control.md index 19e56c8df..3c87c1f94 100644 --- a/docs/docs/Post Platform Guide/access-control.md +++ b/docs/docs/Post Platform Guide/access-control.md @@ -150,6 +150,27 @@ query { 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. +## Naming a group instead of a person + +A grant or denial can name a group eName, and it resolves to the group's members when the decision is made — so membership changes take effect without rewriting any policy. + +```json +{ "v": 1, + "grants": [ { "ename": "@owner", "perms": 15 }, + { "ename": "@9f0e1d2c-3b4a-5968-7766-554433221100", "perms": 1 } ], + "denials": { "enames": [], "conditions": [] }, + "default_perms": 0, + "require": [] } +``` + +You do not have to normalise your group records first. Participants are read from `members`, `memberIds`, `participants`, `participantIds`, `admins` and `owner`, and each entry may be **either an eName or the id of that member's profile record** — the two shapes platforms actually write. A profile id resolves through the record's own `ename` field, falling back to the vault it lives in. + +Worth knowing: + +- **Admins and the owner count as members.** Every participant field is unioned, so a group grant reaches them too. If you need admins treated differently, name them directly rather than relying on the group. +- **A group grant is the least specific kind.** A direct grant to the user or the platform overrides it entirely and is not combined with it. +- **A group whose record this eVault does not hold cannot be resolved.** A grant naming it hands out nothing; a denial naming it stays in force. Uncertainty never widens access, but it can refuse someone you expected to admit. + ## 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. @@ -197,7 +218,6 @@ List queries behave differently again: a record you may not read is **omitted fr 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. diff --git a/docs/docs/W3DS Basics/glossary.md b/docs/docs/W3DS Basics/glossary.md index 2b3b4640d..0257f67fe 100644 --- a/docs/docs/W3DS Basics/glossary.md +++ b/docs/docs/W3DS Basics/glossary.md @@ -88,7 +88,7 @@ An entry in an [ACL](#access-control-list-acl) pairing a party's [eName](#web-30 ## 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. +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. In an [ACL](#access-control-list-acl) a group eName is resolved to its members' eNames when a decision is made, so naming a group stays correct as its membership changes. --- diff --git a/docs/docs/W3DS Protocol/Access-Control.md b/docs/docs/W3DS Protocol/Access-Control.md index 8ee1e893a..e298200ba 100644 --- a/docs/docs/W3DS Protocol/Access-Control.md +++ b/docs/docs/W3DS Protocol/Access-Control.md @@ -58,6 +58,36 @@ Grants tied at the same specificity — duplicates, or two groups the party belo A direct grant is final. A named party never falls through to the ontology half, whether its grant allowed the action or not. +### How a group resolves + +A group eName is not a party in its own right — it stands for the people in it, and is resolved to their eNames when the decision is made. Naming a group in a policy therefore stays correct as the group's membership changes, with nothing to rewrite. + +The group's record is found either in the group's own vault or by its `ename` field naming the group, and its participants are read from whichever fields it carries — `members`, `memberIds`, `participants`, `participantIds`, `admins`, `owner`. A group's members are the union of all of them, so an admin is a member. + +A participant may be named two ways, and both are accepted: + +| Written as | Example | Resolved by | +|---|---|---| +| An eName | `@7b9c2e1a-…` | Taken as-is. | +| A profile record's id | `4f1a8c30-…` | Following the record to the eName behind it. | + +Both occur in practice — `GroupManifest.members` holds eNames while `Group.participantIds` holds profile ids — so a policy naming a group works regardless of which shape the group was written with. + +When a participant is given as a profile id, the eName is taken from the record's own `ename` field where it has one, and otherwise from the vault the record lives in. The record's own statement wins because the same profile syncs into several vaults, so the vault it happens to sit in does not reliably identify its subject. + +An id that resolves to nothing is skipped rather than treated as a member. + +### When membership cannot be determined + +A lookup that fails is not the same as a party being shown not to be a member, and the two lead to opposite answers: + +- A **grant** to a group applies only on proof of membership. Uncertainty withholds it. +- A **denial** naming a group applies unless the party is shown *not* to be a member. Uncertainty holds the denial. + +So a group whose record cannot be read is safe in both directions: it hands out nothing and it stops removing nothing. + +Where no group resolver is configured at all, groups simply do not resolve — that is the feature switched off rather than a failed lookup, and group grants and denials alike match nobody. + ## 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. @@ -240,9 +270,9 @@ Reserved permission bits exist for the same reason: they are refused today so th ## 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. +- **Group resolution reads records this eVault holds.** A group whose record has not synced here cannot be resolved, and is treated as undeterminable — see above for what that means in each direction. - `default_perms` above READ for unnamed parties is unsettled under the current sync model. ## See also diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index 3928660c1..fc3d043d4 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -314,7 +314,11 @@ A valid platform Bearer token satisfies the legacy path but does **not** bypass **`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`. +**Groups.** A grant or denial may name a group eName; it resolves to member eNames at decision time. The group record is found in the group's own vault or by its `ename` field, and participants are read from `members`, `memberIds`, `participants`, `participantIds`, `admins`, `owner` (unioned — admins and owner count). Each entry is **either an eName or a profile record's id**; an id resolves via that record's `ename` field, else the vault it lives in. Never assume one form. + +Undeterminable membership is not "not a member": a grant needs proof and is withheld, a denial stands until non-membership is shown. With no resolver configured, groups match nobody at all. + +Not yet wired: no condition evaluator is connected, so any `require` group containing conditions fails closed. Write policies using `grants`, `denials.enames`, group enames, and empty-group `require`. Full model: `docs/docs/W3DS Protocol/Access-Control.md`. Special cases: