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..799351ae44 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,52 @@ 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. + // + // 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`. 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; 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,