diff --git a/.changeset/session-payload-positions-security-axis.md b/.changeset/session-payload-positions-security-axis.md new file mode 100644 index 0000000000..97ca7023fb --- /dev/null +++ b/.changeset/session-payload-positions-security-axis.md @@ -0,0 +1,102 @@ +--- +"@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 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 — +`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, **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/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/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/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/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' }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 7decbd1f23..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 }] @@ -3474,52 +3479,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/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e0e47f9e18..4a097c477a 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,8 +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'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; @@ -3546,65 +3544,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 @@ -3612,14 +3580,71 @@ 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` 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 + // 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[] = []; + 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; ' + + 'positions[] is empty and position-gated UI will be hidden:', + err?.message ?? String(err), + ); + } // 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 new file mode 100644 index 0000000000..e74a7e2edb --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -0,0 +1,322 @@ +// 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'; + +// [#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'; +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; +}; + +/** 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. + * + * 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, 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); + // [#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. + 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'); + }); + + // [#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'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 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/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"] } }, 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'), + }, ], }, }); 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)'), 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', 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