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
38 changes: 38 additions & 0 deletions .changeset/tidy-donkeys-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/runtime': major
---

**BREAKING**: `ctx.user.roles` 已移除 —— action body / AI 路由处理器上的调用者位置(positions)只保留一个拼法 `ctx.user.positions`(#6011)

`ActorUser`(action body 的 `ctx.user`、AI 路由处理器的 `req.user`)过去同时发出两个键,值完全相同(`roles` 是 `positions` 的逐字副本)。`roles` 是 ADR-0090 D3 明令保留并禁用的词,且没有关闭日期 —— 维护者 2026-08-06 裁定**立即退役**,不设弃用窗口、不双发。

### 迁移:FROM → TO

```js
// FROM — v17 起该键不再存在,读到的是 undefined
const positions = ctx.user.roles;
if (ctx.user.roles.includes('sales_rep')) { … }

// TO — 权威拼法,值逐字不变
const positions = ctx.user.positions;
if (ctx.user.positions.includes('sales_rep')) { … }
```

一行修复:把 body / 路由处理器里的 `ctx.user.roles` 改写成 `ctx.user.positions`(`req.user.roles` → `req.user.positions`)。**值不变** —— 两个键此前由同一次赋值产生,所以这是一次纯粹的改键,不是改语义。`positions` 数组恒存在,空时是 `[]` 而非 `undefined`,无需 `?? []`。

### ⚠️ 改键不等于修好了权限判断

`positions` 与此前的 `roles` 一样,**都不是授权输入**。权限由 security service 判定(capability 授予、placement、ADR-0095 推导出的 posture),不由名字字符串比较判定。因此:

```js
// 这不是迁移,这是把缺陷换了个拼法
if (ctx.user.roles.includes('admin')) { … } // 旧的错
if (ctx.user.positions.includes('admin')) { … } // 一样错,只是改了键名
```

把 `roles.includes('admin')` 改写成 `positions.includes('admin')` 迁移的是**缺陷本身**,不是那次读取。这类判断应改为向 security service 询问能力,而不是比对位置名。(与 #5991 的 `ctx.session` 更名同一告诫。)

### 不受影响的面

- **`ctx.session.roles` 不在本次范围内**,仍按 #5613 的弃用窗口双发 `positions` + `roles`,由 ADR-0087 语义迁移 `action-session-roles-to-positions` 约定其关闭时点。两个面同名不同物,请勿混为一谈。
- better-auth 会话上的 `user.roles`、`/api/v1/auth/me/permissions` 返回体的 `roles`、CEL/formula 的 `current_user.*`,都是各自独立的生产者,均未改动。
20 changes: 16 additions & 4 deletions packages/runtime/src/action-ctx-user-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ describe('#5372 — the SHAPE: one key set across every producer', () => {
// the id/name aliases the dispatch surfaces already published.
expect(keys(rest)).toEqual([
'displayName', 'email', 'id', 'isPlatformAdmin', 'name', 'organizationId',
'permissions', 'positions', 'roles', 'systemPermissions', 'userId',
'permissions', 'positions', 'systemPermissions', 'userId',
]);
});

Expand All @@ -312,7 +312,6 @@ describe('#5372 — the SHAPE: one key set across every producer', () => {
displayName: 'Dev Admin',
email: 'admin@objectos.ai',
positions: ['platform_admin'],
roles: ['platform_admin'],
// Derived by `createEvalUser`, never stored — ADR-0068 D2.
isPlatformAdmin: true,
permissions: ['admin_full_access'],
Expand All @@ -321,10 +320,23 @@ describe('#5372 — the SHAPE: one key set across every producer', () => {
});
});

it('the ADR-0090 position aliases stay in lockstep (`roles` is `positions`)', async () => {
it('publishes positions under ONE spelling — the `roles` alias is gone (#6011)', async () => {
// REPLACED, not deleted. This pin used to assert the two spellings
// stayed "in lockstep"; the maintainer's 2026-08-06 ruling closed the
// alias outright (direction 2, immediate retirement — not a
// deprecation window), so a lockstep assertion would now pin the
// removed limb. Deleting it outright would have been worse: the
// substance it guarded (positions reaches the body verbatim) would
// have gone unguarded on this surface. So it asserts BOTH halves —
// what the surviving key carries, and that the retired one is absent.
const { actionCtx } = await dispatchRest(makeEc({ positions: ['sales_rep'] }), makeQl(DEV_ADMIN));

// Substance: the canonical key carries the caller's positions verbatim.
expect(actionCtx.user.positions).toEqual(['sales_rep']);
expect(actionCtx.user.roles).toEqual(actionCtx.user.positions);
// Direction: the retired spelling is ABSENT — not present-and-empty,
// which is what a half-done removal (dropped value, surviving key)
// would leave behind and what `toBeUndefined()` alone cannot tell apart.
expect('roles' in actionCtx.user).toBe(false);
expect(Object.keys(actionCtx.user)).not.toContain('roles');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ describe('#4705 — /ai/* req.user carries the capability channel', () => {
// Verbatim — the set-name channel is untouched by the addition, and
// `ai_seat` (synthesized by resolveExecutionContext) still rides it.
expect(user.permissions).toEqual(['admin_full_access', 'ai_seat']);
expect(user.roles).toEqual(['platform_admin']);
// The position channel, under its one canonical spelling — the `roles`
// alias this line used to read was retired in #6011. Substance kept:
// positions are still a THIRD channel, unmerged with either permission
// list, which is what this case exists to prove.
expect(user.positions).toEqual(['platform_admin']);
expect('roles' in user).toBe(false);
// …and neither list has absorbed the other. A capability must NOT be
// readable off `permissions`, nor a set name off `systemPermissions`:
// that conflation is the failure mode this issue exists to prevent.
Expand Down Expand Up @@ -247,7 +252,7 @@ describe('#4705 — the concrete-mount producer agrees on the shape', () => {
expect(seen.user.displayName).toBe('Admin');
expect(Object.keys(seen.user).sort()).toEqual([
'displayName', 'email', 'id', 'isPlatformAdmin', 'name', 'organizationId',
'permissions', 'positions', 'roles', 'systemPermissions', 'userId',
'permissions', 'positions', 'systemPermissions', 'userId',
]);
});

Expand Down
15 changes: 12 additions & 3 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3608,7 +3608,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
const actionUser = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.user;
const actionSession = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.session;

it('forwards the session user id + business roles to the action body (not `system`)', async () => {
it('forwards the session user id + positions to the action body (not `system`)', async () => {
const { dispatcher, executeAction, ctx } = captureCtx({
userId: 'user_42',
positions: ['sales_rep', 'org_member'],
Expand All @@ -3619,8 +3619,14 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
const user = actionUser(executeAction);
expect(user.id).toBe('user_42');
expect(user.roles).toEqual(['sales_rep', 'org_member']);
expect(user.positions).toEqual(['sales_rep', 'org_member']);
// #6011 retired the `roles` alias on ctx.user outright (ADR-0090 D3's
// banned spelling; no consumer read it). The assertion that used to sit
// here read `user.roles` and is replaced by its inverse rather than
// dropped, so a re-added alias fails HERE and not only in the shape test.
// ⚠️ ctx.session is a DIFFERENT face and still dual-emits `roles` for
// #5613's deprecation window — see the test three cases below.
expect('roles' in user).toBe(false);
expect(user.permissions).toEqual(['convert_lead']);
expect(user.email).toBe('rep@acme.test');
// #3280 made `organizationId` the blessed name; the `tenantId` alias was
Expand Down Expand Up @@ -3682,8 +3688,11 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
await selfInvoked.dispatcher.handleActions('/lead/convert', 'POST', {}, selfInvoked.ctx);
const user = actionUser(selfInvoked.executeAction);
expect(user.id).toBe('system');
expect(user.roles).toEqual([]);
expect(user.positions).toEqual([]);
// The retired alias stays absent on the system principal too (#6011) — a
// partial removal that left `roles: []` here would still satisfy a
// `toEqual([])` pin, which is why this asserts the key, not the value.
expect('roles' in user).toBe(false);
// No resolved caller → no session (parity with the hook surface).
expect(actionSession(selfInvoked.executeAction)).toBeUndefined();

Expand Down
20 changes: 16 additions & 4 deletions packages/runtime/src/security/actor-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,23 @@ export interface ActorUser extends EvalUser {
name: string;
/** Alias of {@link name} — the spelling AI route handlers already read. */
displayName: string;
/** ADR-0090 position names held by the caller (canonical; `EvalUser.positions`). */
/**
* ADR-0090 position names held by the caller (canonical; `EvalUser.positions`).
*
* The pre-ADR-0090 `roles` alias of this key was REMOVED in v17 (#6011).
* It carried `positions` verbatim under a word ADR-0090 D3 reserves and
* bans, and no consumer read it — the "kept for the REST/AI shapes" claim
* its comment made was checked and disproven at removal time (the REST/AI
* shapes are BUILT here, but nothing downstream read `.roles` off them).
* A body that used to read `ctx.user.roles` reads `ctx.user.positions`.
*
* ⚠️ Do not restore it as a `??` fallback in a consumer: `positions` is the
* one spelling this surface publishes, and a second de-facto spelling is
* exactly the state #5613's ruling called a defect rather than an endpoint.
* (`ctx.session` is a DIFFERENT face and keeps its own deprecation window —
* see `buildActionSession` in `action-execution.ts`.)
*/
positions: string[];
/** Legacy alias of {@link positions} (pre-ADR-0090 spelling, kept for the REST/AI shapes). */
roles: string[];
/** Permission-SET names (`admin_full_access`, `ai_seat`, …). */
permissions: string[];
/** CAPABILITIES (`manage_metadata`, `studio.access`, …) — a separate channel (#4705). */
Expand Down Expand Up @@ -205,7 +218,6 @@ export function buildActorUser(input?: {
userId: id,
displayName: name,
positions: core.positions,
roles: core.positions,
permissions: anonymous ? [] : strings(input?.permissions),
systemPermissions: anonymous ? [] : strings(input?.systemPermissions),
};
Expand Down
Loading