diff --git a/.changeset/delegate-permission-set-resolution.md b/.changeset/delegate-permission-set-resolution.md new file mode 100644 index 0000000000..8c02cce993 --- /dev/null +++ b/.changeset/delegate-permission-set-resolution.md @@ -0,0 +1,33 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +`/auth/me/permissions` and `/me/apps` now resolve the caller's permission sets by +calling `ISecurityService.resolvePermissionSetsForContext` on the `security` +service, instead of re-implementing that resolution twice locally. + +The endpoints previously composed the requested set names themselves (positions ∪ +explicit sets ∪ the deployment baseline), built their own `sys_permission_set` DB +loader, and called the permission evaluator directly — one rule in three copies, +which diverged from the enforcement path three times, each divergence found only +after it reached a user. They now project a single resolution owned by the +enforcement path. + +Behaviour is unchanged on the ordinary paths, measured on the wire. Three states +change, all of them states where the UI plane previously disagreed with the data +plane: + +- A deactivated `sys_permission_set` row whose name matches a live position name + no longer grants capabilities, tabs or object access on these endpoints. It + already granted nothing on the data plane. +- A permission set with a malformed JSON column is no longer dropped whole by + `/auth/me/permissions`; the malformed column degrades on its own, as it already + did for the data plane and for `/me/apps`. +- A stack whose SecurityPlugin started in degraded mode (no middleware-capable + engine, so the `security` service is never registered and nothing is enforced) + now takes the endpoints' documented degraded branch instead of reporting a + restrictive map computed against enforcement that does not exist. + +`plugin-hono-server` still takes no runtime dependency on `plugin-security`: the +resolution is reached through the service locator, and the degraded branches for +a stack with no SecurityPlugin are unchanged. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts index 0d851de15d..d2bf6035ba 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts @@ -31,16 +31,32 @@ // cases now pin is +2 apps, +2 capabilities, +1 readable object, +1 readable // field, all recovered. // -// ## Why the baseline must arrive as a SERVICE here, not as an `everyone` row +// ## Why the baseline must arrive through the RESOLVER, not as an `everyone` row // // `resolveUserAuthzGrants` already expands the implicit `everyone` position and // whatever is bound to it — that path was never on the cliff, and a fixture // that delivered the baseline that way would pass before and after the fix. -// The deployment baseline is a different channel: SecurityPlugin registers -// `security.baselinePermissionSets` (#7555 — the app-declared baseline composed -// with the platform `member_default`), and `baselinePermissionSetNames` is the -// only thing that reads it. That channel is the one the cliff gated, so these -// fixtures bind the baseline set to NO position and supply it as the service. +// The deployment baseline is a different channel, so these fixtures bind the +// baseline set to NO position and let the resolver apply it. +// +// ## What #7616 changed about these cases, and what it did NOT +// +// The composition itself is no longer this file's: both handlers now delegate +// to `ISecurityService.resolvePermissionSetsForContext` on the `security` +// service — the enforcement path's own resolution — instead of composing the +// requested names and loading `sys_permission_set` themselves. So the ADDITIVE +// RULE is pinned where it now lives (plugin-security's own cases); the double +// below stands for the contract, not for the rule. +// +// What these cases still measure is the half that is genuinely this file's and +// is what a user sees: the endpoints hand the caller's context to that resolver +// and PROJECT the answer onto the wire correctly — apps in `/me/apps`, +// capabilities and object/field access in `/auth/me/permissions`. The counts +// below are therefore unchanged from the #7608 fix, and they are the reason a +// regression in the projection still fails here rather than in plugin-security. +// The delegation itself is pinned at the bottom of this file: one resolver +// call per request, the caller's context passed whole, and NONE of the +// `security.*` internal handles read. import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; @@ -103,25 +119,54 @@ function permissionSet( } /** - * plugin-security's `PermissionEvaluator`, on the DB-backed branch this caller - * exercises. plugin-hono-server must not depend on plugin-security (OPTIONAL in - * the stacks these endpoints serve), so the double covers the one method both - * handlers call — and it records every identifier list it is handed, which is - * how the cases below can also state the mechanism (one call, baseline inside) - * alongside the user-visible count. + * [#7616] A stand-in for `ISecurityService.resolvePermissionSetsForContext` — + * the ONE resolution both handlers delegate to. plugin-hono-server must not + * depend on plugin-security (OPTIONAL in the stacks these endpoints serve), so + * the double covers the one contract method both handlers call, and records + * every context it is handed so the delegation cases can state the mechanism + * alongside the user-visible counts. + * + * It reproduces exactly the two properties of the plugin's resolution these + * cases stand on — requested = positions ∪ explicit sets ∪ baseline, ADDITIVE + * and unconditional (ADR-0090 D5), and the sets returned WHOLE from + * `sys_permission_set` — and nothing else. ⚠️ It is a stand-in, not the + * authority: an assertion about the RULE belongs in plugin-security, where the + * rule is. Everything asserted here is about what the endpoints do with the + * answer. */ -function makeEvaluator() { - const calls: string[][] = []; +function makeSecurityService(rows: Row[], baseline: string[]) { + const calls: any[] = []; + const parse = (v: unknown, fallback: unknown) => + typeof v === 'string' ? JSON.parse(v || JSON.stringify(fallback)) : v ?? fallback; return { calls, - resolvePermissionSets: async ( - identifiers: string[], - _metadata: unknown, - _bootstrap: unknown[] | undefined, - dbLoader?: (names: string[]) => Promise, - ) => { - calls.push([...identifiers]); - return dbLoader ? dbLoader(identifiers) : []; + service: { + resolvePermissionSetsForContext: async (context: any) => { + calls.push(context); + const requested: string[] = [ + ...(Array.isArray(context?.positions) ? context.positions : []), + ...(Array.isArray(context?.permissions) ? context.permissions : []), + ]; + if (context?.userId) { + for (const name of baseline) { + if (!requested.includes(name)) requested.push(name); + } + } + // Resolution ORDER, like the plugin's: requested order, not row + // order — the response's `permissionSets` array reports it. + return requested.flatMap((name) => + rows + .filter((r) => r.name === name) + .map((r) => ({ + name: r.name, + label: r.label, + objects: parse(r.object_permissions, {}), + fields: parse(r.field_permissions, {}), + systemPermissions: parse(r.system_permissions, []), + tabPermissions: parse(r.tab_permissions, {}), + })), + ); + }, }, }; } @@ -133,12 +178,23 @@ const metadata = { list: async () => [] as unknown[] }; * position. `explicitGrant` is the ONLY axis — it adds a single * `sys_user_permission_set` row, the "first real grant" D5 names. */ -function mount({ explicitGrant, baseline = [BASELINE], grantedSetId = 'ps_ops' }: { +function mount({ + explicitGrant, + baseline = [BASELINE], + grantedSetId = 'ps_ops', + legacyHandles = true, +}: { explicitGrant: boolean; - /** The registered `security.baselinePermissionSets`; `null` = slot unclaimed. */ - baseline?: string[] | null; + /** The deployment baseline the RESOLVER applies (its business since #7616). */ + baseline?: string[]; /** Which set the explicit grant binds — `ps_baseline` for the overlap case. */ grantedSetId?: 'ps_ops' | 'ps_baseline'; + /** + * Whether the locator carries the `security.*` handles this file used to + * resolve from. Present by DEFAULT, exactly as a real stack registers them, + * so the cases below prove the answer no longer depends on them. + */ + legacyHandles?: boolean; }) { const tables: Record = { sys_user: [{ id: USER, email: 'member@example.com' }], @@ -174,7 +230,7 @@ function mount({ explicitGrant, baseline = [BASELINE], grantedSetId = 'ps_ops' } { name: 'billing', requiredPermissions: ['billing.manage'] }, ], }; - const evaluator = makeEvaluator(); + const security = makeSecurityService(tables.sys_permission_set, baseline); const services: Record = { auth: { api: { @@ -186,24 +242,37 @@ function mount({ explicitGrant, baseline = [BASELINE], grantedSetId = 'ps_ops' } }, objectql: makeQl(tables), metadata, - 'security.permissions': evaluator, + security: security.service, }; - // [#7555] The composed baseline, as SecurityPlugin registers it. Omitted - // entirely for the unclaimed-slot case, where the locator THROWS (as the - // real kernel's does) and `baselinePermissionSetNames` falls back. - if (baseline !== null) services['security.baselinePermissionSets'] = baseline; + if (legacyHandles) { + // The handles this file used to resolve its own answer from, wired the + // way SecurityPlugin wires them. They are here to be IGNORED: a + // permission-set resolution rebuilt locally would read them and pass, + // so their presence is what gives `lookups` below its teeth. + services['security.permissions'] = { + resolvePermissionSets: async () => { + throw new Error('the endpoints must not resolve permission sets themselves'); + }, + }; + services['security.bootstrapPermissionSets'] = []; + services['security.baselinePermissionSets'] = baseline; + services['security.fallbackPermissionSet'] = baseline[0] ?? null; + } + /** Every service name the endpoints asked the locator for, in order. */ + const lookups: string[] = []; const app = new Hono(); registerCurrentUserEndpoints({ rawApp: app, ctx: { logger: { debug() {}, warn() {} }, getService: (name: string): T => { + lookups.push(name); if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); return services[name] as T; }, }, }); - return { app, evaluator }; + return { app, security, lookups }; } const permissionsOf = async (app: any) => @@ -248,31 +317,31 @@ describe('/me/apps — the baseline survives the first explicit grant (#7608)', expect(one).not.toContain('billing'); }); - it('resolves ONCE, with the baseline inside the request (no second, gated call)', async () => { - // The mechanism behind the counts above: the baseline is an INPUT to - // the single resolution, not a consolation prize for resolving to - // nothing. Asserting the call shape here is what lets the cases above - // stay about apps. - const { app, evaluator } = mount({ explicitGrant: true }); + it('[#7616] resolves ONCE, by delegating the caller\'s CONTEXT (no local resolution)', async () => { + // The mechanism behind the counts above. Pre-#7616 this handler + // composed a NAME LIST and called the evaluator with a DB loader of its + // own; it now hands the resolver the context and merges what comes + // back. Asserting the call shape here is what lets the cases above stay + // about apps. + const { app, security } = mount({ explicitGrant: true }); await app.request(`http://localhost${ME_APPS}`); - expect(evaluator.calls).toHaveLength(1); - expect(evaluator.calls[0]).toContain(BASELINE); - expect(evaluator.calls[0]).toContain(EXPLICIT); + expect(security.calls).toHaveLength(1); + expect(security.calls[0].userId).toBe(USER); + // The caller's own grants arrive as the context's fields — NOT + // pre-composed with the baseline by this file. + expect(security.calls[0].permissions).toContain(EXPLICIT); + expect(security.calls[0].permissions).not.toContain(BASELINE); }); - it('does not duplicate a baseline name the caller already holds explicitly', async () => { - // A member granted the baseline set DIRECTLY must not have it pushed a - // second time: `resolvePermissionSets` would merge the same set into - // itself, and the DB loader's `limit: names.length` would over-read. - // The plugin's copy guards this with `if (!requested.includes(name))`; - // this is the case that keeps the guard honest here. - const { app, evaluator } = mount({ explicitGrant: true, grantedSetId: 'ps_baseline' }); - await app.request(`http://localhost${ME_APPS}`); + it('a member granted the baseline set DIRECTLY sees it once, not twice', async () => { + // The overlap case, kept as a USER-VISIBLE assertion: a member whose + // one explicit grant IS the baseline set must come out with exactly the + // baseline's apps. De-duplicating the requested names is the resolver's + // job since #7616 — what must hold here is that merging a set into + // itself does not change the projection. + const { app } = mount({ explicitGrant: true, grantedSetId: 'ps_baseline' }); - const requested = evaluator.calls[0]; - expect(requested).toContain(BASELINE); - expect(requested.filter((n) => n === BASELINE)).toHaveLength(1); expect(await appNamesOf(app)).toEqual(['home', 'reports']); }); }); @@ -320,35 +389,57 @@ describe('/auth/me/permissions — the same rule on the object/field surface (#7 expect(one.systemPermissions.length - zero.systemPermissions.length).toBe(1); }); - it('resolves ONCE here too, with the baseline inside the request', async () => { - const { app, evaluator } = mount({ explicitGrant: true }); + it('[#7616] delegates here too — one call, the caller\'s context, no name list', async () => { + const { app, security } = mount({ explicitGrant: true }); await app.request(`http://localhost${ME_PERMISSIONS}`); - expect(evaluator.calls).toHaveLength(1); - expect(evaluator.calls[0]).toContain(BASELINE); - expect(evaluator.calls[0]).toContain(EXPLICIT); + expect(security.calls).toHaveLength(1); + expect(security.calls[0].userId).toBe(USER); + expect(security.calls[0].permissions).toContain(EXPLICIT); + expect(security.calls[0].permissions).not.toContain(BASELINE); }); }); describe('the baseline is a floor, not a licence (#7608)', () => { it('a deployment declaring an EMPTY baseline resolves the explicit grant alone', async () => { - // The additive push must degrade to a plain resolution when there is - // nothing to add — not throw, and not invent `member_default` on a - // deployment that deliberately declared none. - const { app, evaluator } = mount({ explicitGrant: true, baseline: [] }); + // A deployment that deliberately declared no baseline must come out + // with the explicit grant and nothing invented on top of it. + const { app } = mount({ explicitGrant: true, baseline: [] }); expect(await appNamesOf(app)).toEqual(['exports']); - expect(evaluator.calls[0]).not.toContain(BASELINE); }); - it('an UNCLAIMED baseline slot still applies the `member_default` default, additively', async () => { - // No SecurityPlugin baseline registration at all: `getService` throws, - // both reads in `baselinePermissionSetNames` fall through, and the bare - // `member_default` default stands — which in this fixture IS the - // baseline set, so the member keeps it through their grant. - const { app } = mount({ explicitGrant: true, baseline: null }); - - expect(await appNamesOf(app)).toEqual(['exports', 'home', 'reports']); + it('[#7616] the answer does not depend on the `security.*` internal handles', async () => { + // REPLACES the case that pinned this file's own baseline fallback + // chain (`security.baselinePermissionSets` → `security.fallback- + // PermissionSet` → a bare `member_default`). That chain is deleted: + // which baseline a deployment applies is the resolver's answer now, and + // an assertion about the chain would pass here while measuring nothing + // this file still does. + // + // What replaces it is the property that makes the deletion true. The + // contract calls `security.permissions` and its siblings implementation + // internals, "deliberately NOT part of this contract"; the default + // fixture registers them anyway, and the endpoints must never ask for + // them. The `security.permissions` double THROWS if called, so a + // re-introduced local resolution fails loudly rather than quietly + // agreeing. + const withHandles = mount({ explicitGrant: true }); + const withoutHandles = mount({ explicitGrant: true, legacyHandles: false }); + + expect(await appNamesOf(withHandles.app)).toEqual(['exports', 'home', 'reports']); + expect(await appNamesOf(withoutHandles.app)).toEqual(['exports', 'home', 'reports']); + for (const name of [ + 'security.permissions', + 'security.bootstrapPermissionSets', + 'security.baselinePermissionSets', + 'security.fallbackPermissionSet', + ]) { + expect(withHandles.lookups).not.toContain(name); + } + // The control: the locator WAS asked for the published service, so the + // assertion above is a real absence and not an empty recording. + expect(withHandles.lookups).toContain('security'); }); it('an app whose requiredPermissions nobody holds stays filtered for both members', async () => { diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts new file mode 100644 index 0000000000..0c7089256f --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7616 — `/auth/me/permissions` and `/me/apps` DELEGATE permission-set +// resolution instead of re-implementing it. +// +// Both handlers used to resolve the caller's sets themselves: compose the +// requested names (positions ∪ explicit sets ∪ the deployment baseline), build a +// `sys_permission_set` DB loader, call the evaluator. That made one rule three +// copies — the enforcement path's, and one per endpoint — and it drifted three +// times, each divergence found only after it reached a user (#7608, #7555, +// #6334). They now call `ISecurityService.resolvePermissionSetsForContext` on +// the `security` service, which is the enforcement path's own resolution. +// +// ## The two absences, which are NOT the same absence +// +// The contract declares the method OPTIONAL, and this file must go on serving +// stacks with no SecurityPlugin at all — it is optional in the compositions +// these endpoints serve, and taking a runtime dependency on it is the one thing +// the card forbids outright. So: +// +// "present but too old to carry the method" → the local fallback for it is +// DELETED (the method ships in +// @objectstack/spec@17.0.0) +// "absent entirely" → the degraded branches STAY +// +// The cases below pin both, and pin that the second did not quietly become the +// first: absence must still produce a defined degraded answer, never a throw and +// never a hard dependency. +// +// ## The third state, which the two-absence framing does not name +// +// SecurityPlugin registers `security.permissions` in `init()` but the `security` +// service only in `start()` — which RETURNS EARLY on an engine that cannot take +// middleware. A stack in that state has the internal handle and no published +// service, AND no security middleware at all, so its data plane enforces +// nothing. Keying the degraded branch on the published service (rather than on +// the internal handle) is what makes the UI plane agree with that: see the +// `start() bailed` case below for the before/after this changed. + +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; + +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; +const ME_APPS = '/api/v1/me/apps'; +const USER = 'usr_member'; +const ACTIVE_ORG = 'org_active'; +const GRANTED = 'showcase_ops'; + +type Row = Record; + +/** The sets a resolver hands back — whole, as the contract publishes them. */ +const RESOLVED = [ + { + name: GRANTED, + label: 'Showcase Ops', + systemPermissions: ['showcase.export_data'], + tabPermissions: { exports: 'visible' }, + // The columns `/me/apps` never projected out of its own DB loader. + // Delegating loads them; nothing here may put them on that wire. + objects: { showcase_order: { allowRead: true, allowEdit: true } }, + fields: { 'showcase_order.total': { readable: true, editable: true } }, + }, +]; + +const APPS = [ + { name: 'exports', requiredPermissions: ['showcase.export_data'] }, + { name: 'billing', requiredPermissions: ['billing.manage'] }, + { name: 'open', requiredPermissions: [] }, +]; + +/** `where` matcher: scalar equality plus the `$in` form the resolver sends. */ +function matches(row: Row, where: Row | undefined): boolean { + return Object.entries(where ?? {}).every(([key, cond]) => { + // REFUSE an unsupported combinator rather than reading it as a field + // name — a silent `false` here would look exactly like a row that did + // not match, and the case above it would pass for the wrong reason. + if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`); + const value = row[key] ?? null; + if (cond && typeof cond === 'object' && Array.isArray((cond as any).$in)) { + return (cond as any).$in.includes(value); + } + return value === (cond ?? null); + }); +} + +const TABLES: Record = { + sys_user: [{ id: USER, email: 'member@example.com' }], + sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [ + { id: 'ups1', user_id: USER, permission_set_id: 'ps_ops', organization_id: ACTIVE_ORG }, + ], + sys_position: [], + sys_position_permission_set: [], + sys_permission_set: [{ id: 'ps_ops', name: GRANTED }], +}; + +const ql = { + find: async (object: string, opts: any) => { + const rows = (TABLES[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + registry: { getAllApps: () => APPS, getAllObjects: () => [] }, + getSchema: () => undefined, +}; + +/** + * @param security what the `security` slot holds: + * 'resolver' — a service carrying the method (the ordinary stack); + * 'throws' — a service whose resolution FAILS (the contract's fail-closed + * stance: "callers must fail CLOSED on a throw rather than + * reading it as no sets"); + * 'too-old' — a service WITHOUT the method; + * 'unclaimed' — no `security` service at all. + * @param internalHandle whether `security.permissions` is registered — the + * `init()`-time handle a degraded-start SecurityPlugin leaves behind. + */ +function mount({ + security, + internalHandle = false, +}: { + security: 'resolver' | 'throws' | 'too-old' | 'unclaimed'; + internalHandle?: boolean; +}) { + const services: Record = { + auth: { + api: { + getSession: async () => ({ + user: { id: USER, email: 'member@example.com' }, + session: { activeOrganizationId: ACTIVE_ORG }, + }), + }, + }, + objectql: ql, + metadata: { list: async () => [] as unknown[] }, + }; + if (security === 'resolver') { + services.security = { resolvePermissionSetsForContext: async () => RESOLVED }; + } else if (security === 'throws') { + services.security = { + resolvePermissionSetsForContext: async () => { + throw new Error('permission-set resolution failed'); + }, + }; + } else if (security === 'too-old') { + // Every OTHER method of the contract, and not this one. + services.security = { getReadFilter: async () => undefined, resolvePermissionSetNames: async () => [] }; + } + if (internalHandle) { + services['security.permissions'] = { + resolvePermissionSets: async () => { + throw new Error('the endpoints must not resolve permission sets themselves'); + }, + }; + } + const app = new Hono(); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { + logger: { debug() {}, warn() {} }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name] as T; + }, + }, + }); + return app; +} + +const permissionsOf = async (app: any) => + (await app.request(`http://localhost${ME_PERMISSIONS}`)).json() as Promise; +const appsBodyOf = async (app: any) => + (await app.request(`http://localhost${ME_APPS}`)).json() as Promise; +const appNamesOf = async (app: any) => + ((await appsBodyOf(app)).apps as any[]).map((a) => a.name).sort(); + +describe('[#7616] the delegated resolution reaches both wires', () => { + it('/auth/me/permissions projects the resolved sets it is handed', async () => { + const body = await permissionsOf(mount({ security: 'resolver' })); + + expect(body.authenticated).toBe(true); + expect(body.permissionSets).toEqual([GRANTED]); + expect(body.objects.showcase_order?.allowRead).toBe(true); + expect(body.fields['showcase_order.total']?.editable).toBe(true); + expect(body.systemPermissions).toEqual(['showcase.export_data']); + expect(body.tabPermissions).toEqual({ exports: 'visible' }); + }); + + it('/me/apps filters on the SAME resolution', async () => { + // `billing` is gated on a capability nobody holds, `open` on none. + expect(await appNamesOf(mount({ security: 'resolver' }))).toEqual(['exports', 'open']); + }); + + it('the wider column set stays OFF the /me/apps wire', async () => { + // THE measurement this card asked for. `/me/apps` used to fetch a + // narrower projection of `sys_permission_set` (`name` + + // `systemPermissions` + `tabPermissions`) than `/auth/me/permissions` + // did; delegating means one resolution serves both, so the sets now + // arrive here carrying `objects` and `fields` too. + // + // Loading them changes what is in MEMORY. This asserts it changes + // nothing that reaches the CLIENT: the body is the app list and only + // the app list, byte-identical to the registry's own rows, with no + // permission-set column leaking through the filter. + const body = await appsBodyOf(mount({ security: 'resolver' })); + + expect(Object.keys(body)).toEqual(['apps']); + expect(body.apps).toEqual([ + { name: 'exports', requiredPermissions: ['showcase.export_data'] }, + { name: 'open', requiredPermissions: [] }, + ]); + expect(JSON.stringify(body)).not.toContain('showcase_order'); + expect(JSON.stringify(body)).not.toContain('readable'); + }); +}); + +describe('[#7616] absence #1 — a service too old to carry the method', () => { + // The absence the released floor cleared. There is no local resolution left + // to fall back to, and re-adding one is what these two cases forbid. + it('/auth/me/permissions degrades rather than resolving locally', async () => { + const body = await permissionsOf(mount({ security: 'too-old' })); + + expect(body.authenticated).toBe(true); + expect(body.userId).toBe(USER); + expect(body.objects).toEqual({}); + expect(body.fields).toEqual({}); + }); + + it('/me/apps degrades rather than resolving locally', async () => { + expect(await appNamesOf(mount({ security: 'too-old' }))).toEqual( + ['billing', 'exports', 'open'], + ); + }); +}); + +describe('[#7616] absence #2 — no SecurityPlugin at all (KEPT)', () => { + // plugin-hono-server must not take a runtime dependency on plugin-security. + // These are the pre-existing degraded branches, unchanged: they answer + // without any security service in the locator, and they answer 200. + it('/auth/me/permissions answers empty-but-authenticated', async () => { + const body = await permissionsOf(mount({ security: 'unclaimed' })); + + expect(body.authenticated).toBe(true); + expect(body.userId).toBe(USER); + expect(body.objects).toEqual({}); + expect(body.fields).toEqual({}); + // The capability/tab keys are ABSENT from this body rather than empty — + // the frontend's fail-open cue, and how it tells "no answer" from "no + // access". An empty object here would read as the latter. + expect('systemPermissions' in body).toBe(false); + expect('tabPermissions' in body).toBe(false); + }); + + it('/me/apps fails OPEN and lists every app', async () => { + expect(await appNamesOf(mount({ security: 'unclaimed' }))).toEqual( + ['billing', 'exports', 'open'], + ); + }); +}); + +describe('[#7616] the third state — SecurityPlugin present, start() bailed', () => { + // `security.permissions` is registered in `init()`; the `security` service + // only in `start()`, which returns early on an engine that cannot take + // middleware — and that same early return is BEFORE the plugin registers + // any middleware, so nothing is enforced on the data plane either. + // + // Measured before/after on this branch: this state used to answer with a + // restrictive map and 1 of 3 apps, computed against enforcement that does + // not exist — so the access it reported as withheld was not being withheld + // by anything, and the console described a policy no layer applied. It now + // degrades, which is what the degraded branch's own premise ("matches + // server behaviour when SecurityPlugin isn't registered") asks for. + // + // That is the argument on its own merits, and it is deliberately NOT + // sourced to a ruling: no card here grades one fail-direction as worse + // than the other, and an earlier draft of this comment mis-cited #7608 as + // doing so. #7608 says the opposite of what it was cited for — it calls + // the UI under-reporting direction the MILDER reading of its own defect + // ("so it presents as ... rather than as an exposure"). Widening this + // state is a judgement, reviewed as one. + it('degrades on both surfaces, and never calls the internal handle', async () => { + // The `security.permissions` double THROWS if called, so a resolution + // routed back through the internal handle fails here rather than + // silently resurrecting the copy this card deleted. + const body = await permissionsOf(mount({ security: 'unclaimed', internalHandle: true })); + expect(body.authenticated).toBe(true); + expect(body.objects).toEqual({}); + + expect(await appNamesOf(mount({ security: 'unclaimed', internalHandle: true }))).toEqual( + ['billing', 'exports', 'open'], + ); + }); +}); + +describe('[#7616] a failed resolution fails CLOSED, as the contract requires', () => { + // "Throws on resolution failure; callers must fail CLOSED on a throw rather + // than reading it as no sets." A thrown resolution is NOT absence: absence + // is a composition fact and fails open, failure is a runtime fault and must + // not hand out access it could not verify. + it('/auth/me/permissions reports no access', async () => { + const body = await permissionsOf(mount({ security: 'throws' })); + + expect(body.authenticated).toBe(true); + expect(body.objects).toEqual({}); + expect(body.fields).toEqual({}); + expect(body.systemPermissions).toEqual([]); + expect(body.tabPermissions).toEqual({}); + }); + + it('/me/apps keeps filtering — it does not widen to the full list', async () => { + // The distinction that matters: `open` requires nothing and survives; + // `exports` and `billing` are permission-gated and must not appear on a + // resolution nobody completed. + expect(await appNamesOf(mount({ security: 'throws' }))).toEqual(['open']); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts index d2cffc291e..09fcf0ad62 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts @@ -25,6 +25,15 @@ // surfaced. A negative asserted on its own would pass in the pre-fix world for // the wrong reason — because the resolver produced nothing at all, not because // it judged the invalid row correctly. +// +// [#7616] The grant aggregation these cases are about is UNCHANGED: it is +// `resolveUserAuthzGrants` filling `execCtx.positions` / `execCtx.permissions`, +// and this file's diff for that card touched none of it. What changed is the +// step AFTER — the endpoints hand that context to +// `ISecurityService.resolvePermissionSetsForContext` instead of resolving the +// names themselves — so the double below is a stand-in for that contract method +// rather than for the evaluator, and every case still measures what it did: a +// position-bound set's capabilities reaching the wire. import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; @@ -85,22 +94,42 @@ function permissionSet(id: string, name: string, systemPermissions: string[]): R } /** - * A stand-in for plugin-security's `PermissionEvaluator` on its DB-backed - * branch: resolve the requested identifiers through the loader the endpoint - * supplies. plugin-hono-server must not depend on plugin-security (that - * package is OPTIONAL in the stacks these endpoints serve), so the double - * covers the one method both handlers call — and it is the DB branch that - * matters here, since the identifiers under test are exactly what the endpoint - * feeds it. + * [#7616] A stand-in for `ISecurityService.resolvePermissionSetsForContext` — + * the ONE resolution both handlers delegate to. plugin-hono-server must not + * depend on plugin-security (that package is OPTIONAL in the stacks these + * endpoints serve), so the double covers the one contract method both handlers + * call. + * + * It resolves the caller's own names — `positions ∪ permissions`, which is + * exactly what `resolveUserAuthzGrants` put on the context and therefore what + * these cases are about — out of the `sys_permission_set` rows the fixture + * seeded. No deployment baseline is added: these fixtures declare none, and the + * additive-baseline rule has its own file. */ -const evaluator = { - resolvePermissionSets: async ( - identifiers: string[], - _metadata: unknown, - _bootstrap: unknown[] | undefined, - dbLoader?: (names: string[]) => Promise, - ) => (dbLoader ? dbLoader(identifiers) : []), -}; +function makeSecurityService(rows: Row[]) { + const parse = (v: unknown, fallback: unknown) => + typeof v === 'string' ? JSON.parse(v || JSON.stringify(fallback)) : v ?? fallback; + return { + resolvePermissionSetsForContext: async (context: any) => { + const requested: string[] = [ + ...(Array.isArray(context?.positions) ? context.positions : []), + ...(Array.isArray(context?.permissions) ? context.permissions : []), + ]; + return requested.flatMap((name) => + rows + .filter((r) => r.name === name) + .map((r) => ({ + name: r.name, + label: r.label, + objects: parse(r.object_permissions, {}), + fields: parse(r.field_permissions, {}), + systemPermissions: parse(r.system_permissions, []), + tabPermissions: parse(r.tab_permissions, {}), + })), + ); + }, + }; +} /** Minimal `metadata` — present so the endpoint takes its full (non-degraded) branch. */ const metadata = { list: async () => [] as unknown[] }; @@ -123,7 +152,7 @@ function mount({ tables, activeOrg = ACTIVE_ORG }: MountOptions) { }, objectql: makeQl(tables), metadata, - 'security.permissions': evaluator, + security: makeSecurityService(tables.sys_permission_set ?? []), }; const app = new Hono(); registerCurrentUserEndpoints({ diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 606398f253..207d2a7ccf 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -44,7 +44,13 @@ import { type EnableLike, } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { IAuthService, IMetadataService, IObjectQLEngine, Logger } from '@objectstack/spec/contracts'; +import type { + IAuthService, + IMetadataService, + IObjectQLEngine, + ISecurityService, + Logger, +} from '@objectstack/spec/contracts'; import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; /** API prefix these endpoints mount under unless the host overrides it. */ @@ -115,9 +121,11 @@ const ENVIRONMENT_UNAVAILABLE = { function contextForKernel(kernel: any, from: CurrentUserEndpointsContext): CurrentUserEndpointsContext { return { // Sync `getService`, like every other read on this surface. Per-environment - // kernels register `auth` / `objectql` / `metadata` / - // `security.permissions` as INSTANCES (their plugins register them in - // `init()`), so they are in the service map by the time a request arrives. + // kernels register `auth` / `objectql` / `metadata` / `security` as + // INSTANCES rather than async providers, so they are in the service map + // by the time a request arrives. (`security` is registered in the + // plugin's `start()` rather than its `init()` — later than the others, + // still long before any request reaches this file.) getService: (name: string): T | undefined => kernel?.getService?.(name) as T | undefined, logger: from.logger, getKernel: () => kernel, @@ -263,30 +271,13 @@ export function foldWildcardSuperUser(objects: Record): void { /** - * The `security.permissions` slot, as these two handlers use it. + * The permission-set fields the two handlers merge out of a resolution. * - * [#4251] plugin-security registers its `PermissionEvaluator` under this name; - * the slot has no `packages/spec` contract, so this declares the ONE method both - * handlers call rather than erasing the lookup. Structural on purpose — - * plugin-hono-server must not take a runtime dependency on plugin-security, - * which is OPTIONAL in the stacks these endpoints serve (the `!evaluator` - * branches below are exactly its absence). - * - * The parameter types are the loose shapes this caller passes, not the - * evaluator's own: it takes `metadataService: any` and resolves to parsed - * `PermissionSet`s, while the DB loader here yields the projected subset the - * merges below read. Declaring what is passed and read keeps the claim honest. + * Deliberately looser than the contract's `PermissionSet`: it declares what + * these two handlers READ, so the merges below stay honest about the columns + * they depend on while the resolution itself is the plugin's (see + * {@link permissionSetResolver}). */ -interface PermissionEvaluatorSurface { - resolvePermissionSets( - identifiers: string[], - metadataService: unknown, - bootstrapPermissionSets?: unknown[], - dbLoader?: (unresolved: string[]) => Promise, - ): Promise; -} - -/** The permission-set fields the two handlers merge out of a resolution. */ interface ResolvedPermissionSetLike { name?: string; objects?: Record; @@ -315,86 +306,72 @@ function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): } /** - * [#7555, ADR-0090 D5] The baseline permission-set NAMES this deployment - * applies to a human principal — read from SecurityPlugin, never re-derived. - * - * The plugin registers `security.baselinePermissionSets` (the app-declared - * baseline COMPOSED with the platform `member_default`); this file's two - * resolutions must ask for that list rather than the single - * `security.fallbackPermissionSet` name, or an app that declares an `isDefault` - * set gets the pre-#7555 DISPLACEMENT here — its members' capability and tab - * surface computed from the app set alone, disagreeing with the data plane one - * function call away. - * - * The `security.fallbackPermissionSet` read is kept as the fallback for a - * SecurityPlugin too old to register the list, and the bare `member_default` - * default for a stack with no SecurityPlugin at all — both pre-existing - * behaviours, unchanged. + * [#7616] The ONE permission-set resolution both handlers below use — + * `ISecurityService.resolvePermissionSetsForContext`, reached through the + * `security` service. Returns `null` when no such resolver is registered. + * + * ## Why a delegation, and not the local resolution this replaces + * + * This file used to resolve the caller's sets itself, twice: composing the + * requested names (positions ∪ explicit sets ∪ the deployment baseline), + * building its own `sys_permission_set` DB loader, and calling the evaluator + * directly. That made one rule THREE copies — the enforcement path's, and one + * per endpoint — and it drifted three times, each divergence found only after + * it reached a user: + * + * - **#7608** — both endpoints kept the fallback CLIFF the additive ADR-0090 D5 + * baseline abolishes, so a member who received their FIRST grant went from + * 2 apps to 1 on `/me/apps` while the data plane kept their baseline; + * - **#7555** — an app-declared `isDefault` set DISPLACED `member_default` + * here rather than composing with it; + * - **#6334** — the grant aggregation missed `sys_user_position` / + * `sys_position_permission_set` entirely (closed by delegating to + * `resolveUserAuthzGrants`; this is that precedent extended one step). + * + * The contract's own header states the rule this restores: a consumer that + * re-derives these answers locally "will drift the moment the enforcement path + * changes. Ask this service instead." + * + * ## Two absences, and only ONE of them was dropped + * + * - **A SecurityPlugin too old to carry the method** — GONE. The method ships + * in `@objectstack/spec@17.0.0`, so a floor carrying it can be assumed and + * the local copy that stood in for it is deleted outright rather than kept + * as a third path. + * - **No SecurityPlugin at all** — KEPT, and it is why this returns `null` + * rather than throwing. The plugin is OPTIONAL in the stacks these endpoints + * serve and each handler has a defined degraded answer for its absence, so + * the resolution is reached through the SERVICE LOCATOR and never through a + * package import. Delegating must not turn an optional dependency into a + * runtime one. + * + * The contract declares the method OPTIONAL, so the narrowing below is what the + * TYPE demands too — the unguarded call does not compile. + * + * ## What "absent" now means, precisely + * + * The degraded branches used to key on `security.permissions`, which the + * contract names an implementation internal ("deliberately NOT part of this + * contract"). They key on the published service instead, and that is a real + * difference in ONE state: SecurityPlugin registers `security.permissions` in + * `init()`, but the `security` service only in `start()` — which RETURNS EARLY + * when the engine cannot take middleware. A stack in that state has no security + * middleware registered at all, so the data plane enforces NOTHING; the + * degraded branch's own premise ("matches server behaviour when SecurityPlugin + * isn't registered") is satisfied there, while the previous answer — a + * restrictive map computed against enforcement that does not exist — was the + * console-hides-what-the-API-allows false NEGATIVE #7608 is about. */ -function baselinePermissionSetNames(ctx: { getService: (name: string) => T | undefined }): string[] { - const composed = (() => { - try { return ctx.getService('security.baselinePermissionSets'); } - catch { return undefined; } - })(); - if (Array.isArray(composed)) return composed; - const declared: string | null = (() => { - try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } - catch { return 'member_default'; } +function permissionSetResolver( + ctx: { getService: (name: string) => T | undefined }, +): ((context: ExecutionContext) => Promise) | null { + const security = (() => { + try { return ctx.getService>('security') ?? null; } + catch { return null; } })(); - return declared ? [declared] : []; -} - -/** - * [#7608, ADR-0090 D5] The permission-set names to resolve for an - * AUTHENTICATED caller: their own grants ∪ the deployment baseline, ADDITIVE - * and unconditional. - * - * This mirrors `SecurityPlugin.resolvePermissionSetsForContext` — the data - * plane's resolution — deliberately and by name, because the two used to - * disagree. Both handlers below resolved the caller's own names first and - * applied the baseline only in a SECOND call gated on - * `resolved.length === 0`: the fallback CLIFF D5 abolishes, verbatim — - * - * > The fallback cliff is abolished. Today's semantics ("fallback applies - * > only while the user has *zero* explicit grants") mean the first real - * > grant silently removes the user's baseline. `everyone` is additive like - * > any other position: baseline ∪ explicit, always. - * - * The plane it left disagreeing with is one function call away. The engine - * middleware resolves additively, so a member who received their FIRST - * position or permission-set grant kept the baseline on the data plane and - * lost it here: `/auth/me/permissions` reported object/field access narrower - * than a read actually returns, and `/me/apps` dropped every app whose - * `requiredPermissions` or tab visibility came from the baseline. The - * fail-direction is CLOSED (the console hides what the API allows), which is - * why it read as cosmetic for as long as it did. - * - * Pushing the baseline into `requested` also retires the second - * `resolvePermissionSets` call outright rather than merely widening its guard: - * once the baseline is in the FIRST call's input, a second call over a SUBSET - * of those same names can add nothing. - * - * No `principalKind === 'agent'` branch, unlike the plugin's copy — and that is - * a property of this surface, not an omission. D10 withholds the human baseline - * from an agent principal because its ceiling must stay exactly its - * scope-derived set; these two endpoints are reached only through - * {@link makeExecutionContextResolver}, which resolves a better-auth SESSION - * and never marks a principal kind. An agent has no session to present here, so - * the branch would be unreachable code asserting a case this transport cannot - * produce. - */ -function effectivePermissionSetNames( - execCtx: { positions?: unknown; permissions?: unknown }, - baselineNames: string[], -): string[] { - const requested: string[] = [ - ...(Array.isArray(execCtx.positions) ? execCtx.positions as string[] : []), - ...(Array.isArray(execCtx.permissions) ? execCtx.permissions as string[] : []), - ]; - for (const name of baselineNames) { - if (!requested.includes(name)) requested.push(name); - } - return requested; + const resolve = security?.resolvePermissionSetsForContext; + if (typeof resolve !== 'function') return null; + return (context) => resolve.call(security, context); } /** @@ -733,69 +710,33 @@ export function registerCurrentUserEndpoints( return c.json({ authenticated: false }); } try { - // [#4093] Guarded like the three lookups below, not bare: - // `getService` THROWS on an unregistered slot, and since - // plugin-dev stopped stubbing `security.permissions` (a fake - // that answered "allowed" for everything) an unclaimed slot is - // the ordinary state of a stack without SecurityPlugin. Bare, - // it landed in the outer catch — same fail-open body, but - // logged as "/auth/me/permissions failed", which reads as a - // fault on every console navigation instead of the deliberate - // `!evaluator` branch right below. + // [#4093] Guarded, not bare: `getService` THROWS on an + // unregistered slot, and an unclaimed one is the ordinary state of + // a stack without SecurityPlugin. Bare, it landed in the outer + // catch — same fail-open body, but logged as + // "/auth/me/permissions failed", which reads as a fault on every + // console navigation instead of the deliberate degraded branch + // right below. const metadata = (() => { try { return ctx.getService('metadata') ?? null; } catch { return null; } })(); - const evaluator = (() => { - try { return ctx.getService('security.permissions') ?? null; } - catch { return null; } - })(); - const bootstrap: any[] = (() => { - try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } - catch { return []; } - })(); - const baselineNames: string[] = baselinePermissionSetNames(ctx); - // DB loader: surfaces user-defined permission sets - // (created via the admin UI as `sys_permission_set` - // rows) that aren't in metadata or bootstrap. + // [#7616] The resolution is the plugin's, reached through the + // published contract; `null` is SecurityPlugin's absence. See + // {@link permissionSetResolver} for which absence this drops and + // which it keeps. + const resolvePermissionSets = permissionSetResolver(ctx); + // Read for the registry/schema lookups the merge annotations below + // need — no longer for a permission-set DB loader of our own. const ql = (() => { try { return ctx.getService('objectql') ?? null; } catch { return null; } })(); - const dbLoader = ql - ? async (names: string[]) => { - let rows: any; - try { - rows = await ql.find( - 'sys_permission_set', - { where: { name: { $in: names } }, limit: names.length }, - { context: { isSystem: true } }, - ); - } catch { - rows = []; - } - const list = Array.isArray(rows) ? rows : rows?.records ?? []; - return list.map((r: any) => ({ - name: r.name, - label: r.label, - objects: typeof r.object_permissions === 'string' - ? JSON.parse(r.object_permissions || '{}') - : r.object_permissions ?? {}, - fields: typeof r.field_permissions === 'string' - ? JSON.parse(r.field_permissions || '{}') - : r.field_permissions ?? {}, - // #2752 follow-through: DB-loaded sets used to drop - // their capability + tab columns, so a direct grant - // of e.g. `setup.access` never surfaced here. - systemPermissions: typeof r.system_permissions === 'string' - ? JSON.parse(r.system_permissions || '[]') - : r.system_permissions ?? [], - tabPermissions: typeof r.tab_permissions === 'string' - ? JSON.parse(r.tab_permissions || '{}') - : r.tab_permissions ?? {}, - })); - } - : undefined; - if (!evaluator || !metadata) { + // The `!metadata` half of this guard is UNCHANGED and deliberate: + // it is not the resolver's input any more (the plugin supplies its + // own), but a stack with no metadata service degraded here before + // and must keep degrading here, or this becomes a second behaviour + // change riding on the delegation. + if (!resolvePermissionSets || !metadata) { // Auth resolved but security plugin isn't wired — emit // an empty-but-authenticated body so the frontend can // fail-open with full access (matches server behaviour @@ -810,14 +751,16 @@ export function registerCurrentUserEndpoints( fields: {}, }); } - // [#7608] Resolve the same way SecurityPlugin middleware does: - // position names + explicit permission-set names + the deployment - // baseline, all in ONE call — see effectivePermissionSetNames for - // why the baseline is additive rather than a second, cliff-gated - // resolution. - const requested = effectivePermissionSetNames(execCtx, baselineNames); - const resolved: ResolvedPermissionSetLike[] = await evaluator - .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) + // [#7616] The caller's sets, resolved ONCE by the owner of the + // rule: positions expanded, the ADR-0090 D5 baseline applied + // ADDITIVELY (never as a `resolved.length === 0` cliff), the D10 + // agent-principal rule honoured, deactivated `sys_permission_set` + // rows dropped — all of it the enforcement path's own answer rather + // than this file's re-derivation of it. + // + // Fails CLOSED on a throw, as the contract requires: no sets merge + // to no access, which is what the body below then reports. + const resolved: ResolvedPermissionSetLike[] = await resolvePermissionSets(execCtx) .catch(() => []); // Most-permissive merge of `objects` and `fields` across // all resolved permission sets — same semantics as @@ -985,49 +928,22 @@ export function registerCurrentUserEndpoints( const tabs: Record = { ...((execCtx as any).tabPermissions ?? {}) }; let failOpen = true; try { - const evaluator = ctx.getService('security.permissions'); - failOpen = !evaluator; - if (evaluator) { - const metadata = ctx.getService('metadata'); - const bootstrap: any[] = (() => { - try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } - catch { return []; } - })(); - // [#7608] Baseline ∪ explicit, in ONE resolution — the - // same additive rule /auth/me/permissions applies above - // and the engine middleware applies on the data plane. - const requested = effectivePermissionSetNames( - execCtx as { positions?: unknown; permissions?: unknown }, - baselinePermissionSetNames(ctx), - ); - const qlSvc = (() => { - try { return ctx.getService('objectql') ?? null; } catch { return null; } - })(); - const dbLoader = qlSvc - ? async (names: string[]) => { - let rows: any; - try { - rows = await qlSvc.find( - 'sys_permission_set', - { where: { name: { $in: names } }, limit: names.length }, - { context: { isSystem: true } }, - ); - } catch { rows = []; } - const list = Array.isArray(rows) ? rows : rows?.records ?? []; - return list.map((r: any) => ({ - name: r.name, - systemPermissions: typeof r.system_permissions === 'string' - ? JSON.parse(r.system_permissions || '[]') - : r.system_permissions ?? [], - tabPermissions: typeof r.tab_permissions === 'string' - ? JSON.parse(r.tab_permissions || '{}') - : r.tab_permissions ?? {}, - })); - } - : undefined; - const resolved: ResolvedPermissionSetLike[] = await evaluator - .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) - .catch(() => []); + // [#7616] The SAME resolution `/auth/me/permissions` uses, from + // the same owner. What stays local is only the PROJECTION — + // this surface reads capabilities and tabs off the resolved + // sets, that one merges objects and fields — which is exactly + // the split the contract keeps ("the merge semantics stay with + // the CALLER, deliberately: two consumers legitimately project + // different subsets of the same sets"). The narrower COLUMN + // set this handler's own DB loader used to fetch was never the + // projection: the columns it dropped are read by nobody here, + // so loading the sets whole changes what is in memory and + // nothing that reaches the wire. + const resolvePermissionSets = permissionSetResolver(ctx); + failOpen = !resolvePermissionSets; + if (resolvePermissionSets) { + const resolved: ResolvedPermissionSetLike[] = + await resolvePermissionSets(execCtx).catch(() => []); const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; for (const ps of resolved) { for (const sp of (Array.isArray(ps?.systemPermissions) ? ps.systemPermissions : [])) {