Skip to content

Commit 54417dd

Browse files
committed
test(plugin-auth): migrate the four fixtures that pinned the removed auth-axis derivation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 571e7ef commit 54417dd

4 files changed

Lines changed: 176 additions & 14 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136)
7+
8+
<!-- adr-0087: registered session-payload-positions-security-axis -->
9+
10+
**BREAKING** meaning change on a published payload — `user.positions` in
11+
`GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's
12+
launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on
13+
#15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one
14+
name, one meaning.
15+
16+
`customSession` built the array from the better-auth `sys_user.role` scalar
17+
split on commas, plus the active membership mapped to `org_*`, plus
18+
`platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057
19+
D4 table that is the source of truth for custom positions. The Console binds
20+
that array straight through as the CEL root `current_user`, so an
21+
`action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed
22+
by a business position answered FALSE for **everyone**, including the user who
23+
genuinely held it.
24+
25+
⭐ It failed **silently and in the invisible direction**: the root was bound and
26+
the key was present, so `has(current_user.positions)` was true, CEL raised
27+
nothing, and the predicate simply returned FALSE. A predicate that *faults*
28+
fails OPEN in the shell and would have shown the button; a successful FALSE
29+
shows nothing and reports nothing. The documented example
30+
(`'org_admin' in current_user.positions`) kept working throughout, because
31+
`org_admin` is the one name that sits on **both** axes.
32+
33+
This was a **declared** contract being violated, not an ambiguous name:
34+
`EvalUserSchema` already specified `positions` as "built-in identity names +
35+
position names", exposed to "every predicate surface (server formula, server
36+
RLS, client UI gates) ... with an identical shape" so that a predicate
37+
"evaluates identically wherever it is written". `/auth/me/permissions` and
38+
every server-side evaluator (`ExecutionContext.positions`) already resolved the
39+
security axis; only the session payload did not.
40+
41+
**What changes**
42+
43+
- `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**,
44+
not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE
45+
authority (`core/security/resolve-authz-context.ts`, whose header forbids
46+
every entry point from re-reading the `sys_*` grant tables itself), scoped to
47+
the session's active organization. The payload therefore carries the
48+
`sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and
49+
agrees with `/auth/me/permissions` set for set. Same move
50+
`isPlatformAdminUserId` made at #10348.
51+
- `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as
52+
an alias of `'platform_admin' in positions`), so one authority answers both.
53+
- `packages/spec``EvalUserSchema` states which axis `positions` is, and
54+
states that the better-auth role scalar is not it.
55+
56+
**No key is renamed, and none is added.** The ruling anticipated a renamed
57+
auth-role array; measured against the tree, it has no content to carry and no
58+
consumer. Everything the old union contributed beyond the security axis was the
59+
`sys_user.role` scalar's own tokens — and that scalar is **already published,
60+
unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban
61+
carves out, for third-party schema this platform does not own). Minting a
62+
`roles` array would revive the exact banned identifier `check:role-word`
63+
ratchets against, to publish information the payload already carries. A
64+
consumer that wants the better-auth role reads `user.role`.
65+
66+
**What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2);
67+
`platform_admin` still derives from the unscoped `admin_full_access` grant with
68+
its ADR-0091 validity window and ADR-0049 active flag intact — pinned shape for
69+
shape against both gates by `platform-admin-standing.consolidation.test.ts`
70+
PIN 6, which passes unchanged.
71+
72+
**Upgrade.** If you gate on the better-auth role scalar, read `user.role`
73+
instead of looking for its tokens in `user.positions`. Predicates written
74+
against real position names, built-in identity names, or `everyone` need no
75+
change — they start working. Deployments that stored business role names in
76+
`sys_user.role` rather than assigning positions should assign them through
77+
`sys_user_position` (the governed ADR-0090 D12 channel); a name in
78+
`sys_member.role` is still projected, so membership-derived names are
79+
unaffected.

