Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .changeset/session-payload-positions-security-axis.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered session-payload-positions-security-axis -->

**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.
25 changes: 24 additions & 1 deletion content/docs/references/identity/eval-user.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Callout type="info">
Expand Down Expand Up @@ -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) |

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
},
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-hono-server": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
Expand Down
36 changes: 32 additions & 4 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
40 changes: 39 additions & 1 deletion packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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;
Expand Down Expand Up @@ -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' });
Expand Down
Loading
Loading