From 571e7efe51f217424480bd604ad3c3f4c1a4a4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:53:02 +0000 Subject: [PATCH 1/9] fix(plugin-auth): session payload positions[] carries the security positions, not auth roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `customSession` built `user.positions` from a hand-rolled union — the better-auth `sys_user.role` scalar split on commas, the active membership mapped to `org_*`, and `platform_admin` — and read nothing from `sys_user_position`, the ADR-0057 D4 table that is the source of truth for custom positions. The Console binds that array straight through as the CEL root `current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so an `action.visible` narrowed by a business position answered FALSE for everyone, including its holder — silently, because the root and the key were both bound and CEL raised nothing. `EvalUserSchema` had declared the opposite all along: `positions` is "built-in identity names + position names", exposed to "every predicate surface ... with an identical shape" so a predicate "evaluates identically wherever it is written". The payload was violating a declared contract, not merely carrying an ambiguous name. The documented example survived because `org_admin` sits on both axes — the one name that could not reveal the split. The derivation is deleted rather than repaired: `resolve-authz-context.ts` states that every entry point must resolve authorization through it and never re-read the `sys_*` grant tables itself, and this callback was doing exactly what that forbids. It now asks `resolveUserAuthzGrants` — the same authority `/auth/me/permissions` is served from and every server-side evaluator resolves `ExecutionContext.positions` through — scoped to the session's active organization. `isPlatformAdmin` is derived from that array (ADR-0068 D2 defines it as an alias of `'platform_admin' in positions`), so one authority answers both and cannot disagree with itself. The better-auth role scalar is not lost: `user.role` stays on the payload verbatim, which is the ADR-0090 D3 documented exception for third-party schema. Fails closed and warns on an unreadable grant store, matching what both halves of the old derivation already did silently. Refs #15136 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- packages/plugins/plugin-auth/package.json | 1 + .../plugins/plugin-auth/src/auth-manager.ts | 135 +++++---- .../session-positions-security-axis.test.ts | 257 ++++++++++++++++++ pnpm-lock.yaml | 7 +- 4 files changed, 338 insertions(+), 62 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index b1b11c0ccc..8ab88d1d89 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@objectstack/driver-sql": "workspace:*", + "@objectstack/formula": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-security": "workspace:*", diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 30ee873658..0fae6997fd 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -32,7 +32,7 @@ import type { IDataEngine } from '@objectstack/core'; // `auth-manager` used to re-derive that standing itself, in two spellings // that had drifted from the declared authority and from each other; both // now ask the authority. Nothing in this file reads the grant tables. -import { hasPlatformAdminStanding } from '@objectstack/core'; +import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/core'; import type { IEmailService, ISmsService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, @@ -42,7 +42,6 @@ import { } from '@objectstack/types'; import { resolveMembershipLimitOption } from './membership-limit.js'; import { - mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN, MEMBERSHIP_ROLE_DELEGATED_ADMIN, } from '@objectstack/spec'; @@ -3542,65 +3541,35 @@ export class AuthManager { } // customSession() — augments the session payload with the canonical - // `roles: string[]` array (ADR-0068 D1/D2): the stored `user.role` scalar - // split on commas, PLUS the active membership mapped to canonical - // `org_owner`/`org_admin`/`org_member`, PLUS `platform_admin` when the user - // resolves as a platform admin (ADR-0068 D2). `user.isPlatformAdmin` is a - // derived alias of `'platform_admin' in positions`. + // `positions: string[]` array (ADR-0068 D1/D2, renamed ADR-0090 D3) and its + // derived `isPlatformAdmin` alias. // - // IMPORTANT: `user.role` is NOT overwritten anymore — consumers must gate - // on `positions[]` / `isPlatformAdmin` (e.g. via objectui's useIsWorkspaceAdmin), - // never on `user.role === 'admin'`. Consumers that match individual role - // names (e.g. the Console approvals inbox resolving `role:` approvers) - // also read `positions` — business names such as `manager` survive only there. - // The raw membership role stays on the organization plugin's `member` payload. + // [#15136] `positions[]` is the SECURITY axis — the same set + // `/auth/me/permissions` reports and every server-side evaluator resolves: + // the active organization's membership mapped to canonical + // `org_owner`/`org_admin`/`org_member`, the ADR-0057 D4 `sys_user_position` + // assignments, the ADR-0090 D5 `everyone` anchor, and `platform_admin`. It + // is not derived here; it is asked of `resolveUserAuthzGrants`, the ONE + // authority, for the reason spelled out at the call site below. // - // Better-auth's `sys_user` table doesn't carry a `role` column. We derive - // it from two sources: + // ⛔ The better-auth `user.role` scalar is NOT a source of it. It used to be + // (split on commas and unioned in), which put auth-layer role names on an + // array whose declared meaning — `EvalUserSchema`, "built-in identity names + // + position names" — is the security axis, and left the real position names + // off it entirely. // - // 1. **Platform admin** — the ADR-0068 D2 standing, resolved through - // `core/security/resolve-authz-context.ts` (the single authority for - // authorization derivation) and never re-read here. See - // `isPlatformAdminUserId` below. - // 2. **Organization admin** — a `sys_member` row in the user's *active* - // organization (`session.activeOrganizationId`) with role `owner` or - // `admin`. Org owners/admins are entitled to manage org-scoped - // metadata such as saved list views, dashboards, etc. - // - // ADR-0068 D2: rather than synthesizing `user.role = 'admin'`, both paths now - // contribute CANONICAL names to `user.positions` (platform_admin / org_*), - // and `user.isPlatformAdmin` is a derived alias. The raw membership role - // remains available via the `organization` plugin's `member` payload. + // IMPORTANT: `user.role` is NOT overwritten — consumers must gate on + // `positions[]` / `isPlatformAdmin` (e.g. via objectui's + // `useWorkspaceAdminStatus`), never on `user.role === 'admin'`. A consumer + // that genuinely wants the better-auth role reads `user.role`, which stays + // on the payload verbatim; the raw membership role stays on the organization + // plugin's `member` payload. const dataEngine = this.config.dataEngine; if (dataEngine) { const { customSession } = await import('better-auth/plugins/custom-session'); plugins.push(customSession(async ({ user, session }) => { if (!user?.id) return { user, session }; - // ADR-0068 D2 — surface CANONICAL org_* role names (not a boolean flag): - // a membership owner/admin/member maps to org_owner/org_admin/org_member. - const activeOrgRoles = async (): Promise => { - try { - const orgId = (session as any)?.activeOrganizationId; - if (!orgId) return []; - const members = await dataEngine.find('sys_member', { - where: { user_id: user.id, organization_id: orgId }, - limit: 5, - }); - const out: string[] = []; - for (const m of (Array.isArray(members) ? members : [])) { - const raw = typeof m?.role === 'string' ? m.role : ''; - for (const r of raw.split(',').map((s: string) => s.trim()).filter(Boolean)) { - const mapped = mapMembershipRole(r); - if (!out.includes(mapped)) out.push(mapped); - } - } - return out; - } catch { - return []; - } - }; - // ADR-0068 D1/D2 (renamed ADR-0090 D3) — emit ONE canonical // positions[] (identity names + position names), with NO singular // overwrite. isPlatformAdmin is a DERIVED alias of @@ -3608,14 +3577,60 @@ export class AuthManager { // [#10348] Asked through the ONE authority, exactly as `/sso/register` // and `/admin/impersonate-user` ask it — so the session payload can no // longer disagree with the gates about who a platform admin is. - const platformAdmin = await this.isPlatformAdminUserId(user.id); - const orgRoles = await activeOrgRoles(); - const storedRole = typeof (user as any).role === 'string' ? (user as any).role : ''; - const positions = Array.from(new Set([ - ...storedRole.split(',').map((s: string) => s.trim()).filter(Boolean), - ...orgRoles, - ...(platformAdmin ? [BUILTIN_IDENTITY_PLATFORM_ADMIN] : []), - ])); + // + // [#15136 — maintainer ruling 2026-09-05, option A] ONE name, ONE + // meaning: `positions[]` is the SECURITY axis on every surface. + // + // This used to be a hand-rolled union — the better-auth `sys_user.role` + // scalar split on commas, plus the active membership mapped to `org_*`, + // plus `platform_admin`. It read NOTHING from `sys_user_position`, the + // ADR-0057 D4 table that is the source of truth for custom positions, so + // a user genuinely holding a business position got a payload without it + // and every client-side gate narrowed by that position answered FALSE — + // silently, because the root and the key were both bound and CEL raised + // nothing. `EvalUserSchema` had declared the opposite all along + // ("built-in identity names + position names", "evaluates identically + // wherever it is written"); the Console binds this array straight through + // as `current_user`, so the payload WAS the contract violation. + // + // The derivation is not repaired here — it is DELETED and asked of the + // authority instead. `resolve-authz-context.ts` states that every entry + // point must resolve authorization through it and never re-read the + // `sys_*` grant tables itself; this callback was doing exactly what that + // forbids, which is how it drifted out of agreement with + // `/auth/me/permissions` (served from the same `grants.positions`) and + // with every server-side evaluator (`ExecutionContext.positions`) in the + // first place. The same move `isPlatformAdminUserId` made at #10348. + // + // Scoped to the session's ACTIVE organization, so a position held in one + // organization does not answer while the caller operates in another — + // the resolver's own rule, now applied to the payload too. + // + // `isPlatformAdmin` is derived from the resolved array rather than asked + // separately: ADR-0068 D2 defines it as an alias of `'platform_admin' in + // positions`, and one authority answering both cannot disagree with + // itself. `platform-admin-standing.consolidation.test.ts` PIN 6 pins that + // equivalence shape by shape against the gates. + // + // Fail CLOSED on an unreadable grant store, matching what both halves of + // the old derivation already did (`isPlatformAdminUserId` returns false + // on any lookup error; `activeOrgRoles` caught to `[]`). Warned rather + // than swallowed — an empty `positions[]` hides UI, and this card is + // about exactly that going unannounced. + let positions: string[] = []; + try { + const grants = await resolveUserAuthzGrants(dataEngine as any, user.id, { + tenantId: (session as any)?.activeOrganizationId ?? undefined, + }); + positions = grants.positions; + } catch (err: any) { + console.warn( + '[auth] could not resolve authorization grants for the session payload; ' + + 'positions[] is empty and position-gated UI will be hidden:', + err?.message ?? String(err), + ); + } + const platformAdmin = positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN); // ADR-0069 — authentication-policy gate posture (password expiry, // enforced MFA). Computed only when a gate feature is enabled (else diff --git a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts new file mode 100644 index 0000000000..b0e6b687f7 --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The session payload's `positions[]` is the SECURITY axis (#15136). +// +// `EvalUserSchema` (`packages/spec/src/identity/eval-user.zod.ts`) declares one +// contract for the whole platform: the signed-in user is "exposed to every +// predicate surface (server formula, server RLS, client UI gates) under the +// canonical variable name `current_user` ... with an IDENTICAL shape", and its +// `positions` field is "built-in identity names + POSITION names". The Console +// binds that root straight from the `get-session` payload (objectui +// `packages/app-shell/src/providers/expressionUser.ts` — `positions: +// user.positions ?? []`, a pass-through), so whatever `customSession` derives +// IS `current_user.positions` for every client-side `visible` / `visibleWhen` +// gate. +// +// It derived the wrong axis. The union was the better-auth `sys_user.role` +// scalar split on commas, plus the active membership mapped to `org_*`, plus +// `platform_admin` — and NOTHING from `sys_user_position`, the ADR-0057 D4 +// table that is the source of truth for custom positions. A user genuinely +// holding `demo_reviewer` got `["user","org_member"]`, so a button narrowed by +// that position vanished for EVERYONE, including its holder. +// +// ⭐ Why the failure is silent, and why that is the whole defect: the root IS +// bound and the key IS present, so `has(current_user.positions)` is true, CEL +// raises nothing, and the predicate simply answers FALSE. A faulting predicate +// fails OPEN in the shell (objectui `evaluateVisibility`) and would at least +// have shown the button; a successful FALSE shows nothing and reports nothing. +// The documented example survived because `org_admin` happens to sit on BOTH +// axes — the one name that could not reveal the split. +// +// The pins below drive the REAL pipeline in both halves: a real better-auth +// instance over a real `AuthManager` answering a real `getSession()`, and the +// real `celEngine` from `@objectstack/formula` — the same engine the server +// evaluates formulas and RLS with — over the payload that session returns. No +// fixture stands in for either side, because the defect lived exactly in the +// seam between them. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { celEngine } from '@objectstack/formula'; +import { AuthManager } from './auth-manager'; +// Imported from a sibling TEST file for the reason `platform-admin-standing. +// consolidation.test.ts` documents at its own import: re-registering that +// file's `describe`s here is cheaper than minting a second engine double (a +// second looseness risk plus new `check:engine-double-contract` ledger rows). +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-15136'; +const BASE = 'http://localhost:3000/api/v1/auth'; +const ORG = 'org_15136'; + +/** The card's own position name, verbatim. */ +const POSITION = 'demo_reviewer'; + +/** + * The card's own `action.visible` predicate, verbatim minus the `record` half + * (this suite is about the identity root; the record half is another axis). + */ +const VISIBLE = `'${POSITION}' in current_user.positions`; + +const makeManager = (engine: any) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + } as any); + +const signUp = (manager: AuthManager, email: string, name: string) => { + inviteForAudienceGate(manager, email); + return manager.handleRequest( + new Request(`${BASE}/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name }), + }), + ); +}; + +const signIn = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request(`${BASE}/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD }), + }), + ); + +const bearerFrom = (response: Response): string => { + const token = response.headers.get('set-auth-token'); + if (!token) throw new Error('no set-auth-token on the response'); + return token; +}; + +const userIdFor = (engine: any, email: string): string => { + const row = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +/** The REAL session payload, through a real better-auth `getSession()`. */ +const payloadFor = async (manager: AuthManager, bearer: string) => { + const auth: any = await manager.getAuthInstance(); + const session = await auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${bearer}` }) }) + .catch(() => null); + return session?.user ?? null; +}; + +/** + * Evaluate a predicate exactly as a shell gate does: bind the session payload + * as `current_user` and run it through the real CEL engine. + * + * Returns the discriminated result rather than a boolean, so a FAULT can never + * be read as a `false` — telling those two apart is the point of the suite. + */ +const evaluateVisible = (source: string, user: any) => + celEngine.evaluate({ dialect: 'cel', source } as any, { user }); + +/** + * Two principals over one engine, both members of the same organization. Only + * `holder` is assigned the position — `bystander` is the in-test control that + * keeps every "the button shows" assertion from passing vacuously. + */ +const arrange = async (opts: { assignPosition?: boolean } = {}) => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + await signUp(manager, 'holder@example.com', 'Position Holder'); + await signUp(manager, 'bystander@example.com', 'Bystander'); + + const holderId = userIdFor(engine, 'holder@example.com'); + const bystanderId = userIdFor(engine, 'bystander@example.com'); + + await engine.insert('sys_organization', { id: ORG, name: 'Card Org', slug: 'card-org' }); + await engine.insert('sys_member', { organization_id: ORG, user_id: holderId, role: 'member' }); + await engine.insert('sys_member', { organization_id: ORG, user_id: bystanderId, role: 'member' }); + + // The security-layer position, and the ADR-0057 D4 assignment row that is its + // source of truth. `organization_id: null` = a global assignment, so the + // fixture does not also depend on the session carrying an active org. + await engine.insert('sys_position', { id: 'pos_reviewer', name: POSITION, label: 'Demo Reviewer' }); + if (opts.assignPosition !== false) { + await engine.insert('sys_user_position', { + user_id: holderId, + position: POSITION, + organization_id: null, + }); + } + + const holderBearer = bearerFrom(await signIn(manager, 'holder@example.com')); + const bystanderBearer = bearerFrom(await signIn(manager, 'bystander@example.com')); + return { engine, manager, holderId, bystanderId, holderBearer, bystanderBearer }; +}; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 1 — the card, inverted. The holder sees the button; the bystander does +// not. Asserted as the ruled behaviour, not as the defect. +// ─────────────────────────────────────────────────────────────────────────── +describe('a position-narrowed `visible` predicate resolves on the session payload', () => { + it('carries the assigned security position into the payload', async () => { + const { manager, holderBearer } = await arrange(); + const user = await payloadFor(manager, holderBearer); + expect(user?.positions, JSON.stringify(user?.positions)).toContain(POSITION); + }); + + it('shows the button to the holder — and hides it from the bystander on the same engine', async () => { + const { manager, holderBearer, bystanderBearer } = await arrange(); + + const held = evaluateVisible(VISIBLE, await payloadFor(manager, holderBearer)); + expect(held, JSON.stringify(held)).toMatchObject({ ok: true, value: true }); + + // The control. Without it, a predicate that answered `true` for everyone + // (an evaluator bug, a scope that binds nothing) would score green above. + const notHeld = evaluateVisible(VISIBLE, await payloadFor(manager, bystanderBearer)); + expect(notHeld, JSON.stringify(notHeld)).toMatchObject({ ok: true, value: false }); + }); + + it('hides it from the holder once the assignment row is gone (the axis is the ROW, not the name)', async () => { + const { manager, holderBearer } = await arrange({ assignPosition: false }); + const v = evaluateVisible(VISIBLE, await payloadFor(manager, holderBearer)); + expect(v, JSON.stringify(v)).toMatchObject({ ok: true, value: false }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 2 — the silence itself. The root and the key are bound in EVERY case, so +// a regression can only ever show up as a wrong verdict, never as a fault. This +// is what made the defect invisible, and it is pinned so a future "fix" that +// merely makes the predicate fault (which fails OPEN in the shell, showing the +// button to everyone) cannot be mistaken for a repair. +// ─────────────────────────────────────────────────────────────────────────── +describe('the predicate root stays bound — the failure mode was a FALSE, never a fault', () => { + it('`has(current_user.positions)` is true for holder and bystander alike', async () => { + const { manager, holderBearer, bystanderBearer } = await arrange(); + for (const bearer of [holderBearer, bystanderBearer]) { + const v = evaluateVisible('has(current_user.positions)', await payloadFor(manager, bearer)); + expect(v, JSON.stringify(v)).toMatchObject({ ok: true, value: true }); + } + }); + + it('the position-narrowed predicate never FAULTS — it answers, one way or the other', async () => { + const { manager, holderBearer, bystanderBearer } = await arrange(); + for (const bearer of [holderBearer, bystanderBearer]) { + const v = evaluateVisible(VISIBLE, await payloadFor(manager, bearer)); + expect(v.ok, JSON.stringify(v)).toBe(true); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 3 — ADR-0068 D1 parity, which is the contract the card actually broke: +// the payload's `positions` must be the SAME axis `/auth/me/permissions` and +// every server-side evaluator resolve, i.e. the one `resolveUserAuthzGrants` +// yields. Pinned as a set comparison against the authority itself rather than +// against a literal list, so it keeps holding as that authority grows. +// ─────────────────────────────────────────────────────────────────────────── +describe('the payload agrees with the ONE authorization authority, set for set', () => { + it('matches `resolveUserAuthzGrants` for the position holder', async () => { + const { engine, manager, holderId, holderBearer } = await arrange(); + const { resolveUserAuthzGrants } = await import('@objectstack/core'); + + const payload = await payloadFor(manager, holderBearer); + const grants = await resolveUserAuthzGrants(engine as any, holderId); + + expect([...(payload?.positions ?? [])].sort()).toEqual([...grants.positions].sort()); + // Not vacuous: the authority really did resolve the position. + expect(grants.positions).toContain(POSITION); + }); + + it('carries the ADR-0090 D5 `everyone` anchor the authority adds', async () => { + const { manager, holderBearer } = await arrange(); + const user = await payloadFor(manager, holderBearer); + expect(user?.positions, JSON.stringify(user?.positions)).toContain('everyone'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// PIN 4 — ADR-0068 D2 is untouched. `platform_admin` reaches the payload the +// way it always did, and the stored `role` scalar is still never overwritten. +// Without this the change could quietly drop a derivation the gates depend on. +// ─────────────────────────────────────────────────────────────────────────── +describe('the platform-admin derivation and the stored role scalar are unchanged', () => { + it('a plain member is not a platform admin and keeps its role scalar', async () => { + const { manager, holderBearer } = await arrange(); + const user = await payloadFor(manager, holderBearer); + expect(user?.isPlatformAdmin).toBe(false); + expect(user?.positions ?? []).not.toContain('platform_admin'); + expect(user?.role ?? 'user').not.toBe('admin'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b88409ec7e..7f44871995 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,7 +377,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/setup: dependencies: @@ -399,7 +399,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/apps/studio: dependencies: @@ -1594,6 +1594,9 @@ importers: '@objectstack/driver-sql': specifier: workspace:* version: link:../../drivers/driver-sql + '@objectstack/formula': + specifier: workspace:* + version: link:../../formula '@objectstack/objectql': specifier: workspace:* version: link:../../objectql From 54417ddf0fe07571a8658e8c80b88bebb625e2f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:04:59 +0000 Subject: [PATCH 2/9] test(plugin-auth): migrate the four fixtures that pinned the removed auth-axis derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They asserted `positions[]` WAS the better-auth `sys_user.role` scalar split on commas — the derivation the #15136 ruling removed — so they are fixtures to migrate, not evidence against it. Each now asserts the scalar is absent from the array while remaining untouched on the payload, which is the half of ADR-0068 D2 that did not change. One case is added for the axis the payload was missing entirely: a `sys_user_position` assignment reaching `positions[]`. Also declares in `packages/spec` which axis `positions` is, and that the better-auth role scalar is not it, with the regenerated reference page. Part of #15136 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...session-payload-positions-security-axis.md | 79 +++++++++++++++++++ .../docs/references/identity/eval-user.mdx | 25 +++++- .../plugin-auth/src/auth-manager.test.ts | 55 ++++++++++--- packages/spec/src/identity/eval-user.zod.ts | 31 +++++++- 4 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 .changeset/session-payload-positions-security-axis.md diff --git a/.changeset/session-payload-positions-security-axis.md b/.changeset/session-payload-positions-security-axis.md new file mode 100644 index 0000000000..938573e26b --- /dev/null +++ b/.changeset/session-payload-positions-security-axis.md @@ -0,0 +1,79 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/spec": minor +--- + +fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) + + + +**BREAKING** meaning change on a published payload — `user.positions` in +`GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's +launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on +#15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one +name, one meaning. + +`customSession` built the array from the better-auth `sys_user.role` scalar +split on commas, plus the active membership mapped to `org_*`, plus +`platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 +D4 table that is the source of truth for custom positions. The Console binds +that array straight through as the CEL root `current_user`, so an +`action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed +by a business position answered FALSE for **everyone**, including the user who +genuinely held it. + +⭐ It failed **silently and in the invisible direction**: the root was bound and +the key was present, so `has(current_user.positions)` was true, CEL raised +nothing, and the predicate simply returned FALSE. A predicate that *faults* +fails OPEN in the shell and would have shown the button; a successful FALSE +shows nothing and reports nothing. The documented example +(`'org_admin' in current_user.positions`) kept working throughout, because +`org_admin` is the one name that sits on **both** axes. + +This was a **declared** contract being violated, not an ambiguous name: +`EvalUserSchema` already specified `positions` as "built-in identity names + +position names", exposed to "every predicate surface (server formula, server +RLS, client UI gates) ... with an identical shape" so that a predicate +"evaluates identically wherever it is written". `/auth/me/permissions` and +every server-side evaluator (`ExecutionContext.positions`) already resolved the +security axis; only the session payload did not. + +**What changes** + +- `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, + not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE + authority (`core/security/resolve-authz-context.ts`, whose header forbids + every entry point from re-reading the `sys_*` grant tables itself), scoped to + the session's active organization. The payload therefore carries the + `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and + agrees with `/auth/me/permissions` set for set. Same move + `isPlatformAdminUserId` made at #10348. +- `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as + an alias of `'platform_admin' in positions`), so one authority answers both. +- `packages/spec` — `EvalUserSchema` states which axis `positions` is, and + states that the better-auth role scalar is not it. + +**No key is renamed, and none is added.** The ruling anticipated a renamed +auth-role array; measured against the tree, it has no content to carry and no +consumer. Everything the old union contributed beyond the security axis was the +`sys_user.role` scalar's own tokens — and that scalar is **already published, +unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban +carves out, for third-party schema this platform does not own). Minting a +`roles` array would revive the exact banned identifier `check:role-word` +ratchets against, to publish information the payload already carries. A +consumer that wants the better-auth role reads `user.role`. + +**What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); +`platform_admin` still derives from the unscoped `admin_full_access` grant with +its ADR-0091 validity window and ADR-0049 active flag intact — pinned shape for +shape against both gates by `platform-admin-standing.consolidation.test.ts` +PIN 6, which passes unchanged. + +**Upgrade.** If you gate on the better-auth role scalar, read `user.role` +instead of looking for its tokens in `user.positions`. Predicates written +against real position names, built-in identity names, or `everyone` need no +change — they start working. Deployments that stored business role names in +`sys_user.role` rather than assigning positions should assign them through +`sys_user_position` (the governed ADR-0090 D12 channel); a name in +`sys_member.role` is still projected, so membership-derived names are +unaffected. diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx index f55380517c..65b009b481 100644 --- a/content/docs/references/identity/eval-user.mdx +++ b/content/docs/references/identity/eval-user.mdx @@ -18,6 +18,29 @@ it is written. `roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion" behavior is the footgun this eliminates. +## Which axis `positions` is — and which it is not (#15136) + +It is the **security** axis: the built-in identity names plus the +`sys_position` names the principal actually holds through `sys_user_position` +(ADR-0057 D4) and their active membership, plus the `everyone` anchor +(ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and +`resolveUserAuthzGrants` resolves. Every surface derives it from that one +authority, so the "identical shape" promise above is a property of the +producer, not a convention producers are asked to honour. + +⛔ It is **not** the better-auth `sys_user.role` scalar. That scalar is an +authentication-layer field on a table this platform does not own; it stays +published, unchanged, as `user.role` (the one exception ADR-0090 D3's word +ban carves out), and a consumer that genuinely wants it reads it there. + +⚠️ The session payload used to union the scalar into this array and omit the +`sys_user_position` names entirely, which made a position-narrowed client +gate answer FALSE for its own holder — silently, since the root and the key +were both bound and CEL raised nothing. The documented `org_admin` example +kept working throughout because that name sits on both axes. If you are +adding a producer of this shape: derive it from the authority, never +assemble it from whatever identity fields are in reach. + See also: docs/adr/0068-unified-user-context-and-built-in-identity-roles.md @@ -45,7 +68,7 @@ const result = EvalUserSchema.parse(data); | **id** | `string` | ✅ | User ID | | **name** | `string` | optional | Display name | | **email** | `string` | optional | Email address | -| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names assigned to the user (scope-resolved) | +| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role | | **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in positions. Deprecated. | | **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) | diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 7decbd1f23..615bd9ceaf 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3474,52 +3474,85 @@ describe('AuthManager', () => { return plugin._fn as (input: { user: any; session: any }) => Promise; }; - it('returns positions=[] for a regular user with no stored role', async () => { + // [#15136 — maintainer ruling 2026-09-05, option A] These four cases used to + // pin the OPPOSITE contract: that the better-auth `sys_user.role` scalar, + // split on commas, WAS `positions[]`. That is the derivation the ruling + // removed — `positions[]` is the security axis on every surface — so they + // are migrated fixtures, not evidence against it. Each one now asserts the + // scalar is ABSENT from the array while remaining untouched on the payload, + // which is the half of ADR-0068 D2 that did not change. + it('carries the ADR-0090 D5 `everyone` anchor for a regular user with no stored role', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com' }, session: {}, }); expect(result.user.role).toBeUndefined(); - expect(result.user.positions).toEqual([]); + expect(result.user.positions).toEqual(['everyone']); }); - it('splits a stored role string into positions for a non-admin user', async () => { + it('does NOT project the stored role scalar into positions[]', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, session: {}, }); - // No promotion: `role` keeps its stored value. + // The scalar keeps its stored value — it is simply not an authorization + // axis, so it no longer appears in the array authored predicates read. expect(result.user.role).toBe('manager'); - expect(result.user.positions).toEqual(['manager']); + expect(result.user.positions).not.toContain('manager'); + expect(result.user.positions).toEqual(['everyone']); }); - it('appends platform_admin to positions[] without overwriting role when promoting a platform admin', async () => { + it('derives platform_admin without overwriting role and without the scalar', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, session: {}, }); // ADR-0068: NO `role:'admin'` overwrite footgun. The deprecated scalar - // keeps its stored value; the canonical platform_admin identity is added - // to roles[], and isPlatformAdmin is a derived alias. + // keeps its stored value; the canonical platform_admin identity is on + // positions[], and isPlatformAdmin is a derived alias of it. expect(result.user.role).toBe('manager'); - expect(result.user.positions).toEqual(['manager', 'platform_admin']); + expect(result.user.positions).toContain('platform_admin'); + expect(result.user.positions).not.toContain('manager'); expect(result.user.isPlatformAdmin).toBe(true); }); - it('splits a multi-token stored role and appends platform_admin without duplicates', async () => { + it('ignores a multi-token stored role entirely while still deriving platform_admin', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'admin,manager' }, session: {}, }); expect(result.user.role).toBe('admin,manager'); - expect(result.user.positions).toEqual(['admin', 'manager', 'platform_admin']); + expect(result.user.positions).toContain('platform_admin'); + for (const token of ['admin', 'manager']) { + expect(result.user.positions).not.toContain(token); + } expect(result.user.isPlatformAdmin).toBe(true); }); + it('carries an ADR-0057 D4 `sys_user_position` assignment — the axis the payload was missing', async () => { + const engine = makeDataEngine({ platformAdmin: false }); + const inner = engine.find; + engine.find = vi.fn(async (object: string, q?: any) => { + if (object === 'sys_user_position') { + return [{ user_id: 'u-1', position: 'demo_reviewer', organization_id: null }]; + } + return inner(object, q); + }) as any; + + const callback = await getSessionCallback(engine); + const result = await callback({ + user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, + session: {}, + }); + expect(result.user.positions).toContain('demo_reviewer'); + // Still not the scalar — the two axes do not blend. + expect(result.user.positions).not.toContain('manager'); + }); + it('returns the payload untouched when the user has no id', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false })); const user = { email: 'anon@b.com' }; diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index f1c924ae37..e5af88be0d 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -18,6 +18,29 @@ import type { PermissionSet } from '../security/permission.zod'; * `roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion" * behavior is the footgun this eliminates. * + * ## Which axis `positions` is — and which it is not (#15136) + * + * It is the **security** axis: the built-in identity names plus the + * `sys_position` names the principal actually holds through `sys_user_position` + * (ADR-0057 D4) and their active membership, plus the `everyone` anchor + * (ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and + * `resolveUserAuthzGrants` resolves. Every surface derives it from that one + * authority, so the "identical shape" promise above is a property of the + * producer, not a convention producers are asked to honour. + * + * ⛔ It is **not** the better-auth `sys_user.role` scalar. That scalar is an + * authentication-layer field on a table this platform does not own; it stays + * published, unchanged, as `user.role` (the one exception ADR-0090 D3's word + * ban carves out), and a consumer that genuinely wants it reads it there. + * + * ⚠️ The session payload used to union the scalar into this array and omit the + * `sys_user_position` names entirely, which made a position-narrowed client + * gate answer FALSE for its own holder — silently, since the root and the key + * were both bound and CEL raised nothing. The documented `org_admin` example + * kept working throughout because that name sits on both axes. If you are + * adding a producer of this shape: derive it from the authority, never + * assemble it from whatever identity fields are in reach. + * * @see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md */ @@ -189,8 +212,12 @@ export const EvalUserSchema = lazySchema(() => id: z.string().describe('User ID'), name: z.string().optional().describe('Display name'), email: z.string().optional().describe('Email address'), - /** CANONICAL. Scope-resolved (ADR-0068 D3); built-in identity names + position names. */ - positions: z.array(z.string()).default([]).describe('Canonical position/identity names assigned to the user (scope-resolved)'), + /** + * CANONICAL. Scope-resolved (ADR-0068 D3); built-in identity names + + * position names. The SECURITY axis (#15136) — never the better-auth + * `sys_user.role` scalar, which stays published as `user.role`. + */ + positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'), /** DERIVED alias of positions.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */ isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in positions. Deprecated."), organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'), From e580aa42991a9b43b46a2900f6a855f33f336cd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:17:26 +0000 Subject: [PATCH 3/9] docs(spec): register the ADR-0087 semantic entry for the positions[] meaning change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A meaning change with no key move: nothing here can be found by grepping for a removed spelling, and there is no schema rejection to catch a stale reader, so the ledger row is the only channel that reaches an upgrader. The acceptance criteria name the discriminator the defect itself hid behind — `org_admin` sits on both axes and cannot tell them apart — and require the check be made against a real session by a name that exists on one side only. Part of #15136 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...session-payload-positions-security-axis.ts | 69 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 65 +++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts b/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts new file mode 100644 index 0000000000..6c5b29cac2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'session-payload-positions-security-axis', + surface: 'GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)', + replacement: + 'the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports ' + + 'and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role ' + + 'scalar reads `user.role`, which is unchanged and still published', + reason: + 'A MEANING change, not a rename: no key moved, so nothing in this entry can be found by ' + + 'grepping for a removed spelling — which is exactly why it needs a ledger row. ' + + '`customSession` built `user.positions` from the better-auth `sys_user.role` scalar ' + + 'split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, ' + + 'and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for ' + + 'custom positions. The Console binds that array straight through as the CEL root ' + + '`current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so ' + + 'an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position ' + + 'answered FALSE for EVERYONE, including the user who genuinely held it. ' + + '⭐ The failure was silent and in the invisible direction: the root was bound and the ' + + 'key was present, so `has(current_user.positions)` was true and CEL raised nothing — ' + + 'the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell ' + + 'and would have shown the button; a successful FALSE shows nothing and reports ' + + 'nothing. The documented example `\'org_admin\' in current_user.positions` kept working ' + + 'throughout, because `org_admin` is the one name that sits on BOTH axes — which is why ' + + 'no example, test or doc could reveal the split. ' + + 'This was a DECLARED contract being violated rather than an ambiguous name: ' + + '`EvalUserSchema` already specified `positions` as "built-in identity names + position ' + + 'names", exposed to "every predicate surface (server formula, server RLS, client UI ' + + 'gates) ... with an identical shape" so a predicate "evaluates identically wherever it ' + + 'is written". `/auth/me/permissions` and every server-side evaluator ' + + '(`ExecutionContext.positions`) already resolved the security axis; the session ' + + 'payload was the one producer that did not, because it derived the value itself ' + + 'instead of asking the authority `resolve-authz-context.ts` reserves that job for. ' + + '⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition ' + + 'rather than an omission: everything the old union contributed beyond the security ' + + 'axis was the `sys_user.role` scalar\'s own tokens, and that scalar is ALREADY ' + + 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word ' + + 'ban carves out for third-party schema. Minting a `roles` array would revive the exact ' + + 'banned identifier `check:role-word` ratchets against, to publish information the ' + + 'payload already carries. Maintainer ruling 2026-09-05 (#15136, director decision ' + + 'batch #39 item 2, verbatim 「同意」): option A, one name, one meaning. ADR-0068 D1/D2, ' + + 'ADR-0090 D3/D5, ADR-0057 D4.', + acceptanceCriteria: + 'No predicate and no client reader treats `current_user.positions` / ' + + '`session.user.positions` as the better-auth role scalar. Audit every authored ' + + '`visible` / `visibleWhen` / RLS predicate that names `current_user.positions` and ' + + 'classify each comparand: a real `sys_position` name, a built-in identity name ' + + '(`platform_admin` / `org_owner` / `org_admin` / `org_member`), or `everyone` needs NO ' + + 'change and starts working where it silently answered FALSE before; a comparand that ' + + 'was only ever a `sys_user.role` token (`user`, and `admin` — note `admin` is NOT a ' + + 'built-in identity name; a membership `admin` is projected as `org_admin`) either ' + + 'moves to `user.role`, or — the supported route — becomes a real position assigned ' + + 'through `sys_user_position`, the governed ADR-0090 D12 channel. ' + + '⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name ' + + 'that exists on ONE side only: `org_admin` sits on both and cannot discriminate, which ' + + 'is precisely how this defect survived its own documented example. Sign in as a user ' + + 'holding a custom position, read `GET /api/v1/auth/get-session`, and assert that ' + + 'position is in `user.positions` and that the payload agrees set-for-set with `GET ' + + '/api/v1/auth/me/permissions`. ' + + 'Assert the absence of a role-scalar token by VALUE rather than by the predicate\'s ' + + 'verdict: a gate that reads `false` cannot tell "the name is gone" from "the name was ' + + 'never there", and both of those from a faulting predicate, which fails OPEN in the ' + + 'shell and renders anyway. A deployment that stored business role names in ' + + '`sys_user.role` instead of assigning positions is the one that must act; a name in ' + + '`sys_member.role` is still projected, so membership-derived names are unaffected.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d70701822d..92e043a429 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8076,6 +8076,71 @@ const step18: MigrationStep = { + '`organizationId` still stamps `sys_email.organization_id` without acquiring any overlay ' + 'semantics.', }, + { + id: 'session-payload-positions-security-axis', + surface: 'GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)', + replacement: + 'the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports ' + + 'and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role ' + + 'scalar reads `user.role`, which is unchanged and still published', + reason: + 'A MEANING change, not a rename: no key moved, so nothing in this entry can be found by ' + + 'grepping for a removed spelling — which is exactly why it needs a ledger row. ' + + '`customSession` built `user.positions` from the better-auth `sys_user.role` scalar ' + + 'split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, ' + + 'and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for ' + + 'custom positions. The Console binds that array straight through as the CEL root ' + + '`current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so ' + + 'an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position ' + + 'answered FALSE for EVERYONE, including the user who genuinely held it. ' + + '⭐ The failure was silent and in the invisible direction: the root was bound and the ' + + 'key was present, so `has(current_user.positions)` was true and CEL raised nothing — ' + + 'the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell ' + + 'and would have shown the button; a successful FALSE shows nothing and reports ' + + 'nothing. The documented example `\'org_admin\' in current_user.positions` kept working ' + + 'throughout, because `org_admin` is the one name that sits on BOTH axes — which is why ' + + 'no example, test or doc could reveal the split. ' + + 'This was a DECLARED contract being violated rather than an ambiguous name: ' + + '`EvalUserSchema` already specified `positions` as "built-in identity names + position ' + + 'names", exposed to "every predicate surface (server formula, server RLS, client UI ' + + 'gates) ... with an identical shape" so a predicate "evaluates identically wherever it ' + + 'is written". `/auth/me/permissions` and every server-side evaluator ' + + '(`ExecutionContext.positions`) already resolved the security axis; the session ' + + 'payload was the one producer that did not, because it derived the value itself ' + + 'instead of asking the authority `resolve-authz-context.ts` reserves that job for. ' + + '⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition ' + + 'rather than an omission: everything the old union contributed beyond the security ' + + 'axis was the `sys_user.role` scalar\'s own tokens, and that scalar is ALREADY ' + + 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word ' + + 'ban carves out for third-party schema. Minting a `roles` array would revive the exact ' + + 'banned identifier `check:role-word` ratchets against, to publish information the ' + + 'payload already carries. Maintainer ruling 2026-09-05 (#15136, director decision ' + + 'batch #39 item 2, verbatim 「同意」): option A, one name, one meaning. ADR-0068 D1/D2, ' + + 'ADR-0090 D3/D5, ADR-0057 D4.', + acceptanceCriteria: + 'No predicate and no client reader treats `current_user.positions` / ' + + '`session.user.positions` as the better-auth role scalar. Audit every authored ' + + '`visible` / `visibleWhen` / RLS predicate that names `current_user.positions` and ' + + 'classify each comparand: a real `sys_position` name, a built-in identity name ' + + '(`platform_admin` / `org_owner` / `org_admin` / `org_member`), or `everyone` needs NO ' + + 'change and starts working where it silently answered FALSE before; a comparand that ' + + 'was only ever a `sys_user.role` token (`user`, and `admin` — note `admin` is NOT a ' + + 'built-in identity name; a membership `admin` is projected as `org_admin`) either ' + + 'moves to `user.role`, or — the supported route — becomes a real position assigned ' + + 'through `sys_user_position`, the governed ADR-0090 D12 channel. ' + + '⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name ' + + 'that exists on ONE side only: `org_admin` sits on both and cannot discriminate, which ' + + 'is precisely how this defect survived its own documented example. Sign in as a user ' + + 'holding a custom position, read `GET /api/v1/auth/get-session`, and assert that ' + + 'position is in `user.positions` and that the payload agrees set-for-set with `GET ' + + '/api/v1/auth/me/permissions`. ' + + 'Assert the absence of a role-scalar token by VALUE rather than by the predicate\'s ' + + 'verdict: a gate that reads `false` cannot tell "the name is gone" from "the name was ' + + 'never there", and both of those from a faulting predicate, which fails OPEN in the ' + + 'shell and renders anyway. A deployment that stored business role names in ' + + '`sys_user.role` instead of assigning positions is the one that must act; a name in ' + + '`sys_member.role` is still projected, so membership-derived names are unaffected.', + }, { id: 'session-user-language-retired', surface: 'api.session.user.language', From 8d16aaf3772a43ff58b137076ca2e2b064a4b096 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:19:32 +0000 Subject: [PATCH 4/9] fix(plugin-auth)!: derive isPlatformAdmin from the posture RUNG, never from a name in positions[] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review found a privilege-escalation path this PR would have introduced. `positions.includes('platform_admin')` is the exact form `resolve-authz-context.ts` forbids at `hasPlatformAdminStanding`: an ADR-0057 D4 `sys_user_position` row may spell that very name, and a platform-RBAC assignment is not the ADR-0068 D2 capability grant. That read was defensible while `positions[]` carried the auth axis, where nothing a tenant writes could put the word there. Moving it to the security axis moved the string into a space a tenant can WRITE: `sys_user_position` is `apiEnabled`, a tenant-level admin passes the ADR-0090 D12 gate outright, and a delegate passes `assertAssignmentWrite`'s `boundSets.every(...)` vacuously for a position carrying no position-bound set. Measured on the real pipeline: a plain org member with such a row got `isPlatformAdmin: true` and passed `judgePlatformAdmin` — the `/admin/*` mount gate — while the rung said false. Pre-fix all three answered false, so the disagreement was introduced here. Both readers of the old "name implies rung" equivalence are re-bound: - `auth-manager.ts` derives the alias from `grants.posture === 'PLATFORM_ADMIN'` on the same envelope, which is byte-for-byte what `hasPlatformAdminStanding` returns, so payload, predicate and gate cannot disagree. - `platform-admin-gate.ts` drops its positions leg. That line is pre-existing, but this PR is what makes it dangerous, so it is in scope here. Its fixture (`admin-ban-endpoints.test.ts`) is migrated per the ruling, which named that file's cases as fixtures to migrate, and gains a case refusing the escalation shape. New pin `session-platform-admin-rung-agreement.test.ts` drives a real session carrying a D4 row spelled `platform_admin` and requires all three predicates to agree on false, with a genuine unscoped `admin_full_access` grant as the control that they agree on true. It states its own population: it does NOT cover the validity-window or catalogue shapes, which are the consolidation suite's — that suite was green throughout the window this escalation was live, which is the lesson. Part of #15136 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../src/admin-ban-endpoints.test.ts | 36 ++- .../plugins/plugin-auth/src/auth-manager.ts | 24 +- .../plugin-auth/src/platform-admin-gate.ts | 18 +- ...sion-platform-admin-rung-agreement.test.ts | 216 ++++++++++++++++++ .../session-positions-security-axis.test.ts | 21 +- 5 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts diff --git a/packages/plugins/plugin-auth/src/admin-ban-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-ban-endpoints.test.ts index ad17e502ea..4fee2cfc48 100644 --- a/packages/plugins/plugin-auth/src/admin-ban-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-ban-endpoints.test.ts @@ -169,16 +169,44 @@ describe('#9652 runAdminUnbanUser', () => { }); describe('#9652 the shared ADR-0068 platform-admin gate', () => { - it('admits a platform admin carrying positions[] and NO role scalar', () => { - // This is the identity a real deployment produces after ADR-0068 D2 — the - // exact shape better-auth refuses. + // [#15136] MIGRATED FIXTURE. This case used to admit on + // `positions: ['user','platform_admin']` with no `isPlatformAdmin` — it pinned + // the array leg `isPlatformAdminUser` no longer has. Under ruling A + // `positions[]` is the security axis, so that name can arrive from a + // tenant-writable ADR-0057 D4 `sys_user_position` row, and admitting on it + // would hand platform-operator routes to a tenant admin. The identity a real + // deployment produces still carries the derived alias, which is the posture + // RUNG; that is what the gate reads and what this now pins. + it('admits a platform admin carrying the derived alias and NO role scalar', () => { const verdict = judgePlatformAdmin({ - user: { id: 'usr_admin', email: 'a@b.c', positions: ['user', 'platform_admin'], role: 'user' }, + user: { + id: 'usr_admin', + email: 'a@b.c', + positions: ['org_member', 'platform_admin', 'everyone'], + isPlatformAdmin: true, + role: 'user', + }, }); expect(verdict.ok).toBe(true); expect(verdict.ok && verdict.actor.id).toBe('usr_admin'); }); + it('⛔ REFUSES a `platform_admin` NAME in positions[] with no rung behind it', () => { + // The escalation shape: a `sys_user_position` row spelling the built-in + // name. `isPlatformAdmin` is absent because the rung said no. Admitting + // here would be the privilege-escalation path — this is the unit-level + // half of the three-way-agreement pin in + // `session-platform-admin-rung-agreement.test.ts`, which drives the same + // shape through a real session. + const verdict = judgePlatformAdmin({ + user: { id: 'usr_member', positions: ['org_member', 'platform_admin', 'everyone'], role: 'user' }, + }); + expect(verdict.ok).toBe(false); + expect(!verdict.ok && verdict.refusal.status).toBe(403); + expect(!verdict.ok && verdict.refusal.body.error.code).toBe('PERMISSION_DENIED'); + expect(isPlatformAdminUser({ id: 'usr_member', positions: ['platform_admin'] })).toBe(false); + }); + it('admits on the derived isPlatformAdmin alias alone', () => { expect(judgePlatformAdmin({ user: { id: 'u', isPlatformAdmin: true } }).ok).toBe(true); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 51ed3df3e4..4a097c477a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -42,7 +42,6 @@ import { } from '@objectstack/types'; import { resolveMembershipLimitOption } from './membership-limit.js'; import { - BUILTIN_IDENTITY_PLATFORM_ADMIN, MEMBERSHIP_ROLE_DELEGATED_ADMIN, } from '@objectstack/spec'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; @@ -3610,11 +3609,21 @@ export class AuthManager { // organization does not answer while the caller operates in another — // the resolver's own rule, now applied to the payload too. // - // `isPlatformAdmin` is derived from the resolved array rather than asked - // separately: ADR-0068 D2 defines it as an alias of `'platform_admin' in - // positions`, and one authority answering both cannot disagree with - // itself. `platform-admin-standing.consolidation.test.ts` PIN 6 pins that - // equivalence shape by shape against the gates. + // `isPlatformAdmin` comes from the posture RUNG on the same envelope, + // never from the array. ⛔ `positions.includes('platform_admin')` is the + // form `resolve-authz-context.ts` explicitly forbids, because an + // ADR-0057 D4 `sys_user_position` row MAY SPELL THAT VERY NAME and a + // platform-RBAC assignment is not the ADR-0068 D2 capability grant. + // Under ruling A that stopped being theoretical: moving `positions` to + // the security axis moved the string `platform_admin` into a space a + // tenant admin can WRITE (`sys_user_position` is `apiEnabled`), so an + // array read here would have let a tenant mint platform standing and + // pass the `/admin/*` mount gate. `grants.posture === 'PLATFORM_ADMIN'` + // is byte-for-byte what `hasPlatformAdminStanding` returns, so the + // payload, that predicate and `judgePlatformAdmin` cannot disagree. + // ADR-0068 D2 defines the alias as `'platform_admin' in roles`; that + // wording predates D4 rows being able to spell built-in names, and + // core's later ⛔ is the specific rule. // // Fail CLOSED on an unreadable grant store, matching what both halves of // the old derivation already did (`isPlatformAdminUserId` returns false @@ -3622,11 +3631,13 @@ export class AuthManager { // than swallowed — an empty `positions[]` hides UI, and this card is // about exactly that going unannounced. let positions: string[] = []; + let platformAdmin = false; try { const grants = await resolveUserAuthzGrants(dataEngine as any, user.id, { tenantId: (session as any)?.activeOrganizationId ?? undefined, }); positions = grants.positions; + platformAdmin = grants.posture === 'PLATFORM_ADMIN'; } catch (err: any) { console.warn( '[auth] could not resolve authorization grants for the session payload; ' @@ -3634,7 +3645,6 @@ export class AuthManager { err?.message ?? String(err), ); } - const platformAdmin = positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN); // ADR-0069 — authentication-policy gate posture (password expiry, // enforced MFA). Computed only when a gate feature is enabled (else diff --git a/packages/plugins/plugin-auth/src/platform-admin-gate.ts b/packages/plugins/plugin-auth/src/platform-admin-gate.ts index 635e882dfd..409d8e1b26 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-gate.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-gate.ts @@ -58,9 +58,20 @@ export const PLATFORM_ADMIN_REFUSAL_MESSAGES: Readonly /** * Is this session user a platform admin under ADR-0068 D2? * - * Reads the canonical signals `customSession` contributes — the derived - * `isPlatformAdmin` alias and `platform_admin` in `positions[]` — plus the - * legacy `role` scalar as the back-compat fallback described above. + * Reads the derived `isPlatformAdmin` alias `customSession` contributes — which + * is the ADR-0095 D3 posture RUNG, not a name — plus the legacy `role` scalar as + * the back-compat fallback described above. + * + * ⛔ [#15136] It does NOT read `platform_admin` from `positions[]`, and that leg + * was REMOVED rather than merely left unused. `positions[]` is the security axis + * (ruling A), so it carries ADR-0057 D4 `sys_user_position` names — and + * `sys_user_position` is `apiEnabled`, writable by a tenant admin outright and by + * a delegate whose bound-set test passes vacuously for a position carrying no + * position-bound set. A row spelled `platform_admin` would therefore have made + * this predicate — and so the `/admin/*` mount gate below — admit a principal + * that `hasPlatformAdminStanding` refuses. The array read was defensible only + * while `positions` carried the auth axis, where nothing a tenant writes could + * put that word in it; it is the exact form `resolve-authz-context.ts` forbids. * * Exported separately from `judgePlatformAdmin` so a caller that already holds * a session (a test, a hook) can ask the question without building an @@ -70,7 +81,6 @@ export function isPlatformAdminUser(sessionUser: unknown): boolean { const u = sessionUser as Record | null | undefined; if (!u) return false; if (u.isPlatformAdmin === true) return true; - if (Array.isArray(u.positions) && u.positions.includes('platform_admin')) return true; return u.role === 'admin'; } diff --git a/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts b/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts new file mode 100644 index 0000000000..fcc1604f14 --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Three-way agreement on platform-admin standing (#15136, contract review). +// +// Three predicates answer "is this principal a platform operator?", and all +// three are reachable from one request: +// +// 1. `session.user.isPlatformAdmin` — the payload alias `customSession` emits; +// 2. `judgePlatformAdmin(session)` — the gate on the `/admin/*` mount +// (`auth-plugin.ts` `gateAdmin`), fed by that same payload; +// 3. `hasPlatformAdminStanding(engine, userId)` — the ADR-0095 D3 posture +// rung, used by `/sso/register` and `/admin/impersonate-user`. +// +// ⭐ WHY THIS SUITE EXISTS, and what it is NOT. Ruling A moved `positions[]` to +// the security axis, which carries ADR-0057 D4 `sys_user_position` names. That +// table is `apiEnabled`: a tenant-level admin passes the ADR-0090 D12 gate +// outright, and a delegate holding `manageAssignments` passes +// `assertAssignmentWrite`'s `boundSets.every(...)` VACUOUSLY for a position that +// carries no position-bound set. So a tenant can cause the string +// `platform_admin` to appear in their own `positions[]`. +// +// Any predicate that reads that NAME therefore stopped being an authorization +// answer the moment ruling A landed — which is exactly what +// `resolve-authz-context.ts` warns about at `hasPlatformAdminStanding`: +// +// ⛔ Read the RUNG — never `positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)`. +// The positions list is wider on purpose: an ADR-0057 D4 `sys_user_position` +// row may spell that very name, and a platform-RBAC assignment is not the D2 +// capability grant. +// +// ⚠️ POPULATION OF THIS PIN, stated because a pin proves only what it covers: +// it covers the D4-spelled-built-in-name shape and a genuine-grant control. It +// does NOT cover the ADR-0091 validity window, the ADR-0049 catalogue flag, or +// the catalogue-page shapes — those are `platform-admin-standing.consolidation. +// test.ts`'s population, and that suite passing is NOT evidence about this one. +// It was green throughout the window in which this escalation was live. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { hasPlatformAdminStanding } from '@objectstack/core'; +import { AuthManager } from './auth-manager'; +import { judgePlatformAdmin, isPlatformAdminUser } from './platform-admin-gate'; +// Sibling-test import for the engine double — the precedent documented at +// `platform-admin-standing.consolidation.test.ts`'s own import of it. +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-15136'; +const BASE = 'http://localhost:3000/api/v1/auth'; +const ORG = 'org_rung'; +const PS_ADMIN = 'ps_admin_full_access'; + +const makeManager = (engine: any) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + } as any); + +const signUp = (manager: AuthManager, email: string, name: string) => { + inviteForAudienceGate(manager, email); + return manager.handleRequest( + new Request(`${BASE}/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name }), + }), + ); +}; + +const signIn = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request(`${BASE}/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD }), + }), + ); + +const bearerFrom = (response: Response): string => { + const token = response.headers.get('set-auth-token'); + if (!token) throw new Error('no set-auth-token on the response'); + return token; +}; + +const userIdFor = (engine: any, email: string): string => { + const row = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +/** The whole SESSION object, not just `user` — `judgePlatformAdmin` takes the envelope. */ +const sessionFor = async (manager: AuthManager, bearer: string) => { + const auth: any = await manager.getAuthInstance(); + return auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${bearer}` }) }) + .catch(() => null); +}; + +/** + * `shape: 'name-only'` — the escalation: a plain org member plus a D4 row + * SPELLING the built-in name, and no capability grant behind it. + * `shape: 'genuine'` — the control: a real unscoped `admin_full_access` grant. + */ +const arrange = async (shape: 'name-only' | 'genuine') => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + await signUp(manager, 'subject@example.com', 'Subject'); + const userId = userIdFor(engine, 'subject@example.com'); + + await engine.insert('sys_organization', { id: ORG, name: 'Rung Org', slug: 'rung-org' }); + await engine.insert('sys_member', { organization_id: ORG, user_id: userId, role: 'member' }); + + if (shape === 'name-only') { + // Exactly what a tenant admin can write through the `apiEnabled` + // `sys_user_position` surface: a position whose NAME is the built-in. + await engine.insert('sys_position', { id: 'pos_pa', name: 'platform_admin', label: 'Platform Admin' }); + await engine.insert('sys_user_position', { + user_id: userId, + position: 'platform_admin', + organization_id: null, + }); + } else { + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + await engine.insert('sys_user_permission_set', { + user_id: userId, + permission_set_id: PS_ADMIN, + organization_id: null, + }); + } + + const bearer = bearerFrom(await signIn(manager, 'subject@example.com')); + return { engine, manager, userId, bearer }; +}; + +/** All three answers, read off one arranged principal. */ +const verdicts = async (shape: 'name-only' | 'genuine') => { + const { engine, manager, userId, bearer } = await arrange(shape); + const session = await sessionFor(manager, bearer); + return { + positions: (session?.user?.positions ?? []) as string[], + alias: session?.user?.isPlatformAdmin === true, + gate: judgePlatformAdmin(session).ok, + rung: await hasPlatformAdminStanding(engine as any, userId), + }; +}; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('a `sys_user_position` row spelling `platform_admin` confers NO platform standing', () => { + it('the name IS in positions[] — the premise, without which the rest is vacuous', async () => { + const v = await verdicts('name-only'); + expect(v.positions, JSON.stringify(v.positions)).toContain('platform_admin'); + }); + + it('all three answers are FALSE, and they agree with each other', async () => { + const v = await verdicts('name-only'); + expect( + { alias: v.alias, gate: v.gate, rung: v.rung }, + `positions=${JSON.stringify(v.positions)}`, + ).toEqual({ alias: false, gate: false, rung: false }); + }); + + it('the /admin/* mount gate refuses it 403 PERMISSION_DENIED', async () => { + const { manager, bearer } = await arrange('name-only'); + const verdict = judgePlatformAdmin(await sessionFor(manager, bearer)); + expect(verdict.ok).toBe(false); + expect(!verdict.ok && verdict.refusal.status).toBe(403); + expect(!verdict.ok && verdict.refusal.body.error.code).toBe('PERMISSION_DENIED'); + }); + + it('`isPlatformAdminUser` refuses the payload directly', async () => { + const { manager, bearer } = await arrange('name-only'); + const session = await sessionFor(manager, bearer); + expect(isPlatformAdminUser(session?.user)).toBe(false); + }); +}); + +describe('CONTROL — a genuine unscoped admin_full_access grant still admits', () => { + it('all three answers are TRUE, and they agree with each other', async () => { + const v = await verdicts('genuine'); + expect( + { alias: v.alias, gate: v.gate, rung: v.rung }, + `positions=${JSON.stringify(v.positions)}`, + ).toEqual({ alias: true, gate: true, rung: true }); + }); + + it('the derived built-in is projected into positions[] as before', async () => { + const v = await verdicts('genuine'); + expect(v.positions).toContain('platform_admin'); + }); +}); + +describe('the two shapes are INDISTINGUISHABLE by name and separable only by the rung', () => { + it('both carry `platform_admin` in positions[]; only the granted one has standing', async () => { + const escalation = await verdicts('name-only'); + const genuine = await verdicts('genuine'); + + // Identical on the axis a name-reading predicate would consult … + expect(escalation.positions).toContain('platform_admin'); + expect(genuine.positions).toContain('platform_admin'); + + // … and opposite on the axis that actually decides. This is the whole + // finding: `positions.includes('platform_admin')` cannot tell these apart. + expect(escalation.rung).toBe(false); + expect(genuine.rung).toBe(true); + expect(escalation.gate).toBe(false); + expect(genuine.gate).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts index b0e6b687f7..cdba0ad724 100644 --- a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -107,6 +107,14 @@ const payloadFor = async (manager: AuthManager, bearer: string) => { return session?.user ?? null; }; +/** The whole envelope — `session.activeOrganizationId` is the scoping input. */ +const sessionEnvelopeFor = async (manager: AuthManager, bearer: string) => { + const auth: any = await manager.getAuthInstance(); + return auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${bearer}` }) }) + .catch(() => null); +}; + /** * Evaluate a predicate exactly as a shell gate does: bind the session payload * as `current_user` and run it through the real CEL engine. @@ -222,12 +230,21 @@ describe('the predicate root stays bound — the failure mode was a FALSE, never // against a literal list, so it keeps holding as that authority grows. // ─────────────────────────────────────────────────────────────────────────── describe('the payload agrees with the ONE authorization authority, set for set', () => { - it('matches `resolveUserAuthzGrants` for the position holder', async () => { + it('matches `resolveUserAuthzGrants` for the position holder, SCOPED to the session org', async () => { const { engine, manager, holderId, holderBearer } = await arrange(); const { resolveUserAuthzGrants } = await import('@objectstack/core'); const payload = await payloadFor(manager, holderBearer); - const grants = await resolveUserAuthzGrants(engine as any, holderId); + // [#15136 review] Scoped, not unscoped. The payload is tenant-scoped to the + // session's active organization exactly as `/auth/me/permissions` and + // `ExecutionContext.positions` are, so an UNSCOPED reference only agrees by + // accident on a single-org fixture — and would keep agreeing if the scoping + // regressed. Reading the org off the session makes the reference follow the + // subject rather than restate the fixture. + const session = await sessionEnvelopeFor(manager, holderBearer); + const grants = await resolveUserAuthzGrants(engine as any, holderId, { + tenantId: (session as any)?.session?.activeOrganizationId ?? undefined, + }); expect([...(payload?.positions ?? [])].sort()).toEqual([...grants.positions].sort()); // Not vacuous: the authority really did resolve the position. From f4d0cfb500acf094056fc95022db693e51a4e277 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:25:49 +0000 Subject: [PATCH 5/9] docs(changeset): carve out the no-active-org membership projection, and correct the role-word attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections the contract review's standing guard asked for. The changeset claimed "membership-derived names are unaffected" without qualification. Measured: for a session carrying no active organization the resolver projects every membership, so those names are now ADDED where the old derivation contributed none — `[]` becomes `[org_admin, everyone]`. Pinned rather than merely asserted; the first attempt at that pin failed because the default fixture DOES stamp an active org, which is a different branch. The deviation rationale attributed the identifier ban to `check:role-word`. That gate ratchets the reserved word in docs prose; the identifier ban over authored metadata lives in `packages/lint`. A TypeScript payload key trips neither mechanically until documented, so the ADR-level prohibition is what rules — the conclusion is unchanged, the wording was wrong. Also names the new three-way-agreement pin beside PIN 6, and scopes PIN 3's reference to the session's active organization: unscoped, it agreed only because the fixture has one org, and a scoping regression would have passed it. Part of #15136 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...session-payload-positions-security-axis.md | 41 +++++++++++++++---- .../session-positions-security-axis.test.ts | 38 +++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/.changeset/session-payload-positions-security-axis.md b/.changeset/session-payload-positions-security-axis.md index 938573e26b..97ca7023fb 100644 --- a/.changeset/session-payload-positions-security-axis.md +++ b/.changeset/session-payload-positions-security-axis.md @@ -59,21 +59,44 @@ consumer. Everything the old union contributed beyond the security axis was the `sys_user.role` scalar's own tokens — and that scalar is **already published, unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban carves out, for third-party schema this platform does not own). Minting a -`roles` array would revive the exact banned identifier `check:role-word` -ratchets against, to publish information the payload already carries. A -consumer that wants the better-auth role reads `user.role`. +`roles` array would revive that banned word to publish information the payload +already carries. (Precisely: `check:role-word` ratchets the reserved word in +`content/docs` and `skills/` PROSE, while the identifier ban over authored +metadata lives in `packages/lint`; a TypeScript payload key trips neither +mechanically until it is documented. The ADR-level prohibition is what rules +here, not a gate that would have caught it.) A consumer that wants the +better-auth role reads `user.role`. **What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); `platform_admin` still derives from the unscoped `admin_full_access` grant with -its ADR-0091 validity window and ADR-0049 active flag intact — pinned shape for -shape against both gates by `platform-admin-standing.consolidation.test.ts` -PIN 6, which passes unchanged. +its ADR-0091 validity window and ADR-0049 active flag intact — +`platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over +those shapes. + +⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** +`positions.includes('platform_admin')` is the form +`resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` +row may spell that very name — and this card is what made that reachable, by +moving `positions` onto an axis a tenant admin can write. Reading the name would +have let a tenant mint platform standing and pass the `/admin/*` mount gate. +`platform-admin-gate.ts` drops its positions leg for the same reason. +`session-platform-admin-rung-agreement.test.ts` requires the payload alias, that +gate and `hasPlatformAdminStanding` to agree, driven with such a row present and +a genuine grant as the control. **Upgrade.** If you gate on the better-auth role scalar, read `user.role` instead of looking for its tokens in `user.positions`. Predicates written against real position names, built-in identity names, or `everyone` need no change — they start working. Deployments that stored business role names in `sys_user.role` rather than assigning positions should assign them through -`sys_user_position` (the governed ADR-0090 D12 channel); a name in -`sys_member.role` is still projected, so membership-derived names are -unaffected. +`sys_user_position` (the governed ADR-0090 D12 channel). + +A name in `sys_member.role` is still projected, **with one carve-out**: for a +session carrying NO active organization, membership names are now *added*, from +**every** membership the user holds — the resolver projects them all when no +tenant scopes it, where the old derivation contributed none. Measured on the +real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): +`[]` before, `[org_admin, everyone]` after, pinned by +`session-positions-security-axis.test.ts`. With an active organization the +projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so +membership-derived names there are unchanged. diff --git a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts index cdba0ad724..64f2bf06a9 100644 --- a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -256,6 +256,44 @@ describe('the payload agrees with the ONE authorization authority, set for set', const user = await payloadFor(manager, holderBearer); expect(user?.positions, JSON.stringify(user?.positions)).toContain('everyone'); }); + + // [#15136 review — the standing guard's carve-out, measured rather than asserted + // in prose] With NO active organization the resolver projects EVERY membership + // (`resolve-authz-context.ts:815`), so membership-derived names are ADDED to a + // payload that previously carried none of them. That is a behaviour change in + // its own right and the changeset now names it; this pins the shape it names. + it('projects membership names even with no active organization on the session', async () => { + // `autoActiveOrganization: false` is the opt-out the manager documents; the + // default stamps an active org, which is a DIFFERENT branch (and the one the + // rest of this suite exercises). + const engine = createMemoryEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + autoActiveOrganization: false, + } as any); + + await signUp(manager, 'noorg@example.com', 'No Active Org'); + const userId = userIdFor(engine, 'noorg@example.com'); + await engine.insert('sys_organization', { id: ORG, name: 'Card Org', slug: 'card-org' }); + await engine.insert('sys_member', { organization_id: ORG, user_id: userId, role: 'admin' }); + + const bearer = bearerFrom(await signIn(manager, 'noorg@example.com')); + const envelope = await sessionEnvelopeFor(manager, bearer); + + // The population of this pin, stated: it is about the no-active-org branch. + const activeOrg = (envelope as any)?.session?.activeOrganizationId ?? null; + expect(activeOrg, `expected no active org, got ${activeOrg}`).toBeFalsy(); + + // The resolver projects EVERY membership when no tenant scopes it + // (`resolve-authz-context.ts:815`), so these names are ADDED to a payload + // that carried none of them before this card. That is the behaviour change + // the changeset's carve-out names. + const positions = (envelope as any)?.user?.positions ?? []; + expect(positions, JSON.stringify(positions)).toContain('org_admin'); + expect(positions, JSON.stringify(positions)).toContain('everyone'); + }); }); // ─────────────────────────────────────────────────────────────────────────── From b1e99af2a5d929fda6a4005cf8f11b66d569f345 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:37:26 +0000 Subject: [PATCH 6/9] test(plugin-auth): migrate the /admin/sso/* bridge fixtures off the removed positions leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same species as the fixture the ruling named: the platform-admin session shape stood for a real payload back when the gate admitted on the `platform_admin` NAME. It carries the rung-derived alias now, because that is what the gate reads. `positions` keeps the name, since a genuine platform admin really does have it projected — which is exactly why the name alone cannot be the signal. Adds the escalation shape as a refused case on all four bridges. Without it, restoring the array leg would reopen these operator routes and every existing case would still pass. Part of #15136 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../src/admin-sso-bridge-gate.test.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts index 489d97855b..4cc2a364c4 100644 --- a/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts +++ b/packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts @@ -135,10 +135,35 @@ const makeSsoVendor = () => describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate before delegating', () => { // Session shapes are the exact ones platform-admin-gate.ts is unit-tested // for; here they drive the MOUNTED routes so the pin is on the wiring. + // + // [#15136] MIGRATED. The platform-admin shape used to be + // `positions: ['user','platform_admin']` with no `isPlatformAdmin` — it stood + // for a real payload back when the gate admitted on that NAME. It no longer + // does, and deliberately: `positions[]` is now the security axis, so the name + // can arrive from a tenant-writable ADR-0057 D4 `sys_user_position` row, and + // admitting on it would hand these operator routes to a tenant admin. The + // payload a real deployment produces carries the derived alias, which is the + // ADR-0095 D3 posture rung; that is what the gate reads, so that is what the + // fixture must carry. `positions` keeps the name because a genuine platform + // admin really does have it projected — which is exactly why the name alone + // cannot be the signal. const SESSIONS: Record = { member: { user: { id: 'usr_member', positions: ['user'], role: 'user' } }, 'org-admin': { user: { id: 'usr_orgadmin', positions: ['user', 'org_admin', 'org_owner'], role: 'user' } }, - 'platform-admin': { user: { id: 'usr_admin', positions: ['user', 'platform_admin'], role: 'user' } }, + 'platform-admin': { + user: { + id: 'usr_admin', + positions: ['user', 'platform_admin'], + isPlatformAdmin: true, + role: 'user', + }, + }, + // The escalation shape, refused: the built-in NAME with no rung behind it. + // Without this, restoring the array leg would turn these bridges back into + // an open door and every case above would still pass. + 'position-named-platform-admin': { + user: { id: 'usr_tenant', positions: ['org_member', 'platform_admin'], role: 'user' }, + }, }; let app: Hono; @@ -189,6 +214,19 @@ describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate be expect(delegated).not.toHaveBeenCalled(); }); + it(`${path}: a \`platform_admin\` POSITION NAME with no rung → 403, never delegated`, async () => { + // [#15136] The escalation shape. `sys_user_position` is `apiEnabled`, so a + // tenant admin can put this exact name in their own `positions[]` now that + // the array is the security axis. The gate reads the rung-derived alias, + // which is absent here, so these operator routes stay shut. + delegated.mockClear(); + const res = await fire(app, path, { session: 'position-named-platform-admin' }); + const body: any = await res.json(); + expect(res.status).toBe(403); + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(delegated).not.toHaveBeenCalled(); + }); + it(`${path}: a platform admin passes the gate and IS delegated into better-auth`, async () => { delegated.mockClear(); const res = await fire(app, path, { session: 'platform-admin' }); From b65fafc81ac1698934ea8e2092957f8556f8b32a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:04:00 +0000 Subject: [PATCH 7/9] fix(plugin-auth): widen the customSession dataEngine double to its seam's arity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Type Check · workspace` (run 33978981028) failed `@objectstack/plugin-auth#typecheck` on one ARRIVED signature: TS2554 "Expected 1 arguments, but got 2" at src/auth-manager.test.ts:3543, where the `sys_user_position` case overrides `makeDataEngine`'s `find` and delegates `inner(object, q)` to a double declared one-parameter, `vi.fn(async (object: string) => ...)`. CHOSE (a) widen the double; NOT (b) drop the second argument at the delegation. The double stands in for `IDataEngine.find(objectName, query?, options?)` (packages/spec/src/contracts/data-engine.ts:259), and every production read that reaches this fake goes through `resolve-authz-context.ts` `tryFind`, which always calls `ql.find(object, { where, limit, context })` — two arguments, never one. (b) would also have typechecked, by making the delegation call `inner(object)`. That is worse: it fixes the compiler by teaching the double a call shape production never produces. `find` here is a `vi.fn`, so its recorded calls are assertable, and the tenant-scoped `context` `tryFind` threads is exactly the kind of claim a future test would assert over `engine.find.mock.calls` — against a shape that cannot occur. A delegation that silently drops an argument the seam passes is how a double stops expressing the condition it exists to test. Scope: this one type error. No production file touched — the round-2 Clause-(2) contract review's design stands unmodified. Nothing added to test-typecheck-debt.json (MAINTAINER-ONLY, ratchets down only); the gate reports the ledger unchanged at 10 file(s) / 94 error(s) / 23 pinned signature(s). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- packages/plugins/plugin-auth/src/auth-manager.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 615bd9ceaf..15161954d4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3439,7 +3439,12 @@ describe('AuthManager', () => { // platform admin (a sys_user_permission_set row pointing at the // admin_full_access permission set with no organization scope). const makeDataEngine = (opts: { platformAdmin: boolean }) => ({ - find: vi.fn(async (object: string) => { + // Two parameters because the seam has two: `IDataEngine.find(objectName, + // query?, options?)`, and every production read reaching this double goes + // through `resolve-authz-context.ts` `tryFind`, which always calls + // `ql.find(object, { where, limit, context })`. Declaring one parameter + // would force a delegating override to drop an argument the seam passes. + find: vi.fn(async (object: string, _query?: any) => { if (object === 'sys_user_permission_set') { return opts.platformAdmin ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', organization_id: null }] From 87040f33285e31e8ab8504bdb4a0c83ff12ff908 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:36:19 +0000 Subject: [PATCH 8/9] test(plugin-auth): satisfy check:test-source-alias on the positions-axis pin `check:test-source-alias` is the only step making `Lint & Repo Gates` red on this PR: job 101340631783 fails at step 130 and skips steps 131-148, so the merge-driver gate that reds other PRs on this seat never runs here. Base-branch control: the same gate exits 0 on `origin/main` f7db8f4fd, and the test file is absent from that ref, so the redness is this PR's. Both findings are remedied exactly as the gate's own text dictates. 1. `session-positions-security-axis.test.ts:235` paid `import('@objectstack/core')` inside an `it()` body -- a CLOCKED window, and `@objectstack/core` is one of the specifiers this package still resolves through `dist/`. A module-top side-effect import pays that transform during collection, which vitest clocks against nothing. The dynamic call stays exactly where it is, and no timeout was widened -- widening relocates the cliff to the next heavier shard. 2. `@objectstack/formula` was a NEW unaliased artifact import for this package (`celEngine`, the real CEL engine the pins evaluate `current_user.positions` with). One anchored entry in the package's `vitest.config.ts` resolves it to `packages/formula/src/index.ts`, which exists in the checkout. `KNOWN_UNALIASED_TEST_IMPORTS` is shrink-only, so no registry row was added or widened; plugin-auth's existing entry is untouched. Verified: `check:test-source-alias` --self-test and the real run both exit 0, the real run reporting the same counts as the base-branch control (72 packages scanned / 61 registered / 49 published subpaths). `pnpm --filter @objectstack/plugin-auth test` still 102 files / 2142 tests passed, and the package's full `typecheck` task is green with its test-typecheck ledger unchanged at 10 file(s) / 94 error(s) / 23 pinned signature(s). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../src/session-positions-security-axis.test.ts | 10 ++++++++++ packages/plugins/plugin-auth/vitest.config.ts | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts index 64f2bf06a9..e74a7e2edb 100644 --- a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -45,6 +45,16 @@ import { AuthManager } from './auth-manager'; import { createMemoryEngine } from './impersonation-bearer-rotation.test'; import { inviteForAudienceGate } from './audience-gate-test-support'; +// [#10126] Pay the first transform of this dist-resolved workspace dep at MODULE +// LOAD. `@objectstack/core` is reached below only through a dynamic `import()` +// inside an `it()` body (the `resolveUserAuthzGrants` parity pin), and vitest +// clocks test bodies and hooks while collection is clocked against nothing. See +// `scripts/check-test-source-alias.mjs` (the clocked-window rule) and #10115 / +// PR #10120, where the same shape cost 30 ejected merge-queue builds in one +// night. The dynamic call stays where it is -- this only decides WHERE the +// first load is paid. +import '@objectstack/core'; + const SECRET = 'test-secret-at-least-32-chars-long!!'; const PASSWORD = 'S3cure!Passw0rd-15136'; const BASE = 'http://localhost:3000/api/v1/auth'; diff --git a/packages/plugins/plugin-auth/vitest.config.ts b/packages/plugins/plugin-auth/vitest.config.ts index db390bdd67..d846c74a14 100644 --- a/packages/plugins/plugin-auth/vitest.config.ts +++ b/packages/plugins/plugin-auth/vitest.config.ts @@ -37,6 +37,17 @@ export default defineConfig({ find: /^@objectstack\/service-messaging$/, replacement: path.resolve(here, '../../services/service-messaging/src/index.ts'), }, + // [#15136] `session-positions-security-axis.test.ts` takes a VALUE + // import on `@objectstack/formula` (`celEngine`) — the real CEL engine + // the server evaluates formulas and RLS with. Same reason and the same + // anchoring as the two entries above. `check:test-source-alias` reports + // a new artifact import here as a finding and dictates this remedy: its + // `KNOWN_UNALIASED_TEST_IMPORTS` registry is ⛔ SHRINK-ONLY, so aliasing + // the specifier is the fix and a new registry row is not. + { + find: /^@objectstack\/formula$/, + replacement: path.resolve(here, '../../formula/src/index.ts'), + }, ], }, }); From 9fb71ee56f7c676357f0d2c1469fe88021417fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:56:13 +0000 Subject: [PATCH 9/9] test(plugin-auth): resolve @objectstack/formula's TYPES to source too The sibling gate `check:type-source-resolution` is red on the same specifier, and it is red at the PR head `b65fafc81` as well -- measured with both files of the alias fix-up reverted in place under a trap, then restored byte-identical. It was invisible only because `Lint & Repo Gates` halts at step 130 (`Test-source alias gate`) and skips 131-148; this gate is step 132, so unblocking 130 walks the job straight into it. Same defect class, same specifier, same round-1 import (`celEngine`), and the remedy the gate dictates: ONE `paths` rule for the bare name in the package's `tsconfig.json`, copied in shape from the `@objectstack/service-messaging` rule already beside it. `@objectstack/formula` publishes no subpath in its `exports`, so a subpath rule would match nothing on disk and fall back to node resolution. `tsconfig.test.json` inherits this map rather than declaring its own, which is what carries the rule to the test layer that reaches the specifier. Measured rather than assumed, because #12570 records this exact rule billing another package's source diagnostics into this ledger: - `check:type-source-resolution` 0 -- "125 tsc program(s) across 78 packages scanned; 61 registered as still resolving a workspace dep's types through `dist/`". - `pnpm --filter @objectstack/plugin-auth typecheck` 0, with `check:test-typecheck` reporting the ledger UNCHANGED at 10 file(s) / 94 error(s) / 23 pinned signature(s) -- nothing was added to it. - `tsc -p tsconfig.test.json --listFiles`: 15 `packages/formula/src/*.ts` files now in the program, 0 files under `packages/formula/dist/`, program 1243 -> 1257 files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- packages/plugins/plugin-auth/tsconfig.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/plugins/plugin-auth/tsconfig.json b/packages/plugins/plugin-auth/tsconfig.json index 7d7b6ffe29..d3f5b1eb31 100644 --- a/packages/plugins/plugin-auth/tsconfig.json +++ b/packages/plugins/plugin-auth/tsconfig.json @@ -27,6 +27,18 @@ // — that package declares none — and a `paths` target matching nothing on // disk would silently fall back to node resolution. "paths": { + // [#15136] `@objectstack/formula` is imported as a VALUE by + // `src/session-positions-security-axis.test.ts` (`celEngine`, the real CEL + // engine the server evaluates formulas and RLS with — the pins drive it + // rather than a fixture because the defect lived in the seam). Same + // mechanism as the entry below and the same ONE-rule shape: the test layer + // reaches this specifier through `tsconfig.test.json`, which inherits this + // map rather than declaring its own, and `@objectstack/formula` publishes + // no subpath in its `exports`, so a subpath rule would match nothing on + // disk and fall back to node resolution. Without it tsc resolves the + // specifier to `dist/index.d.ts` — a BUILD ARTIFACT — which + // `check:type-source-resolution` refuses. + "@objectstack/formula": ["../../formula/src/index.ts"], "@objectstack/service-messaging": ["../../services/service-messaging/src/index.ts"] } },