From 82f14f0c91718a262be2e50509a34f4024329fff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:35:58 +0000 Subject: [PATCH] feat(spec,plugin-security): publish the caller's resolved permission SETS on the security service (#7616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ISecurityService` could report the caller's effective permission-set NAMES (`resolvePermissionSetNames`) and nothing else — the right primitive for an audience check, the wrong one for a merge. A consumer folding the caller's grants into one answer needs `objects`, `fields`, `systemPermissions` and `tabPermissions`; none of the four is reachable from a name, so `/auth/me/permissions` and `/me/apps` each re-implement the resolution locally instead. One rule, three copies — drifted from the enforcement path three times (#7608, #7555, #6334), each divergence found only after it reached a user. Adds `resolvePermissionSetsForContext(context)` to the published contract: the same resolution the names surface reports the names of, returned whole and in resolution order. Merge semantics stay with the caller, because two consumers legitimately project different subsets of the same sets. The method is OPTIONAL, and that is load-bearing: the contract's availability rule has consumers resolve this service as `Partial`, so a caller must keep its own resolution as the fallback until a floor version carrying the method can be assumed. Optional makes that degradation a property of the type — the unguarded call does not compile. `plugin-security` exposes it on the REGISTERED SERVICE LITERAL, not merely as a public class member. The consumers must never take a runtime dependency on this plugin (optional in the stacks those endpoints serve), so the service locator is the only seam that can carry the delegation, and a method the class declares but the literal does not expose is unreachable across it. Pinned by a test that resolves the service off `registerService` and calls through that handle; reverse-verified (all 5 cases fail without the exposure). Also closes one implementation gap so the declaration is true rather than nominal: the `sys_permission_set` loader dropped `tab_permissions`, so every DB-authored set came back without the column `/me/apps` filters its app list with. Inert for enforcement (nothing on the data plane reads `tabPermissions`), no extra query — the row is already fetched in full. No behaviour change today: the method has no caller yet, by design. The call sites are step 2 and land separately, because `/me/apps` deliberately projects a narrower column set than `/auth/me/permissions` and delegating changes which columns load on both surfaces — a user-visible change wanting its own before/ after measurement. Also corrects a stale `resolveFallbackPermissionSets` doc-comment describing the `resolved.length === 0 && fallbackName` step PR #7615 deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011rK3z836HF4CjfpbJuEjaz --- ...ecurity-service-resolve-permission-sets.md | 73 +++++++ ...ve-permission-sets-for-context.pin.test.ts | 188 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 57 +++++- .../src/contracts/security-service.test.ts | 68 +++++++ .../spec/src/contracts/security-service.ts | 46 +++++ 5 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 .changeset/security-service-resolve-permission-sets.md create mode 100644 packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts diff --git a/.changeset/security-service-resolve-permission-sets.md b/.changeset/security-service-resolve-permission-sets.md new file mode 100644 index 0000000000..5b337df5c6 --- /dev/null +++ b/.changeset/security-service-resolve-permission-sets.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": patch +--- + +feat(spec,plugin-security): publish the caller's resolved permission SETS on the `security` service (#7616) + +`ISecurityService` could report the caller's effective permission-set **names** +(`resolvePermissionSetNames`) and nothing else. That is the right primitive for +an audience check — "does this caller hold `sales_manager`?" — and the wrong one +for a **merge**. A consumer that must fold the caller's grants into one answer +needs the sets themselves: `objects`, `fields`, `systemPermissions`, +`tabPermissions`. None of the four is reachable from a name. + +So the two consumers that need a merge re-implement the resolution instead. +`/auth/me/permissions` and `/me/apps` (`plugin-hono-server`'s +`current-user-endpoints.ts`) each resolve the caller's permission sets by hand, +alongside `SecurityPlugin`'s own copy on the data plane — **one rule, three +copies**, and it has now drifted from the enforcement path three times, each +divergence found only after it reached a user: + +- **#7608** — the plugin applied the ADR-0090 D5 baseline additively while both + endpoints kept the `resolved.length === 0` fallback cliff, so a member's first + grant took them from **2 apps to 1** on `/me/apps`. +- **#7555 / PR #7605** — an app-declared `isDefault` set *displaced* + `member_default` here rather than composing with it. +- **#6334** — the same file's grant aggregation missed `sys_user_position` + entirely; closed by delegating to `resolveUserAuthzGrants`, which is the + precedent this extends one step further. + +**New: `ISecurityService.resolvePermissionSetsForContext(context)`** — the same +resolution `resolvePermissionSetNames` reports the names of, returned whole and +in resolution order. Implementations must return the sets their own enforcement +path resolved (positions expanded, the D5 baseline applied additively, the D10 +agent-principal rule honoured), never a re-derivation. Merge semantics stay with +the caller on purpose: two consumers legitimately project different subsets of +the same sets, and folding a merge in here would make the method a fourth copy +of the rule rather than the one source of its input. + +**It is OPTIONAL, and that is load-bearing.** The contract's availability rule +has consumers resolve this service as `Partial`, so a caller +must keep its own resolution as the fallback until a floor version carrying the +method can be assumed. Declaring it optional makes that degradation a property +of the type — the unguarded call does not compile — rather than a promise in +prose. + +`plugin-security` exposes it on the **registered service literal**, not merely +as a public class member. That distinction is the whole point: the two +consumers must never take a runtime dependency on `plugin-security` (it is +optional in the stacks those endpoints serve), so the service locator is the +only seam that can carry the delegation, and a method the class declares but the +literal does not expose is unreachable across it. + +**One implementation gap closed so the declaration is true rather than +nominal.** The plugin's `sys_permission_set` loader hydrated `objects`, `fields` +and `systemPermissions` but dropped `tab_permissions`, so every **DB-authored** +set came back without the column `/me/apps` filters its app list with. Nothing +on the data plane reads `tabPermissions` (the evaluator never mentions it), so +this is inert for enforcement today — but shipping a contract that promises the +sets whole over a loader that drops a quarter of them is exactly the +declared-≠-delivered defect this card exists to prevent. The row is already +fetched in full: no extra query, one JSON parse. + +**No behaviour changes today.** The method has no caller yet — by design. The +call sites are step 2 and land separately, because `/me/apps` deliberately +projects a narrower column set than `/auth/me/permissions`, so delegating +changes which columns load on both surfaces: a user-visible change that wants +its own before/after measurement rather than riding along on a contract +addition. + +Also corrects a stale doc-comment on `resolveFallbackPermissionSets`, which +still described the `resolved.length === 0 && fallbackName` second step that +PR #7615 deleted (that guard *was* the fallback cliff D5 abolishes). diff --git a/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts b/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts new file mode 100644 index 0000000000..f4113af4bb --- /dev/null +++ b/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7616] `resolvePermissionSetsForContext` — DECLARED = REACHABLE. + * + * `ISecurityService` declaring a method proves nothing about a deployment: the + * consumers of this surface (`/auth/me/permissions`, `/me/apps` in + * `plugin-hono-server`) must never take a runtime dependency on this plugin — + * it is optional in the stacks those endpoints serve — so the ONLY seam they + * can reach it through is the service locator. A method the class declares but + * the registered literal does not expose is unreachable across that seam, and + * a consumer's feature detection would correctly report it absent forever. + * + * So every case below resolves the service the way a cross-package consumer + * does — off the `ctx.registerService('security', …)` call — and never off the + * plugin instance. `getMetadataReadableFields` is pinned the same way in + * `get-metadata-readable-fields.test.ts`; this file extends the pattern to the + * one thing that surface could not answer before: the sets themselves. + * + * The second half is what makes the declaration honest rather than nominal. The + * contract says the sets come back WHOLE — `objects`, `fields`, + * `systemPermissions`, `tabPermissions` — because a consumer that must MERGE + * the caller's grants cannot reach any of those four from + * `resolvePermissionSetNames`. Each is asserted through the located handle, on + * BOTH authoring paths a set can arrive by (declared in metadata, and authored + * in `sys_permission_set` through the DB loader), because the loader is where + * a column silently goes missing. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import type { ISecurityService } from '@objectstack/spec/contracts'; + +/** A metadata-declared set: the platform baseline every member resolves additively. */ +const MEMBER_DEFAULT: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { deal: { allowRead: true } }, + fields: { 'deal.amount': { readable: true, editable: false } }, + systemPermissions: [], + tabPermissions: { app_crm: 'default_on' }, +} as any; + +/** + * A DB-authored set, as it sits in `sys_permission_set` — snake_case columns, + * JSON-encoded payloads. This is the row shape the plugin's `dbLoader` parses, + * and the shape `/me/apps` reads `tab_permissions` off today in its own copy. + */ +const SALES_MANAGER_ROW = { + name: 'sales_manager', + label: 'Sales Manager', + object_permissions: JSON.stringify({ deal: { allowRead: true, allowEdit: true } }), + field_permissions: JSON.stringify({ 'deal.amount': { readable: true, editable: true } }), + system_permissions: JSON.stringify(['setup.access']), + tab_permissions: JSON.stringify({ app_crm: 'visible' }), +}; + +function bootPlugin(dbRows: Array> = []) { + const schema: any = { name: 'deal', label: 'Deal', systemFields: false, fields: { id: { name: 'id' }, amount: { name: 'amount' } } }; + const ql: any = { + registerMiddleware: () => {}, + getSchema: (name: string) => (name === 'deal' ? schema : null), + findOne: async () => null, + find: async (object: string, query: any) => { + if (object !== 'sys_permission_set') return []; + const wanted: string[] = query?.where?.name?.$in ?? []; + return dbRows.filter((r) => wanted.includes(String(r.name))); + }, + }; + const metadata: any = { + get: async (_type: string, name: string) => (name === 'deal' ? schema : null), + list: async () => [MEMBER_DEFAULT], + }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { plugin: new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any), ctx }; +} + +/** + * Resolve the service EXACTLY as a cross-package consumer does: as a `Partial` + * off the locator, never off the plugin instance. The `Partial` is not + * defensive styling — it is the contract's own availability rule, and it is + * what makes the feature detection below the same expression the endpoints + * will write. + */ +async function locateSecurityService(dbRows: Array> = []): Promise> { + const { plugin, ctx } = bootPlugin(dbRows); + await plugin.init(ctx); + await plugin.start(ctx); + const registered = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; + return registered as Partial; +} + +describe('[#7616] resolvePermissionSetsForContext is reachable through the service locator', () => { + it('is exposed on the REGISTERED literal, not merely declared on the class', async () => { + const svc = await locateSecurityService(); + + // The expression a consumer writes. It is the whole point of the card: the + // class has carried this method (privately) all along, and every previous + // consumer still had to re-implement the resolution because this probe + // answered `undefined`. + expect(typeof svc.resolvePermissionSetsForContext).toBe('function'); + + // The rest of the published surface is untouched — the addition is + // additive, and a consumer that only knows the names surface is unaffected. + expect(typeof svc.resolvePermissionSetNames).toBe('function'); + expect(typeof svc.getReadFilter).toBe('function'); + }); + + it('is CALLABLE through that handle and returns the sets whole', async () => { + const svc = await locateSecurityService([SALES_MANAGER_ROW]); + + const sets = await svc.resolvePermissionSetsForContext?.({ + userId: 'u1', + permissions: ['sales_manager'], + } as any); + + // Reachable AND working: a bound method that throws on call would satisfy + // the typeof probe above and fail every consumer. + const byName = new Map((sets ?? []).map((s) => [s.name, s])); + expect([...byName.keys()].sort()).toEqual(['member_default', 'sales_manager']); + + // All four columns the names surface cannot reach, on the DB-authored set — + // the path where a column goes missing, since the loader projects the row + // by hand. + const dbAuthored: any = byName.get('sales_manager'); + expect(dbAuthored.objects).toEqual({ deal: { allowRead: true, allowEdit: true } }); + expect(dbAuthored.fields).toEqual({ 'deal.amount': { readable: true, editable: true } }); + expect(dbAuthored.systemPermissions).toEqual(['setup.access']); + // The column `/me/apps` filters its app list with. Dropped by this loader + // until #7616 — which would have made the published contract false for + // every DB-authored set the moment a consumer trusted it. + expect(dbAuthored.tabPermissions).toEqual({ app_crm: 'visible' }); + + // …and on the metadata-declared set, which arrives by the other path. + const declared: any = byName.get('member_default'); + expect(declared.objects).toEqual({ deal: { allowRead: true } }); + expect(declared.systemPermissions).toEqual([]); + expect(declared.tabPermissions).toEqual({ app_crm: 'default_on' }); + }); + + it('is the SAME resolution the names surface reports — baseline additive, no cliff', async () => { + const svc = await locateSecurityService([SALES_MANAGER_ROW]); + const context = { userId: 'u1', permissions: ['sales_manager'] } as any; + + const names = await svc.resolvePermissionSetNames?.(context); + const sets = await svc.resolvePermissionSetsForContext?.(context); + + // Not "two methods that agree today" — the names surface is literally + // `.map(s => s.name)` over these sets. Pinning the equality is what stops a + // future edit from giving the two surfaces separate resolutions, which is + // the drift shape this card exists to end. + expect((sets ?? []).map((s) => s.name)).toEqual(names); + + // [ADR-0090 D5 / #7608] The baseline is ADDITIVE: a caller holding an + // explicit grant still resolves `member_default`. The `resolved.length === 0` + // cliff is what took a member from 2 apps to 1 on `/me/apps` the day they + // received their first grant — a consumer delegating here inherits the + // corrected rule instead of re-deriving it. + expect(names).toContain('member_default'); + expect(names).toContain('sales_manager'); + }); + + it('a caller with no grants of their own still resolves the baseline', async () => { + const svc = await locateSecurityService(); + + const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1' } as any); + expect((sets ?? []).map((s) => s.name)).toEqual(['member_default']); + }); + + it('an ANONYMOUS caller resolves nothing — the baseline is gated on a principal', async () => { + const svc = await locateSecurityService(); + + // No `userId` → no additive baseline, matching the engine middleware. The + // contract promises the enforcement path's answer, so this surface must not + // hand a guest the member floor either. + const sets = await svc.resolvePermissionSetsForContext?.({ positions: [], permissions: [] } as any); + expect(sets).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 6cd324d5a0..391a2cf2f6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -754,6 +754,17 @@ export class SecurityPlugin implements Plugin { objects: parseJson(r.object_permissions, {}), fields: parseJson(r.field_permissions, {}), systemPermissions: parseJson(r.system_permissions, []), + // [#7616] Hydrate the tab column too. Nothing on the DATA plane + // reads `tabPermissions` (the evaluator never mentions it), so this + // is inert for enforcement — but `resolvePermissionSetsForContext` + // is published on the `security` service as returning the sets + // WHOLE, and a loader that dropped this column would make that + // declaration false for every DB-authored set: the UI-plane copy in + // `/me/apps` reads exactly `tab_permissions` off the same row. + // Declared ≠ delivered is the failure this contract exists to + // prevent, so the column is loaded where the promise is made. The + // row is already fetched in full — no extra query, one JSON parse. + tabPermissions: parseJson(r.tab_permissions, {}), // [ADR-0090 D12] Hydrate the delegated-admin scope so the gate can // resolve a DB-authored delegate's authority. Null column → absent. ...(r.admin_scope ? { adminScope: parseJson(r.admin_scope, undefined) } : {}), @@ -886,6 +897,32 @@ export class SecurityPlugin implements Plugin { const sets = await this.resolvePermissionSetsForContext(context); return sets.map((s) => s.name); }, + // [#7616] The same resolution, returned WHOLE — `objects`, `fields`, + // `systemPermissions`, `tabPermissions`, in resolution order. The names + // above answer an AUDIENCE question; a consumer that must MERGE the + // caller's grants (the object/field map `/auth/me/permissions` serves, + // the capability + tab surface `/me/apps` filters its app list with) + // cannot reach any of those four columns from a name, so both endpoints + // re-implement this resolution locally instead — one rule in three + // copies, which has already drifted from the enforcement path three + // times (#7608, #7555, #6334), each divergence found only after it + // reached a user. + // + // Exposed HERE, on the registered literal, and not merely as a public + // class member: `plugin-hono-server` must never take a runtime + // dependency on this plugin (it is optional in the stacks those + // endpoints serve — the `!evaluator` degraded branches are exactly its + // absence), so the service locator is the only seam that can carry the + // delegation. A method the class declares but the literal does not + // expose is unreachable across that seam, which is the precise failure + // this addition exists to prevent. + // + // The class method stays private on purpose: this literal is the + // supported surface, and routing every cross-package caller through it + // is what keeps the published contract and the enforcement path the + // same code rather than two that agree today. + resolvePermissionSetsForContext: (context?: any): Promise => + this.resolvePermissionSetsForContext(context), // [ADR-0090 D6] First-class access explanation. Same code paths as // the middleware (resolution/evaluator/RLS compiler) — explained by // construction. Explaining ANOTHER user requires `manage_users`. @@ -936,7 +973,7 @@ export class SecurityPlugin implements Plugin { this.getMetadataReadableFields(object, context), }); ctx.registerService('security', registeredSecurityService); - ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, getMetadataReadableFields, canExport, checkAuthoredRowWrite, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / ADR-0106 D7 / #3544 / #3547 / #5493'); + ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, getMetadataReadableFields, canExport, checkAuthoredRowWrite, resolvePermissionSetNames, resolvePermissionSetsForContext, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / ADR-0106 D7 / #3544 / #3547 / #5493 / #7616'); } catch (e) { ctx.logger.warn?.('[security] failed to register "security" service', { error: (e as Error).message, @@ -3405,8 +3442,18 @@ export class SecurityPlugin implements Plugin { /** * [ADR-0106 D7] Resolve the configured baseline permission set(s) on their - * own — the second step `/auth/me/permissions` takes when a caller's own - * names resolve to nothing (`resolved.length === 0 && fallbackName`). + * own — the deployment's answer to "what does a caller with NO resolved sets + * of their own see". + * + * [#7616] This used to describe itself as "the second step + * `/auth/me/permissions` takes when a caller's own names resolve to nothing + * (`resolved.length === 0 && fallbackName`)". That step no longer exists: + * PR #7615 (#7608, ADR-0090 D5) deleted it, because the `resolved.length === 0` + * guard WAS the fallback cliff D5 abolishes — a member's first explicit grant + * silently cost them the entire baseline. That endpoint now folds the + * baseline into the FIRST resolution, additively and unconditionally, so a + * second call over a subset of the same names could add nothing. Only the + * cross-reference was stale; this method's own behaviour never depended on it. * * Distinct from the post-resolution fallback inside * {@link resolvePermissionSetsForContext}, which is gated on `context.userId` @@ -3417,8 +3464,8 @@ export class SecurityPlugin implements Plugin { * * [#7555] Reads the COMPOSED baseline, deliberately — D7 warrants it. This * method exists to answer "what does this deployment's baseline disclose", - * and its whole justification is being *the same* resolution the data plane - * performs ("the same two-step `/auth/me/permissions` performs", above); one + * and its whole justification is being *the same* baseline resolution the + * data plane performs (the composed `security.baselinePermissionSets`); one * plane composing while the other displaced would put the two planes in * disagreement about what the baseline even is, which is the drift D7 is * written to avoid. On every deployment that declares no app baseline the diff --git a/packages/spec/src/contracts/security-service.test.ts b/packages/spec/src/contracts/security-service.test.ts index 9a786fa75a..a8c2971b23 100644 --- a/packages/spec/src/contracts/security-service.test.ts +++ b/packages/spec/src/contracts/security-service.test.ts @@ -155,6 +155,74 @@ describe('Security Service Contract', () => { await expect(nothingDisclosable.getMetadataReadableFields?.('deal', { userId: 'u1' })).resolves.toEqual([]); }); + it('[#7616] resolvePermissionSetsForContext is OPTIONAL — absence keeps the consumer on its own resolution (compile-time)', () => { + // THE structural pin behind "a consumer must keep its local resolution as + // the fallback until a floor version carrying this method can be assumed". + // Optional is what makes that a property of the TYPE: a security service + // that predates the method still satisfies the contract, and the unguarded + // call does not compile, so the fallback branch cannot be dropped by + // accident on the way to a delegation that a deployment may not support. + const withoutIt: ISecurityService = makeService(); + expect(typeof withoutIt.resolvePermissionSetsForContext).toBe('undefined'); + + // Never invoked — its only job is to make the COMPILER prove the point. + const mustNotCompileWithoutAGuard = () => + // @ts-expect-error possibly undefined — a consumer must feature-detect first + withoutIt.resolvePermissionSetsForContext({ userId: 'u1' }); + expect(typeof mustNotCompileWithoutAGuard).toBe('function'); + + // The names-only sibling is NOT optional and stays reachable unguarded — + // the two are different questions, not two spellings of one, so a service + // carrying only the older method is a complete implementation. + expect(typeof withoutIt.resolvePermissionSetNames).toBe('function'); + }); + + it('[#7616] the sets carry the four columns the names cannot: objects, fields, systemPermissions, tabPermissions', async () => { + // Why the method exists at all. `resolvePermissionSetNames` answers an + // AUDIENCE question ("does this caller hold `sales_manager`?"); a consumer + // that must MERGE the caller's grants — the object/field map + // `/auth/me/permissions` serves, the capability + tab surface `/me/apps` + // filters with — cannot reach any of these four from a name, which is + // exactly why those two endpoints re-implement set resolution locally. + const service = makeService({ + resolvePermissionSetNames: async () => ['member_default', 'sales_manager'], + resolvePermissionSetsForContext: async () => [ + { + name: 'member_default', + objects: { deal: { allowRead: true } }, + fields: { 'deal.amount': { readable: true, editable: false } }, + systemPermissions: [], + tabPermissions: { app_crm: 'default_on' }, + }, + { + name: 'sales_manager', + objects: { deal: { allowRead: true, allowEdit: true } }, + fields: { 'deal.amount': { readable: true, editable: true } }, + systemPermissions: ['setup.access'], + tabPermissions: { app_crm: 'visible' }, + }, + ] as any, + }); + + const sets = await service.resolvePermissionSetsForContext?.({ userId: 'u1' }); + expect(sets?.map((s) => s.name)).toEqual(['member_default', 'sales_manager']); + // The names surface answers the audience question over the SAME resolution… + await expect(service.resolvePermissionSetNames({ userId: 'u1' })) + .resolves.toEqual(['member_default', 'sales_manager']); + // …and nothing else. Every column below is unreachable from that list. + expect(sets?.[1]?.objects).toBeDefined(); + expect(sets?.[1]?.fields).toBeDefined(); + expect(sets?.[1]?.systemPermissions).toEqual(['setup.access']); + expect(sets?.[1]?.tabPermissions).toEqual({ app_crm: 'visible' }); + + // The merge stays with the CALLER — this contract hands over the INPUT to + // it, unmerged and in resolution order, because two consumers legitimately + // project different subsets of the same sets. Folding a merge in here would + // make the method a fourth copy of the rule rather than the one source of + // its input. + expect(sets).toHaveLength(2); + }); + it('a partial implementation is feature-detectable rather than wrong', () => { // Consumers probe (`typeof svc.getReadableFields === 'function'`) so an // implementation may omit a method it cannot honour and still be usable. diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 4efebae6ee..5058a87555 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -56,6 +56,7 @@ import type { FilterCondition } from '../data/filter.zod.js'; import type { ExecutionContext } from '../kernel/execution-context.zod.js'; import type { ExplainDecision, ExplainOperation } from '../security/explain.zod.js'; +import type { PermissionSet } from '../security/permission.zod.js'; /** * The context shape these methods accept. @@ -288,6 +289,51 @@ export interface ISecurityService { */ resolvePermissionSetNames(context?: SecurityContext): Promise; + /** + * [#7616] The effective permission SETS for `context` — the same resolution + * {@link resolvePermissionSetNames} reports the names of, returned WHOLE: + * each set's `objects`, `fields`, `systemPermissions` and `tabPermissions`, + * in resolution order. + * + * **Why a second method rather than a wider return on the first.** The names + * are the primitive for an audience check ("does this caller hold + * `sales_manager`?"); the sets are the primitive for a MERGE. A consumer that + * must fold the caller's grants into one answer — the object/field access map + * `/auth/me/permissions` serves, the capability + tab surface `/me/apps` + * filters its app list with — cannot do it from names, so it re-implements + * set resolution locally instead. That local copy is the drift this method + * exists to end: the same rule has now diverged from the enforcement path + * three times, each divergence found only after it reached a user (#7608 — + * a member's first grant took them from 2 apps to 1; #7555 — an app-declared + * `isDefault` displaced `member_default`; #6334 — grant aggregation missed + * `sys_user_position` entirely). + * + * Implementations MUST return the sets their own enforcement path resolved — + * positions expanded, the ADR-0090 D5 baseline applied ADDITIVELY (never as a + * `resolved.length === 0` cliff), and the ADR-0090 D10 agent-principal rule + * honoured — not a re-derivation. Widening this to "the sets, roughly" would + * reintroduce the very drift the method removes. + * + * The merge semantics stay with the CALLER, deliberately: most-permissive for + * `objects`/`fields`, union for `systemPermissions`, highest-rank-wins for + * `tabPermissions`. Two consumers legitimately project different subsets of + * the same sets, and folding a merge in here would make this method the + * fourth copy of a rule instead of the one source of its input. + * + * **Throws** on resolution failure, exactly as {@link resolvePermissionSetNames} + * does; callers must fail CLOSED on a throw rather than reading it as "no sets". + * + * **OPTIONAL, and absence is a defined state — not a bug.** A security service + * that predates this method omits it, and a consumer resolving the service as + * `Partial` (the availability rule at the top of this file) + * must keep its own resolution as the fallback until a floor version carrying + * the method can be assumed. Declaring it optional is what makes that + * degradation a property of the type rather than a promise in prose: the + * unguarded call does not compile, so a consumer cannot skip the fallback by + * accident. + */ + resolvePermissionSetsForContext?(context?: SecurityContext): Promise; + /** * [#3544] Whether `context` may EXPORT `object` — the user-level export axis * (`ObjectPermissionSchema.allowExport`).