content/docs/references/identity/eval-user.mdx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,29 @@ it is written.
1818
`roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion"
1919
behavior is the footgun this eliminates.
2020

21+
## Which axis `positions` is — and which it is not (#15136)
22+
23+
It is the **security** axis: the built-in identity names plus the
24+
`sys_position` names the principal actually holds through `sys_user_position`
25+
(ADR-0057 D4) and their active membership, plus the `everyone` anchor
26+
(ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and
27+
`resolveUserAuthzGrants` resolves. Every surface derives it from that one
28+
authority, so the "identical shape" promise above is a property of the
29+
producer, not a convention producers are asked to honour.
30+
31+
⛔ It is **not** the better-auth `sys_user.role` scalar. That scalar is an
32+
authentication-layer field on a table this platform does not own; it stays
33+
published, unchanged, as `user.role` (the one exception ADR-0090 D3's word
34+
ban carves out), and a consumer that genuinely wants it reads it there.
35+
36+
⚠️ The session payload used to union the scalar into this array and omit the
37+
`sys_user_position` names entirely, which made a position-narrowed client
38+
gate answer FALSE for its own holder — silently, since the root and the key
39+
were both bound and CEL raised nothing. The documented `org_admin` example
40+
kept working throughout because that name sits on both axes. If you are
41+
adding a producer of this shape: derive it from the authority, never
42+
assemble it from whatever identity fields are in reach.
43+
2144
See also: docs/adr/0068-unified-user-context-and-built-in-identity-roles.md
2245

2346
<Callout type="info">
@@ -45,7 +68,7 @@ const result = EvalUserSchema.parse(data);
4568
| **id** | `string` || User ID |
4669
| **name** | `string` | optional | Display name |
4770
| **email** | `string` | optional | Email address |
48-
| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names assigned to the user (scope-resolved) |
71+
| **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 |
4972
| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in positions. Deprecated. |
5073
| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |
5174

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3474,52 +3474,85 @@ describe('AuthManager', () => {
34743474
return plugin._fn as (input: { user: any; session: any }) => Promise<any>;
34753475
};
34763476

3477-
it('returns positions=[] for a regular user with no stored role', async () => {
3477+
// [#15136 — maintainer ruling 2026-09-05, option A] These four cases used to
3478+
// pin the OPPOSITE contract: that the better-auth `sys_user.role` scalar,
3479+
// split on commas, WAS `positions[]`. That is the derivation the ruling
3480+
// removed — `positions[]` is the security axis on every surface — so they
3481+
// are migrated fixtures, not evidence against it. Each one now asserts the
3482+
// scalar is ABSENT from the array while remaining untouched on the payload,
3483+
// which is the half of ADR-0068 D2 that did not change.
3484+
it('carries the ADR-0090 D5 `everyone` anchor for a regular user with no stored role', async () => {
34783485
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false }));
34793486
const result = await callback({
34803487
user: { id: 'u-1', email: 'a@b.com' },
34813488
session: {},
34823489
});
34833490
expect(result.user.role).toBeUndefined();
3484-
expect(result.user.positions).toEqual([]);
3491+
expect(result.user.positions).toEqual(['everyone']);
34853492
});
34863493

3487-
it('splits a stored role string into positions for a non-admin user', async () => {
3494+
it('does NOT project the stored role scalar into positions[]', async () => {
34883495
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false }));
34893496
const result = await callback({
34903497
user: { id: 'u-1', email: 'a@b.com', role: 'manager' },
34913498
session: {},
34923499
});
3493-
// No promotion: `role` keeps its stored value.
3500+
// The scalar keeps its stored value — it is simply not an authorization
3501+
// axis, so it no longer appears in the array authored predicates read.
34943502
expect(result.user.role).toBe('manager');
3495-
expect(result.user.positions).toEqual(['manager']);
3503+
expect(result.user.positions).not.toContain('manager');
3504+
expect(result.user.positions).toEqual(['everyone']);
34963505
});
34973506

3498-
it('appends platform_admin to positions[] without overwriting role when promoting a platform admin', async () => {
3507+
it('derives platform_admin without overwriting role and without the scalar', async () => {
34993508
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
35003509
const result = await callback({
35013510
user: { id: 'u-1', email: 'a@b.com', role: 'manager' },
35023511
session: {},
35033512
});
35043513
// ADR-0068: NO `role:'admin'` overwrite footgun. The deprecated scalar
3505-
// keeps its stored value; the canonical platform_admin identity is added
3506-
// to roles[], and isPlatformAdmin is a derived alias.
3514+
// keeps its stored value; the canonical platform_admin identity is on
3515+
// positions[], and isPlatformAdmin is a derived alias of it.
35073516
expect(result.user.role).toBe('manager');
3508-
expect(result.user.positions).toEqual(['manager', 'platform_admin']);
3517+
expect(result.user.positions).toContain('platform_admin');
3518+
expect(result.user.positions).not.toContain('manager');
35093519
expect(result.user.isPlatformAdmin).toBe(true);
35103520
});
35113521

3512-
it('splits a multi-token stored role and appends platform_admin without duplicates', async () => {
3522+
it('ignores a multi-token stored role entirely while still deriving platform_admin', async () => {
35133523
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
35143524
const result = await callback({
35153525
user: { id: 'u-1', email: 'a@b.com', role: 'admin,manager' },
35163526
session: {},
35173527
});
35183528
expect(result.user.role).toBe('admin,manager');
3519-
expect(result.user.positions).toEqual(['admin', 'manager', 'platform_admin']);
3529+
expect(result.user.positions).toContain('platform_admin');
3530+
for (const token of ['admin', 'manager']) {
3531+
expect(result.user.positions).not.toContain(token);
3532+
}
35203533
expect(result.user.isPlatformAdmin).toBe(true);
35213534
});
35223535

3536+
it('carries an ADR-0057 D4 `sys_user_position` assignment — the axis the payload was missing', async () => {
3537+
const engine = makeDataEngine({ platformAdmin: false });
3538+
const inner = engine.find;
3539+
engine.find = vi.fn(async (object: string, q?: any) => {
3540+
if (object === 'sys_user_position') {
3541+
return [{ user_id: 'u-1', position: 'demo_reviewer', organization_id: null }];
3542+
}
3543+
return inner(object, q);
3544+
}) as any;
3545+
3546+
const callback = await getSessionCallback(engine);
3547+
const result = await callback({
3548+
user: { id: 'u-1', email: 'a@b.com', role: 'manager' },
3549+
session: {},
3550+
});
3551+
expect(result.user.positions).toContain('demo_reviewer');
3552+
// Still not the scalar — the two axes do not blend.
3553+
expect(result.user.positions).not.toContain('manager');
3554+
});
3555+
35233556
it('returns the payload untouched when the user has no id', async () => {
35243557
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false }));
35253558
const user = { email: 'anon@b.com' };

packages/spec/src/identity/eval-user.zod.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,29 @@ import type { PermissionSet } from '../security/permission.zod';
1818
* `roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion"
1919
* behavior is the footgun this eliminates.
2020
*
21+
* ## Which axis `positions` is — and which it is not (#15136)
22+
*
23+
* It is the **security** axis: the built-in identity names plus the
24+
* `sys_position` names the principal actually holds through `sys_user_position`
25+
* (ADR-0057 D4) and their active membership, plus the `everyone` anchor
26+
* (ADR-0090 D5) — exactly the set `/auth/me/permissions` reports and
27+
* `resolveUserAuthzGrants` resolves. Every surface derives it from that one
28+
* authority, so the "identical shape" promise above is a property of the
29+
* producer, not a convention producers are asked to honour.
30+
*
31+
* ⛔ It is **not** the better-auth `sys_user.role` scalar. That scalar is an
32+
* authentication-layer field on a table this platform does not own; it stays
33+
* published, unchanged, as `user.role` (the one exception ADR-0090 D3's word
34+
* ban carves out), and a consumer that genuinely wants it reads it there.
35+
*
36+
* ⚠️ The session payload used to union the scalar into this array and omit the
37+
* `sys_user_position` names entirely, which made a position-narrowed client
38+
* gate answer FALSE for its own holder — silently, since the root and the key
39+
* were both bound and CEL raised nothing. The documented `org_admin` example
40+
* kept working throughout because that name sits on both axes. If you are
41+
* adding a producer of this shape: derive it from the authority, never
42+
* assemble it from whatever identity fields are in reach.
43+
*
2144
* @see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md
2245
*/
2346

@@ -189,8 +212,12 @@ export const EvalUserSchema = lazySchema(() =>
189212
id: z.string().describe('User ID'),
190213
name: z.string().optional().describe('Display name'),
191214
email: z.string().optional().describe('Email address'),
192-
/** CANONICAL. Scope-resolved (ADR-0068 D3); built-in identity names + position names. */
193-
positions: z.array(z.string()).default([]).describe('Canonical position/identity names assigned to the user (scope-resolved)'),
215+
/**
216+
* CANONICAL. Scope-resolved (ADR-0068 D3); built-in identity names +
217+
* position names. The SECURITY axis (#15136) — never the better-auth
218+
* `sys_user.role` scalar, which stays published as `user.role`.
219+
*/
220+
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'),
194221
/** DERIVED alias of positions.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */
195222
isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in positions. Deprecated."),
196223
organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'),

0 commit comments

Comments
 (0)