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`).