Skip to content

Commit c3fd190

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15663-validate-expression-nonstring-source
2 parents 7f413af + 8e0b297 commit c3fd190

20 files changed

Lines changed: 1964 additions & 92 deletions
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 that banned word to publish information the payload
63+
already carries. (Precisely: `check:role-word` ratchets the reserved word in
64+
`content/docs` and `skills/` PROSE, while the identifier ban over authored
65+
metadata lives in `packages/lint`; a TypeScript payload key trips neither
66+
mechanically until it is documented. The ADR-level prohibition is what rules
67+
here, not a gate that would have caught it.) A consumer that wants the
68+
better-auth role reads `user.role`.
69+
70+
**What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2);
71+
`platform_admin` still derives from the unscoped `admin_full_access` grant with
72+
its ADR-0091 validity window and ADR-0049 active flag intact —
73+
`platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over
74+
those shapes.
75+
76+
⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.**
77+
`positions.includes('platform_admin')` is the form
78+
`resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position`
79+
row may spell that very name — and this card is what made that reachable, by
80+
moving `positions` onto an axis a tenant admin can write. Reading the name would
81+
have let a tenant mint platform standing and pass the `/admin/*` mount gate.
82+
`platform-admin-gate.ts` drops its positions leg for the same reason.
83+
`session-platform-admin-rung-agreement.test.ts` requires the payload alias, that
84+
gate and `hasPlatformAdminStanding` to agree, driven with such a row present and
85+
a genuine grant as the control.
86+
87+
**Upgrade.** If you gate on the better-auth role scalar, read `user.role`
88+
instead of looking for its tokens in `user.positions`. Predicates written
89+
against real position names, built-in identity names, or `everyone` need no
90+
change — they start working. Deployments that stored business role names in
91+
`sys_user.role` rather than assigning positions should assign them through
92+
`sys_user_position` (the governed ADR-0090 D12 channel).
93+
94+
A name in `sys_member.role` is still projected, **with one carve-out**: for a
95+
session carrying NO active organization, membership names are now *added*, from
96+
**every** membership the user holds — the resolver projects them all when no
97+
tenant scopes it, where the old derivation contributed none. Measured on the
98+
real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`):
99+
`[]` before, `[org_admin, everyone]` after, pinned by
100+
`session-positions-security-axis.test.ts`. With an active organization the
101+
projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so
102+
membership-derived names there are unchanged.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
The storage download door derives the tenancy posture before resolving the caller
6+
7+
`buildFileReadAuthorizer` resolved every gated download with `resolveAuthzContext({ ql: engine, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran at this door. Its headers come from the real request, so `x-api-key` is accepted, and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture a key stamped with an organization its owner had left therefore authenticated for downloads and was judged by the ownership and record-reachability checks — checks evaluated for a principal the wall should have refused at the door.
8+
9+
The posture is now read off the kernel's `tenancy` service, per download, and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Under `isolated` and `group` an ex-member's stamped key is now refused and no download capability is minted; an organization-less key is refused under `isolated` and stays admitted under `group`, whose union scope makes it legitimate. Under `single` nothing changes. Patch rather than minor: no accept set widens, and a declared guard returns to enforced.

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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
},
4343
"devDependencies": {
4444
"@objectstack/driver-sql": "workspace:*",
45+
"@objectstack/formula": "workspace:*",
4546
"@objectstack/objectql": "workspace:*",
4647
"@objectstack/plugin-hono-server": "workspace:*",
4748
"@objectstack/plugin-security": "workspace:*",

packages/plugins/plugin-auth/src/admin-ban-endpoints.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,44 @@ describe('#9652 runAdminUnbanUser', () => {
169169
});
170170

171171
describe('#9652 the shared ADR-0068 platform-admin gate', () => {
172-
it('admits a platform admin carrying positions[] and NO role scalar', () => {
173-
// This is the identity a real deployment produces after ADR-0068 D2 — the
174-
// exact shape better-auth refuses.
172+
// [#15136] MIGRATED FIXTURE. This case used to admit on
173+
// `positions: ['user','platform_admin']` with no `isPlatformAdmin` — it pinned
174+
// the array leg `isPlatformAdminUser` no longer has. Under ruling A
175+
// `positions[]` is the security axis, so that name can arrive from a
176+
// tenant-writable ADR-0057 D4 `sys_user_position` row, and admitting on it
177+
// would hand platform-operator routes to a tenant admin. The identity a real
178+
// deployment produces still carries the derived alias, which is the posture
179+
// RUNG; that is what the gate reads and what this now pins.
180+
it('admits a platform admin carrying the derived alias and NO role scalar', () => {
175181
const verdict = judgePlatformAdmin({
176-
user: { id: 'usr_admin', email: 'a@b.c', positions: ['user', 'platform_admin'], role: 'user' },
182+
user: {
183+
id: 'usr_admin',
184+
email: 'a@b.c',
185+
positions: ['org_member', 'platform_admin', 'everyone'],
186+
isPlatformAdmin: true,
187+
role: 'user',
188+
},
177189
});
178190
expect(verdict.ok).toBe(true);
179191
expect(verdict.ok && verdict.actor.id).toBe('usr_admin');
180192
});
181193

194+
it('⛔ REFUSES a `platform_admin` NAME in positions[] with no rung behind it', () => {
195+
// The escalation shape: a `sys_user_position` row spelling the built-in
196+
// name. `isPlatformAdmin` is absent because the rung said no. Admitting
197+
// here would be the privilege-escalation path — this is the unit-level
198+
// half of the three-way-agreement pin in
199+
// `session-platform-admin-rung-agreement.test.ts`, which drives the same
200+
// shape through a real session.
201+
const verdict = judgePlatformAdmin({
202+
user: { id: 'usr_member', positions: ['org_member', 'platform_admin', 'everyone'], role: 'user' },
203+
});
204+
expect(verdict.ok).toBe(false);
205+
expect(!verdict.ok && verdict.refusal.status).toBe(403);
206+
expect(!verdict.ok && verdict.refusal.body.error.code).toBe('PERMISSION_DENIED');
207+
expect(isPlatformAdminUser({ id: 'usr_member', positions: ['platform_admin'] })).toBe(false);
208+
});
209+
182210
it('admits on the derived isPlatformAdmin alias alone', () => {
183211
expect(judgePlatformAdmin({ user: { id: 'u', isPlatformAdmin: true } }).ok).toBe(true);
184212
});

packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,35 @@ const makeSsoVendor = () =>
135135
describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate before delegating', () => {
136136
// Session shapes are the exact ones platform-admin-gate.ts is unit-tested
137137
// for; here they drive the MOUNTED routes so the pin is on the wiring.
138+
//
139+
// [#15136] MIGRATED. The platform-admin shape used to be
140+
// `positions: ['user','platform_admin']` with no `isPlatformAdmin` — it stood
141+
// for a real payload back when the gate admitted on that NAME. It no longer
142+
// does, and deliberately: `positions[]` is now the security axis, so the name
143+
// can arrive from a tenant-writable ADR-0057 D4 `sys_user_position` row, and
144+
// admitting on it would hand these operator routes to a tenant admin. The
145+
// payload a real deployment produces carries the derived alias, which is the
146+
// ADR-0095 D3 posture rung; that is what the gate reads, so that is what the
147+
// fixture must carry. `positions` keeps the name because a genuine platform
148+
// admin really does have it projected — which is exactly why the name alone
149+
// cannot be the signal.
138150
const SESSIONS: Record<string, unknown> = {
139151
member: { user: { id: 'usr_member', positions: ['user'], role: 'user' } },
140152
'org-admin': { user: { id: 'usr_orgadmin', positions: ['user', 'org_admin', 'org_owner'], role: 'user' } },
141-
'platform-admin': { user: { id: 'usr_admin', positions: ['user', 'platform_admin'], role: 'user' } },
153+
'platform-admin': {
154+
user: {
155+
id: 'usr_admin',
156+
positions: ['user', 'platform_admin'],
157+
isPlatformAdmin: true,
158+
role: 'user',
159+
},
160+
},
161+
// The escalation shape, refused: the built-in NAME with no rung behind it.
162+
// Without this, restoring the array leg would turn these bridges back into
163+
// an open door and every case above would still pass.
164+
'position-named-platform-admin': {
165+
user: { id: 'usr_tenant', positions: ['org_member', 'platform_admin'], role: 'user' },
166+
},
142167
};
143168

144169
let app: Hono;
@@ -189,6 +214,19 @@ describe('#9653 the /admin/sso/* bridges run the ADR-0068 platform-admin gate be
189214
expect(delegated).not.toHaveBeenCalled();
190215
});
191216

217+
it(`${path}: a \`platform_admin\` POSITION NAME with no rung → 403, never delegated`, async () => {
218+
// [#15136] The escalation shape. `sys_user_position` is `apiEnabled`, so a
219+
// tenant admin can put this exact name in their own `positions[]` now that
220+
// the array is the security axis. The gate reads the rung-derived alias,
221+
// which is absent here, so these operator routes stay shut.
222+
delegated.mockClear();
223+
const res = await fire(app, path, { session: 'position-named-platform-admin' });
224+
const body: any = await res.json();
225+
expect(res.status).toBe(403);
226+
expect(body.error?.code).toBe('PERMISSION_DENIED');
227+
expect(delegated).not.toHaveBeenCalled();
228+
});
229+
192230
it(`${path}: a platform admin passes the gate and IS delegated into better-auth`, async () => {
193231
delegated.mockClear();
194232
const res = await fire(app, path, { session: 'platform-admin' });

0 commit comments

Comments
 (0)