From f24a1407f00c5d7363a23f915f63002e0e818f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 08:44:58 +0000 Subject: [PATCH] fix(hono-server): apply the baseline additively on the UI plane (#7608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both permission resolutions in `current-user-endpoints.ts` applied the deployment baseline only in a SECOND `resolvePermissionSets` call gated on `resolved.length === 0` — the ADR-0090 D5 fallback CLIFF, verbatim: "the first real grant silently removes the user's baseline". `SecurityPlugin.resolvePermissionSetsForContext` — the DATA plane, one function call away — has pushed the baseline into `requested` and resolved once for as long as D5 has existed. These two endpoints had not, so the planes disagreed the moment a member held any explicit grant. A member who received their FIRST position or permission-set grant kept the baseline on the data plane and lost it on the UI plane: `/auth/me/permissions` reported object/field access narrower than a read returns, and `/me/apps` dropped every app whose `requiredPermissions` or tab visibility came from the baseline. Fail-direction CLOSED — the console hid what the API allowed. Measured on the shipped fixture: receiving the first grant took a member from 2 apps to 1; it now takes them from 2 to 3, and restores one readable object, one readable field and two capabilities on `/auth/me/permissions`. The baseline names go into `requested` before the SINGLE resolution, via one shared `effectivePermissionSetNames` helper both handlers call. That retires the second call outright rather than widening its guard: once the baseline is an input to the first call, a second call over a subset of those names can add nothing. Unchanged: a zero-grant member still gets the baseline; a deployment declaring an empty baseline still resolves the caller's own names alone; `/me/apps` still filters an app whose `requiredPermissions` nobody holds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sYyt2vyi3HL63BfF6tDq9 --- .changeset/baseline-additive-on-ui-plane.md | 43 +++ ...t-user-endpoints-additive-baseline.test.ts | 359 ++++++++++++++++++ .../src/current-user-endpoints.ts | 96 +++-- 3 files changed, 471 insertions(+), 27 deletions(-) create mode 100644 .changeset/baseline-additive-on-ui-plane.md create mode 100644 packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts diff --git a/.changeset/baseline-additive-on-ui-plane.md b/.changeset/baseline-additive-on-ui-plane.md new file mode 100644 index 0000000000..9d8d8b8166 --- /dev/null +++ b/.changeset/baseline-additive-on-ui-plane.md @@ -0,0 +1,43 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +fix(hono-server): apply the baseline ADDITIVELY on `/auth/me/permissions` and `/me/apps` — the ADR-0090 D5 fallback cliff, one plane over (#7608) + +Both permission resolutions in `current-user-endpoints.ts` applied the deployment +baseline permission set(s) only in a **second** `resolvePermissionSets` call gated on +`resolved.length === 0`. That is 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. + +`SecurityPlugin.resolvePermissionSetsForContext` — the **data** plane, one function call +away — has pushed the baseline into `requested` and resolved once for as long as D5 has +existed. These two endpoints had not, so the two planes disagreed the moment a member +held any explicit grant at all. + +**What a user saw.** A member who received their **first** position or permission-set +grant kept the baseline on the data plane and lost it on the **UI** plane. +`/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. Measured on the fixture that ships with this change — a member of +one org, a baseline granting two capabilities, one explicit grant adding a third — +receiving that grant took the member from **2 apps to 1**. It now takes them from 2 to +**3**: the two baseline apps are retained instead of traded away, and the same member +regains one readable object, one readable field and two capabilities on +`/auth/me/permissions`. The fail-direction was **closed** (the console hid what the API +allowed), which is why it read as cosmetic for as long as it did. + +The baseline names are now pushed into `requested` before the **single** resolution, in +one shared `effectivePermissionSetNames` helper both handlers call — which retires the +second `resolvePermissionSets` call outright rather than merely widening its guard: once +the baseline is an input to the first call, a second call over a subset of those same +names can add nothing. Unchanged: a member with zero grants still gets the baseline, a +deployment declaring an empty baseline still resolves the caller's own names alone, and +`/me/apps` still filters an app whose `requiredPermissions` nobody holds. + +No `principalKind === 'agent'` branch, unlike the plugin's copy, and that is a property +of this surface rather than an omission — these endpoints are reached only through the +better-auth **session** resolver, which never marks a principal kind, so an agent has no +session to present here. 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 new file mode 100644 index 0000000000..f1f21b3a2e --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7608 — the deployment baseline is ADDITIVE on the UI plane (ADR-0090 D5). +// +// Both resolutions in `current-user-endpoints.ts` used to apply the baseline +// permission set(s) only in a SECOND `resolvePermissionSets` call gated on +// `resolved.length === 0` — the fallback CLIFF D5 abolishes: "the first real +// grant silently removes the user's baseline". `SecurityPlugin.resolve- +// PermissionSetsForContext` (the DATA plane, one call away) has pushed the +// baseline into `requested` and resolved once for as long as D5 has existed, so +// the two planes disagreed the moment a member held any explicit grant at all. +// +// ## What these cases measure +// +// The accept bar for this fix is the USER-VISIBLE effect, not the guard. Every +// case here therefore drives the real HTTP surface and counts what a member +// SEES — apps in `/me/apps`, capabilities and object/field access in +// `/auth/me/permissions` — for two members who differ in exactly one thing: +// whether they hold one explicit permission-set grant. +// +// zero-grant member → baseline applies (this was already true; pinned so a +// regression cannot "fix" the cliff by deleting the +// baseline outright, which would pass every case below +// that only asserts the one-grant member gained things) +// one-grant member → baseline ∪ explicit (this is the fix) +// +// The pre-fix numbers, measured on the parent commit with these same fixtures: +// the one-grant member saw 1 app (`exports`), 1 capability, and no baseline +// object/field access — while the zero-grant member saw 2 apps (`home`, +// `reports`). Receiving the grant COST that member two apps. The delta these +// 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 +// +// `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. + +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'; + +/** The deployment baseline set — reachable ONLY via `security.baselinePermissionSets`. */ +const BASELINE = 'member_default'; +/** The one explicit grant that used to cost the member the baseline. */ +const EXPLICIT = 'showcase_ops'; + +type Row = Record; + +function matches(row: Row, where: Row | undefined): boolean { + return Object.entries(where ?? {}).every(([key, cond]) => { + 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); + }); +} + +function makeQl(tables: Record) { + return { + 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: () => tables.__apps ?? [], getAllObjects: () => [] }, + getSchema: () => undefined, + }; +} + +/** A `sys_permission_set` row, JSON columns stored as text like the real table. */ +function permissionSet( + id: string, + name: string, + grant: { + systemPermissions?: string[]; + tabPermissions?: Record; + objects?: Record; + fields?: Record; + }, +): Row { + return { + id, + name, + object_permissions: JSON.stringify(grant.objects ?? {}), + field_permissions: JSON.stringify(grant.fields ?? {}), + system_permissions: JSON.stringify(grant.systemPermissions ?? []), + tab_permissions: JSON.stringify(grant.tabPermissions ?? {}), + }; +} + +/** + * 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. + */ +function makeEvaluator() { + const calls: string[][] = []; + return { + calls, + resolvePermissionSets: async ( + identifiers: string[], + _metadata: unknown, + _bootstrap: unknown[] | undefined, + dbLoader?: (names: string[]) => Promise, + ) => { + calls.push([...identifiers]); + return dbLoader ? dbLoader(identifiers) : []; + }, + }; +} + +const metadata = { list: async () => [] as unknown[] }; + +/** + * The fixture: one member of one org, four apps, and a baseline set bound to no + * 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' }: { + explicitGrant: boolean; + /** The registered `security.baselinePermissionSets`; `null` = slot unclaimed. */ + baseline?: string[] | null; + /** Which set the explicit grant binds — `ps_baseline` for the overlap case. */ + grantedSetId?: 'ps_ops' | 'ps_baseline'; +}) { + 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: explicitGrant + ? [{ id: 'ups1', user_id: USER, permission_set_id: grantedSetId, organization_id: ACTIVE_ORG }] + : [], + // Deliberately EMPTY: nothing binds the baseline to `everyone`, so the + // service channel is the only way it can reach the response. + sys_position: [], + sys_position_permission_set: [], + sys_permission_set: [ + permissionSet('ps_baseline', BASELINE, { + systemPermissions: ['home.access', 'reports.view'], + tabPermissions: { home: 'visible', reports: 'visible' }, + objects: { sys_dashboard: { allowRead: true } }, + fields: { 'sys_dashboard.title': { readable: true, editable: false } }, + }), + permissionSet('ps_ops', EXPLICIT, { + systemPermissions: ['showcase.export_data'], + tabPermissions: { exports: 'visible' }, + objects: { showcase_order: { allowRead: true } }, + fields: { 'showcase_order.total': { readable: true, editable: true } }, + }), + ], + __apps: [ + { name: 'home', requiredPermissions: ['home.access'] }, + { name: 'reports', requiredPermissions: ['reports.view'] }, + { name: 'exports', requiredPermissions: ['showcase.export_data'] }, + // The control: no grant anywhere reaches it, so it must stay out + // of every answer — a baseline applied too WIDELY fails here. + { name: 'billing', requiredPermissions: ['billing.manage'] }, + ], + }; + const evaluator = makeEvaluator(); + const services: Record = { + auth: { + api: { + getSession: async () => ({ + user: { id: USER, email: 'member@example.com' }, + session: { activeOrganizationId: ACTIVE_ORG }, + }), + }, + }, + objectql: makeQl(tables), + metadata, + 'security.permissions': evaluator, + }; + // [#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; + 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, evaluator }; +} + +const permissionsOf = async (app: any) => + (await app.request(`http://localhost${ME_PERMISSIONS}`)).json() as Promise; + +const appNamesOf = async (app: any) => { + const body = await (await app.request(`http://localhost${ME_APPS}`)).json() as any; + return (body.apps as any[]).map((a) => a.name).sort(); +}; + +describe('/me/apps — the baseline survives the first explicit grant (#7608)', () => { + it('a member with ZERO grants gets the baseline (unchanged by this fix)', async () => { + // The half that always worked, because the cliff's own guard let it + // through. Pinned so the fix cannot be "achieved" by dropping the + // baseline everywhere — that would leave this member with 0 apps. + const { app } = mount({ explicitGrant: false }); + + expect(await appNamesOf(app)).toEqual(['home', 'reports']); + }); + + it('a member with ONE explicit grant KEEPS the baseline and gains their own', async () => { + // THE regression. Pre-fix this answered `['exports']`: `resolved` was + // non-empty, so the cliff withheld the baseline entirely and the member + // LOST `home` and `reports` by being granted something. + const { app } = mount({ explicitGrant: true }); + + expect(await appNamesOf(app)).toEqual(['exports', 'home', 'reports']); + }); + + it('the measurement: the grant ADDS an app instead of costing two', async () => { + // The number the card asks for, computed rather than asserted by hand — + // stated as a delta between the two members so the direction is on the + // record. Pre-fix this delta was -1 (2 apps → 1); it is now +1 (2 → 3), + // with the two baseline apps retained rather than traded away. + const zero = await appNamesOf(mount({ explicitGrant: false }).app); + const one = await appNamesOf(mount({ explicitGrant: true }).app); + + const recovered = zero.filter((name) => one.includes(name)); + expect(recovered).toEqual(['home', 'reports']); + expect(one.length - zero.length).toBe(1); + // The control never appears for either member. + 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 }); + await app.request(`http://localhost${ME_APPS}`); + + expect(evaluator.calls).toHaveLength(1); + expect(evaluator.calls[0]).toContain(BASELINE); + expect(evaluator.calls[0]).toContain(EXPLICIT); + }); + + 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}`); + + const requested = evaluator.calls[0]; + expect(requested).toContain(BASELINE); + expect(requested.filter((n) => n === BASELINE)).toHaveLength(1); + expect(await appNamesOf(app)).toEqual(['home', 'reports']); + }); +}); + +describe('/auth/me/permissions — the same rule on the object/field surface (#7608)', () => { + it('a member with ZERO grants reports the baseline (unchanged by this fix)', async () => { + const body = await permissionsOf(mount({ explicitGrant: false }).app); + + expect(body.authenticated).toBe(true); + expect(body.permissionSets).toEqual([BASELINE]); + expect(body.objects.sys_dashboard?.allowRead).toBe(true); + expect(body.systemPermissions.sort()).toEqual(['home.access', 'reports.view']); + }); + + it('a member with ONE explicit grant reports baseline ∪ explicit', async () => { + // Pre-fix: `permissionSets: ['showcase_ops']`, no `sys_dashboard`, no + // `home.access` — the endpoint reporting object access NARROWER than + // the read it describes, since the data plane kept the baseline. + const body = await permissionsOf(mount({ explicitGrant: true }).app); + + expect(body.permissionSets.sort()).toEqual([BASELINE, EXPLICIT].sort()); + // Baseline access, retained through the grant. + expect(body.objects.sys_dashboard?.allowRead).toBe(true); + expect(body.fields['sys_dashboard.title']?.readable).toBe(true); + expect(body.systemPermissions).toContain('home.access'); + expect(body.systemPermissions).toContain('reports.view'); + expect(body.tabPermissions.home).toBe('visible'); + // …alongside the explicit grant, which must not be displaced either. + expect(body.objects.showcase_order?.allowRead).toBe(true); + expect(body.fields['showcase_order.total']?.editable).toBe(true); + expect(body.systemPermissions).toContain('showcase.export_data'); + }); + + it('the measurement: the grant costs the member NO object, field or capability', async () => { + const zero = await permissionsOf(mount({ explicitGrant: false }).app); + const one = await permissionsOf(mount({ explicitGrant: true }).app); + + const lost = (a: string[], b: string[]) => a.filter((k) => !b.includes(k)); + expect(lost(Object.keys(zero.objects), Object.keys(one.objects))).toEqual([]); + expect(lost(Object.keys(zero.fields), Object.keys(one.fields))).toEqual([]); + expect(lost(zero.systemPermissions, one.systemPermissions)).toEqual([]); + // …and it strictly adds: +1 object, +1 field, +1 capability. + expect(Object.keys(one.objects).length - Object.keys(zero.objects).length).toBe(1); + expect(Object.keys(one.fields).length - Object.keys(zero.fields).length).toBe(1); + 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 }); + await app.request(`http://localhost${ME_PERMISSIONS}`); + + expect(evaluator.calls).toHaveLength(1); + expect(evaluator.calls[0]).toContain(BASELINE); + expect(evaluator.calls[0]).toContain(EXPLICIT); + }); +}); + +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: [] }); + + 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('an app whose requiredPermissions nobody holds stays filtered for both members', async () => { + // The fail-direction guard: widening the baseline must not turn + // `/me/apps` into a fail-open list. + expect(await appNamesOf(mount({ explicitGrant: false }).app)).not.toContain('billing'); + expect(await appNamesOf(mount({ explicitGrant: true }).app)).not.toContain('billing'); + }); +}); 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 0d6fff4c9f..ead5fba89d 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -342,6 +342,59 @@ function baselinePermissionSetNames(ctx: { getService: (name: string) => T | 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; +} + /** * Buckets whose user-context generic writes are guarded fail-closed at the * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), @@ -695,7 +748,7 @@ export function registerCurrentUserEndpoints( try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } catch { return []; } })(); - const fallbackNames: string[] = baselinePermissionSetNames(ctx); + 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. @@ -752,23 +805,15 @@ export function registerCurrentUserEndpoints( fields: {}, }); } - // Resolve the same way SecurityPlugin middleware does: - // role names + explicit permission-set names, with a - // fallback to `member_default` when authenticated users - // resolve to zero permission sets (matches the - // post-resolution fallback in security-plugin.ts). - const requested = [ - ...(execCtx.positions ?? []), - ...(execCtx.permissions ?? []), - ]; - let resolved: ResolvedPermissionSetLike[] = await evaluator + // [#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) .catch(() => []); - if (resolved.length === 0 && fallbackNames.length > 0) { - resolved = await evaluator - .resolvePermissionSets(fallbackNames, metadata, bootstrap, dbLoader) - .catch(() => []); - } // Most-permissive merge of `objects` and `fields` across // all resolved permission sets — same semantics as // PermissionEvaluator.getFieldPermissions but for ALL @@ -942,11 +987,13 @@ export function registerCurrentUserEndpoints( try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } catch { return []; } })(); - const fallbackNames: string[] = baselinePermissionSetNames(ctx); - const requested = [ - ...((execCtx as any).positions ?? []), - ...((execCtx as any).permissions ?? []), - ]; + // [#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; } })(); @@ -972,14 +1019,9 @@ export function registerCurrentUserEndpoints( })); } : undefined; - let resolved: ResolvedPermissionSetLike[] = await evaluator + const resolved: ResolvedPermissionSetLike[] = await evaluator .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) .catch(() => []); - if (resolved.length === 0 && fallbackNames.length > 0) { - resolved = await evaluator - .resolvePermissionSets(fallbackNames, metadata, bootstrap, dbLoader) - .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 : [])) {