|
| 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. |
0 commit comments