From 55d918832bd9827820b2d03c1d2f8926d3c4b79a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 00:55:18 +0000 Subject: [PATCH 1/2] fix(plugin-hono-server): current-user endpoints delegate grant resolution to resolveUserAuthzGrants (#6334) The standalone resolver behind /auth/me/permissions and /me/apps read sys_member + sys_user_permission_set and nothing else, so position-bound permission sets (sys_user_position -> sys_position_permission_set, the ADR-0090 D3 distribution mechanism) never reached the response. objectui's useCapabilityGate surfaces read this endpoint, so the button was hidden from users who genuinely held the capability while the data plane granted it. Session lookup stays local; all grant aggregation now delegates to the canonical resolver's userId-driven core. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .../hono-current-user-position-grants.md | 41 +++ ...ent-user-endpoints-position-grants.test.ts | 309 ++++++++++++++++++ .../src/current-user-endpoints.ts | 231 +++++-------- 3 files changed, 426 insertions(+), 155 deletions(-) create mode 100644 .changeset/hono-current-user-position-grants.md create mode 100644 packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts diff --git a/.changeset/hono-current-user-position-grants.md b/.changeset/hono-current-user-position-grants.md new file mode 100644 index 0000000000..35f3d1dc70 --- /dev/null +++ b/.changeset/hono-current-user-position-grants.md @@ -0,0 +1,41 @@ +--- +"@objectstack/plugin-hono-server": patch +--- + +fix(plugin-hono-server): `/auth/me/permissions` resolves position-bound grants through the canonical resolver (#6334) + +On a hono host, `/api/v1/auth/me/permissions` and `/me/apps` resolved the caller +through a standalone resolver in `current-user-endpoints.ts` that read +`sys_member` + `sys_user_permission_set` — and **nothing else**. It never read +`sys_user_position` / `sys_position_permission_set`, so a permission set bound to +a **position** — the ADR-0090 D3 distribution mechanism, and how the showcase app +grants every persona — was invisible to these endpoints: the response carried +`positions: []`, omitted the set from `permissionSets`, and withheld its +`systemPermissions`. + +That is the surface objectui's four `useCapabilityGate` gates read (toolbar, row +kebab, record header, bulk bar — ADR-0066 D4), while the data plane resolves +through SecurityPlugin's middleware on the canonical chain. So the server +**granted** the action and the UI **hid the button** from a user who genuinely +held the capability — the failure direction the fail-open design names as the +worse one. + +A second, quieter half of the same divergence: the hand-rolled envelope published +membership roles under `roles`, while `ExecutionContext` — and every reader in +that file — calls the field `positions` (ADR-0090 D3, "formerly `roles`"). The +endpoint's `positions` was therefore always `[]` and those names never reached +`resolvePermissionSets` either, independently of the position tables. + +The session lookup (the genuinely transport-specific part) stays where it is; all +grant aggregation now delegates to `resolveUserAuthzGrants`, the canonical +resolver's userId-driven core, which `@objectstack/core` exports for exactly this +caller shape — a surface that already knows who the principal is and needs the +same envelope with no HTTP request to resolve it from. Arriving with it, none of +it re-implemented: `sys_user_position` (null org = global, active-org match, +ADR-0091 validity windows), the implicit `everyone` audience anchor (ADR-0090 D5), +`sys_position_permission_set`, `mapMembershipRole` normalization, the +platform-admin derivation and posture rung, and the `ai_seat` synthesis. + +No response-envelope change: `positions` / `permissionSets` / `systemPermissions` +/ `tabPermissions` keep their names and shapes, and now carry the grants the +server was already enforcing. 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 new file mode 100644 index 0000000000..07fb75a987 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #6334 — position-bound capabilities must reach `/auth/me/permissions`. +// +// The standalone resolver behind these endpoints used to read `sys_member` + +// `sys_user_permission_set` and nothing else. `sys_user_position` / +// `sys_position_permission_set` — the ADR-0090 D3 DISTRIBUTION mechanism, which +// is how showcase grants every persona — were invisible to it, so a permission +// set bound to a position never reached the response: `positions: []`, +// `permissionSets` without the set, `systemPermissions` without its +// capabilities. objectui's four `useCapabilityGate` surfaces (ADR-0066 D4) read +// this endpoint, so the button was hidden from a user who genuinely HELD the +// capability, while the data plane (SecurityPlugin middleware, canonical chain) +// granted the same action. Hiding from an entitled user is the failure +// direction the fail-open design names as the worse one. +// +// The fix delegates all grant aggregation to `resolveUserAuthzGrants` — the +// canonical resolver's userId-driven core. These cases therefore assert BOTH +// halves: the issue's own repro (a position-bound set surfaces), and the +// semantics that come free with the delegation and would have to be re-written +// by hand otherwise — the implicit `everyone` anchor (ADR-0090 D5), null-org = +// global, active-org matching, and the ADR-0091 validity window. +// +// Every negative case here carries a co-present VALID grant and asserts it +// 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. + +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_ops'; +const ACTIVE_ORG = 'org_active'; +const OTHER_ORG = 'org_other'; + +/** Far past / far future bounds for the ADR-0091 window cases (real clock). */ +const PAST = '2020-01-01T00:00:00.000Z'; +const FUTURE = '2999-01-01T00:00:00.000Z'; + +type Row = Record; + +/** `where` matcher: scalar equality plus the `$in` form both resolvers use. */ +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); + }); +} + +/** + * A seeded fake data engine — READ ONLY (`find`), which is every verb this + * surface uses. `where`/`limit` are honoured so a resolver that queries the + * wrong table or the wrong scope gets nothing, exactly as it would in the + * engine. + */ +function makeQl(tables: Record) { + return { + find: async (object: string, opts: any, _ctx?: 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 permission set as `sys_permission_set` stores it (JSON columns as text). */ +function permissionSet(id: string, name: string, systemPermissions: string[]): Row { + return { + id, + name, + object_permissions: '{}', + field_permissions: '{}', + system_permissions: JSON.stringify(systemPermissions), + tab_permissions: '{}', + }; +} + +/** + * 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. + */ +const evaluator = { + resolvePermissionSets: async ( + identifiers: string[], + _metadata: unknown, + _bootstrap: unknown[] | undefined, + dbLoader?: (names: string[]) => Promise, + ) => (dbLoader ? dbLoader(identifiers) : []), +}; + +/** Minimal `metadata` — present so the endpoint takes its full (non-degraded) branch. */ +const metadata = { list: async () => [] as unknown[] }; + +interface MountOptions { + tables: Record; + /** Active organization on the session (`session.activeOrganizationId`). */ + activeOrg?: string | null; +} + +function mount({ tables, activeOrg = ACTIVE_ORG }: MountOptions) { + const services: Record = { + auth: { + api: { + getSession: async () => ({ + user: { id: USER, email: 'ops@example.com' }, + session: activeOrg ? { activeOrganizationId: activeOrg } : {}, + }), + }, + }, + objectql: makeQl(tables), + metadata, + 'security.permissions': evaluator, + }; + const app = new Hono(); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { + logger: { debug() {}, warn() {} }, + // Throws for an unclaimed slot, like the real kernel locator. + 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; + +/** The showcase-shaped seed: `ops` position ↔ `showcase_ops` permission set. */ +function baseTables(overrides: Record = {}): Record { + return { + sys_user: [{ id: USER, email: 'ops@example.com' }], + sys_member: [{ user_id: USER, organization_id: ACTIVE_ORG, role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [], + sys_position: [ + { id: 'pos_ops', name: 'ops' }, + { id: 'pos_auditor', name: 'auditor' }, + { id: 'pos_everyone', name: 'everyone' }, + ], + sys_position_permission_set: [ + { position_id: 'pos_ops', permission_set_id: 'ps_ops' }, + { position_id: 'pos_auditor', permission_set_id: 'ps_auditor' }, + { position_id: 'pos_everyone', permission_set_id: 'ps_default' }, + ], + sys_permission_set: [ + permissionSet('ps_ops', 'showcase_ops', ['setup.access', 'showcase.export_data']), + permissionSet('ps_auditor', 'showcase_auditor', ['showcase.audit_read']), + permissionSet('ps_default', 'showcase_member_default', []), + permissionSet('ps_direct', 'showcase_direct', ['showcase.direct_only']), + ], + ...overrides, + }; +} + +describe('position-bound permission sets reach /auth/me/permissions (#6334)', () => { + it('surfaces a sys_user_position → sys_position_permission_set grant', async () => { + // The issue's repro, verbatim: one `sys_user_position` row for the + // active org, `valid_from`/`valid_until` both empty. + const app = mount({ + tables: baseTables({ + sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG }], + }), + }); + + const body = await permissionsOf(app); + + expect(body.authenticated).toBe(true); + expect(body.positions).toContain('ops'); + expect(body.permissionSets).toContain('showcase_ops'); + expect(body.systemPermissions).toContain('showcase.export_data'); + expect(body.systemPermissions).toContain('setup.access'); + }); + + it('still resolves a direct sys_user_permission_set binding (positive control)', async () => { + // The table the pre-fix resolver DID read — the issue's own control, + // where switching to a direct binding made the capability appear. It + // must keep working after the delegation. + const app = mount({ + tables: baseTables({ + sys_user_permission_set: [ + { id: 'ups1', user_id: USER, permission_set_id: 'ps_direct', organization_id: null }, + ], + }), + }); + + const body = await permissionsOf(app); + + expect(body.permissionSets).toContain('showcase_direct'); + expect(body.systemPermissions).toContain('showcase.direct_only'); + }); + + it('carries the implicit `everyone` position and its default set (ADR-0090 D5)', async () => { + // No position row at all: every AUTHENTICATED member implicitly holds + // `everyone`, so sets bound to it resolve like any other position-bound + // grant. The issue saw this missing too (`everyone → showcase_member_default`). + const app = mount({ tables: baseTables() }); + + const body = await permissionsOf(app); + + expect(body.positions).toContain('everyone'); + expect(body.permissionSets).toContain('showcase_member_default'); + }); + + it('projects the normalized org-membership position (sys_member.role)', async () => { + // `member` → `org_member` (mapMembershipRole). The pre-fix envelope put + // these under `roles` while every reader here — and ExecutionContext + // itself — calls the field `positions`, so they were dropped on the + // floor independently of the position tables. + const body = await permissionsOf(mount({ tables: baseTables() })); + + expect(body.positions).toContain('org_member'); + }); + + it('treats a null-org position row as global (resolves under any active org)', async () => { + const app = mount({ + tables: baseTables({ + sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: null }], + }), + }); + + const body = await permissionsOf(app); + + expect(body.positions).toContain('ops'); + expect(body.systemPermissions).toContain('showcase.export_data'); + }); + + it('drops a position row scoped to another organization, keeping the active-org one', async () => { + const app = mount({ + tables: baseTables({ + sys_user_position: [ + { id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG }, + { id: 'up2', user_id: USER, position: 'auditor', organization_id: OTHER_ORG }, + ], + }), + }); + + const body = await permissionsOf(app); + + // The co-present valid grant is asserted so the negative below cannot + // pass merely because nothing resolved. + expect(body.positions).toContain('ops'); + expect(body.positions).not.toContain('auditor'); + expect(body.permissionSets).not.toContain('showcase_auditor'); + expect(body.systemPermissions).not.toContain('showcase.audit_read'); + }); + + it('drops position grants outside their ADR-0091 validity window', async () => { + const app = mount({ + tables: baseTables({ + sys_user_position: [ + { id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG }, + // Expired, and not-yet-active — both spellings of "outside + // the half-open [from, until) window". + { + id: 'up2', user_id: USER, position: 'auditor', + organization_id: ACTIVE_ORG, valid_until: PAST, + }, + { + id: 'up3', user_id: USER, position: 'auditor', + organization_id: ACTIVE_ORG, valid_from: FUTURE, + }, + ], + }), + }); + + const body = await permissionsOf(app); + + expect(body.positions).toContain('ops'); + expect(body.positions).not.toContain('auditor'); + expect(body.systemPermissions).not.toContain('showcase.audit_read'); + }); +}); + +describe('/me/apps sees the same position-bound capabilities (#6334)', () => { + it('lists an app whose requiredPermissions come from a position-bound set', async () => { + const app = mount({ + tables: baseTables({ + sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: ACTIVE_ORG }], + __apps: [ + { name: 'exports', requiredPermissions: ['showcase.export_data'] }, + { name: 'billing', requiredPermissions: ['billing.manage'] }, + ], + }), + }); + + const body = await (await app.request(`http://localhost${ME_APPS}`)).json() as any; + + // `exports` is entered through a capability the user holds ONLY via the + // position chain; `billing` is the control that the filter still filters. + expect(body.apps.map((a: any) => a.name)).toEqual(['exports']); + }); +}); 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 e566fe111b..faa8555057 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -37,8 +37,7 @@ * round-trip to an auth service that does not implement these paths. */ -import { IDataEngine, derivePosture } from '@objectstack/core'; -import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; +import { IDataEngine, resolveUserAuthzGrants } from '@objectstack/core'; import { resolveEffectiveApiMethods, effectiveOperationsArray, @@ -455,16 +454,52 @@ export function annotateEffectiveApiOperations( * would agree on who the caller is. That surface has since been deleted and * these endpoints are the only remaining caller — kept as a named export * because the serverless host path (cloud#924) composes it directly. + * + * ## It resolves the SESSION only; every grant comes from the ONE resolver (#6334) + * + * This used to re-read the grant tables itself — `sys_member` + + * `sys_user_permission_set`, and nothing else. It never read `sys_user_position` + * / `sys_position_permission_set`, so a permission set bound to a POSITION (the + * ADR-0090 D3 distribution mechanism — showcase grants every persona that way) + * was invisible here: `/auth/me/permissions` answered `positions: []` and + * withheld that set's `systemPermissions`, while the data plane — which runs on + * SecurityPlugin's middleware over the canonical chain — granted the same + * action. objectui's four `useCapabilityGate` surfaces (ADR-0066 D4) read + * exactly this endpoint, so a user who genuinely HELD the capability had the + * button hidden: the failure direction the fail-open design names as the worse + * one. Same shape as the REST copy that once silently dropped `sys_user_role`, + * which is the drift `resolveAuthzContext` was extracted to end. + * + * A second, quieter half of the same divergence: the hand-rolled envelope + * published membership roles under `roles`, while `ExecutionContext` — and every + * reader in this file — calls that field `positions` (ADR-0090 D3, "formerly + * `roles`"). So the endpoint's `positions` was ALWAYS `[]` and the resolved role + * names never reached `resolvePermissionSets` either, independently of the + * position tables. + * + * So the session lookup — the genuinely transport-specific part — stays here and + * ALL grant aggregation delegates to `resolveUserAuthzGrants`, the canonical + * resolver's userId-driven core, exported for exactly this caller shape: a + * surface that already knows WHO the principal is and needs the SAME envelope + * with no HTTP request to resolve it from. `sys_user_position` (null org = + * global, active-org match, ADR-0091 validity windows), the implicit `everyone` + * position (ADR-0090 D5), `sys_position_permission_set`, `mapMembershipRole` + * normalization, the platform-admin derivation and the `ai_seat` synthesis + * arrive with it instead of being re-implemented — one copy fewer to drift. */ export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { - const getObjectQL = () => ctx.getService('objectql'); - // Helper: resolve ExecutionContext from request headers (cookie session - // or API key). Mirrors the runtime's resolveExecutionContext but - // self-contained to avoid a cross-package dep. We DO query the - // `sys_user_permission_set` link tables because hardcoding a single - // permission set name (e.g. `member_default`) would silently ignore - // any explicit admin / role assignment — including the platform-admin - // promotion seeded by `bootstrapPlatformAdmin`. + /** + * The data engine, or `undefined` when the slot is unclaimed. GUARDED: the + * locator's contract permits `getService` to THROW for an absent slot (the + * kernel's does), and an engine-less stack must still resolve the SESSION — + * a multi-tenant environment kernel carrying `auth` but no `objectql` + * answers `{authenticated:true}` with empty grants, never a fake anonymous + * (cloud#927). `resolveUserAuthzGrants` accepts `undefined` and yields an + * empty-but-valid envelope, so the guard is all this needs. + */ + const getObjectQL = (): IDataEngine | undefined => { + try { return ctx.getService('objectql'); } catch { return undefined; } + }; const resolveCtx = async (c: any): Promise => { try { const authService = ctx.getService('auth'); @@ -478,159 +513,45 @@ export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { if (!session?.user?.id) return undefined; const userId = session.user.id; const tenantId = session.session?.activeOrganizationId ?? undefined; - const permissions: string[] = []; - const roles: string[] = []; - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - // Roles via sys_member (org-scoped if active org). - const memberRows = await ql?.find?.( - 'sys_member', - { - where: tenantId - ? { user_id: userId, organization_id: tenantId } - : { user_id: userId }, - limit: 50, - ...sysCtx, - } as any, - ).catch(() => []); - for (const m of (memberRows ?? []) as any[]) { - if (typeof m.role === 'string') { - for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { - if (!roles.includes(r)) roles.push(r); - } - } - } - // User-scoped permission sets — match BOTH (a) the active - // org's link rows and (b) the cross-tenant rows - // (organization_id IS NULL) so the platform-admin - // promotion seeded by `bootstrapPlatformAdmin` applies - // regardless of the user's active org. - const upsRows = await ql?.find?.( - 'sys_user_permission_set', - { where: { user_id: userId }, limit: 100, ...sysCtx } as any, - ).catch(() => []); - const psIds = new Set(); - for (const r of (upsRows ?? []) as any[]) { - const orgScope = r.organization_id ?? null; - if (!orgScope || (tenantId && orgScope === tenantId)) { - const pid = r.permission_set_id ?? r.permissionSetId; - if (pid) psIds.add(pid); - } - } - if (psIds.size > 0) { - const psRows = await ql?.find?.( - 'sys_permission_set', - { where: { id: { $in: Array.from(psIds) } }, limit: 500, ...sysCtx } as any, - ).catch(() => []); - for (const ps of (psRows ?? []) as any[]) { - if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name); - } - } - } catch { - /* fall through with whatever we resolved so far */ - } - // Resolve fellow-org user IDs so identity-table RLS (sys_user - // org-members policy) can scope @-mention pickers, owner - // lookups and reviewer selectors to the active organization. - // Mirrors the resolvers in `@objectstack/rest` and - // `@objectstack/runtime` so all three REST entry-points - // produce a consistent ExecutionContext shape. - let orgUserIds: string[] = [userId]; - if (tenantId) { - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const memberRows = await ql?.find?.( - 'sys_member', - { where: { organization_id: tenantId }, limit: 1000, ...sysCtx } as any, - ).catch(() => []); - const ids = new Set([userId]); - for (const m of (memberRows ?? []) as any[]) { - const uid = m.user_id ?? m.userId; - if (typeof uid === 'string' && uid.length > 0) ids.add(uid); - } - orgUserIds = Array.from(ids); - } catch { - /* fall back to self-only */ - } - } - // [ADR-0105 D2] The caller's org access set — the `group` - // posture's Layer 0 wall is `organization_id IN (...)`, so a - // context without it fails every read closed on this surface. - // Resolved from the user's OWN memberships (all organizations, - // not the active one). This standalone resolver duplicates the - // canonical `resolveAuthzContext` by design (see the posture - // note below); the duplication is tracked by - // `scripts/check-single-authz-resolver.mjs`. - let accessibleOrgIds: string[] = []; - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const myMemberships = await ql?.find?.( - 'sys_member', - { where: { user_id: userId }, limit: 200, ...sysCtx } as any, - ).catch(() => []); - const orgIds = new Set(); - for (const m of (myMemberships ?? []) as any[]) { - const oid = m.organization_id ?? m.organizationId; - if (typeof oid === 'string' && oid.length > 0) orgIds.add(oid); - } - accessibleOrgIds = Array.from(orgIds); - } catch { - /* no memberships resolvable → empty set → fails closed */ - } - // Env-side AI-seat marker (simple model). The single-org env - // DB has no permission-set/org dimension for this — the seat is - // the boolean `sys_user.ai_access`. Read it with a GUARDED system - // query (NOT a better-auth additionalField: sys_user is - // better-auth-managed and better-auth SELECTs explicit columns, - // so an additionalField would make getSession query a possibly- - // missing column → broken auth; a guarded read can only no-op). - // When true, synthesize the `ai_seat` capability so the per-agent - // gate (evaluateAgentAccess → requires `ai_seat`) admits the user - // with no permission-set grant. Absent/false/missing-column → - // no synthesis (deny, as before). - if (!permissions.includes('ai_seat')) { - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const uRows = await ql?.find?.( - 'sys_user', - { where: { id: userId }, limit: 1, ...sysCtx } as any, - ).catch(() => []); - // Turso returns sqlite booleans as 1/0; memory driver as boolean. - const aiAccess = (uRows?.[0] as any)?.ai_access; - if (aiAccess === true || aiAccess === 1 || aiAccess === '1') permissions.push('ai_seat'); - } catch { - /* no ai_access column / query failed → no seat (safe) */ - } - } + // THE delegation (#6334). Never throws — fail-closed by + // construction: a missing engine or an absent table yields an + // empty-but-valid envelope rather than an exception, so no read + // here needs its own guard. + const grants = await resolveUserAuthzGrants(getObjectQL(), userId, { + tenantId, + seedEmail: session.user.email ? String(session.user.email) : undefined, + }); // [#2408 / #3361] Open the per-request `Server-Timing` disclosure // gate for an admin/service principal — the standalone-surface analog - // of the runtime dispatcher's `timedResolveExecutionContext`. This - // self-contained resolver derives no posture rung, so derive one HERE, - // for the gate decision ONLY, from the resolved permission-set grants, - // and hand it to the shared `isPerfDisclosurePrincipal` predicate. The - // rung is computed onto a THROW-AWAY object, never the returned - // context: `ctx.posture` is an enforcement input (Layer 0 tier - // adjudication, ADR-0099 D1) and only the authoritative resolver may - // set it. A no-op when perf-tuning is off (no ambient gate). - const disclosurePosture = derivePosture({ - isPlatformAdmin: permissions.includes(ADMIN_FULL_ACCESS), - isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)), - }); - if (isPerfDisclosurePrincipal({ isSystem: false, posture: disclosurePosture } as ExecutionContext)) { + // of the runtime dispatcher's `timedResolveExecutionContext`. The rung + // is no longer re-derived here onto a throw-away object: + // `grants.posture` IS the authoritative derivation (ADR-0095 D2/D3 — + // from held capability grants, never a better-auth role), so this + // surface and the dispatcher hand the shared predicate the same value. + // A no-op when perf-tuning is off (no ambient gate). + if (isPerfDisclosurePrincipal({ isSystem: false, posture: grants.posture } as ExecutionContext)) { allowPerfDisclosure(); } return { userId, tenantId, - roles, - permissions, + email: grants.email, + // `positions`, not `roles`: the ExecutionContext field name every + // reader here uses (ADR-0090 D3). Carries the ADR-0057 D4 + // `sys_user_position` assignments, the normalized org-membership + // names, the implicit `everyone` anchor and the derived + // `platform_admin` — none of which this surface used to see. + positions: grants.positions, + permissions: grants.permissions, + systemPermissions: grants.systemPermissions, + tabPermissions: grants.tabPermissions, + posture: grants.posture, isSystem: false, - org_user_ids: orgUserIds, - accessible_org_ids: accessibleOrgIds, + org_user_ids: grants.org_user_ids, + // [ADR-0105 D2] The caller's org access set — the `group` + // posture's Layer 0 wall is `organization_id IN (...)`, so a + // context without it fails every read closed on this surface. + accessible_org_ids: grants.accessible_org_ids, } as any; } catch { return undefined; From faf1d3d374e971d420697b7531ee5ad60d20f06d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:38:54 +0000 Subject: [PATCH 2/2] fix(plugin-hono-server): drop seedEmail, update erasure ratchet baseline (#6334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthSessionApi.getSession` declares `user: { id?: string }` and nothing more, so reading `email` off it is the #4127 shape. The canonical resolver reads `sys_user.email` itself (the row it loads anyway for ai_seat), and that column is unique by the auth invariant — same answer, no contract widening. The erasure ratchet baseline drops its `current-user-endpoints.ts` entry: the 6 sites it recorded were the `as any` query options of the hand-copied table reads this PR deletes (ratchet DOWN, as the gate itself instructs). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .../src/current-user-endpoints.ts | 15 +++++++++++---- scripts/query-options-erasure-baseline.json | 1 - 2 files changed, 11 insertions(+), 5 deletions(-) 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 faa8555057..799351ae44 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -517,10 +517,17 @@ export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { // construction: a missing engine or an absent table yields an // empty-but-valid envelope rather than an exception, so no read // here needs its own guard. - const grants = await resolveUserAuthzGrants(getObjectQL(), userId, { - tenantId, - seedEmail: session.user.email ? String(session.user.email) : undefined, - }); + // + // No `seedEmail`: that option exists for a caller holding an email + // the resolver cannot read back (the API-key path, which has no + // session). Here the resolver's own `sys_user` read — the row it + // loads anyway for the `ai_seat` synthesis — answers it, and + // `sys_user.email` is unique by the auth invariant, so the two + // sources cannot disagree. `AuthSessionApi.getSession` declares + // `user: { id?: string }` and nothing more; reading an undeclared + // `email` off it through `any` is the #4127 shape, and widening + // that contract needs a call site that actually requires it. + const grants = await resolveUserAuthzGrants(getObjectQL(), userId, { tenantId }); // [#2408 / #3361] Open the per-request `Server-Timing` disclosure // gate for an admin/service principal — the standalone-surface analog // of the runtime dispatcher's `timedResolveExecutionContext`. The rung diff --git a/scripts/query-options-erasure-baseline.json b/scripts/query-options-erasure-baseline.json index 8f3544d598..890c716b9f 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -42,7 +42,6 @@ "packages/plugins/plugin-approvals/src/lifecycle-hooks.ts": 4, "packages/plugins/plugin-auth/src/admin-import-users.ts": 1, "packages/plugins/plugin-auth/src/auth-manager.ts": 14, - "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts": 6, "packages/plugins/plugin-sharing/src/share-link-routes.ts": 3, "packages/plugins/plugin-sharing/src/share-link-service.ts": 5, "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts": 1,