Skip to content

Commit 7e7a605

Browse files
os-zhuangclaude
andauthored
fix(runtime): carry the capability channel onto AI-route req.user (#4705) (#4712)
`domains/ai.ts` built `req.user` from the request's ExecutionContext but copied only the permission-SET-NAME channel (`ec.permissions`, which also carries the synthesized `ai_seat`), never the CAPABILITY channel (`ec.systemPermissions` — `manage_metadata`, `studio.access`, …). That made `/ai/*` the one route domain where a capability gate could not be written: every other surface tests `systemPermissions` (domains/meta.ts's `manage_metadata` gate, action-execution.ts, rest-server.ts), so the same test written against an AI route's `req.user.permissions` is permanently false — it would close the route on platform admins, not tighten it. It is the direct blocker for the capability gate on `POST /api/v1/ai/tools/:toolName/execute` (objectstack-ai/cloud#1015). Transport only: the field is copied through with the fail-closed default the neighbouring fields use (non-array or absent -> `[]`, never `undefined`), the two channels stay side by side and unmerged, and no route in this repo gates on it. `route.permissions` is untouched. `dispatcher-plugin`'s `resolveRequestUser` — the other producer of an AI-route `req.user`, backing the concrete per-route mounts — has no ExecutionContext to read and stays capability-less on purpose; it now says so in the same shape (`systemPermissions: []`) so a consumer never has to tell `undefined` from `[]`. Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4a85c8 commit 7e7a605

4 files changed

Lines changed: 354 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): carry the capability channel onto an AI route's `req.user` (#4705)
6+
7+
`/ai/*` was the one route domain in the platform where a capability check could
8+
not be written. The dispatcher builds `req.user` from the request's
9+
ExecutionContext, and `resolveAuthzContext` resolves a caller into **two** lists
10+
that look alike and are not:
11+
12+
| ExecutionContext field | Carries |
13+
|---|---|
14+
| `permissions` | permission-**set names** (`admin_full_access`, `organization_admin`, `member_default`) plus the synthesized `ai_seat` |
15+
| `systemPermissions` | **capabilities**`manage_metadata`, `studio.access`, `setup.access`, … — the union of every resolved set's `systemPermissions[]` |
16+
17+
Only the first was copied. Every other surface gates on the second
18+
(`domains/meta.ts`'s `manage_metadata` check, `action-execution.ts`,
19+
`rest-server.ts`), so the same test written against an AI route's
20+
`req.user.permissions` was **permanently false** — a gate built on it would not
21+
have tightened the route, it would have closed it on platform admins too. That
22+
is what blocked the capability gate on
23+
`POST /api/v1/ai/tools/:toolName/execute`, where any authenticated user can
24+
currently run any registered tool (`create_object`, `apply_blueprint`,
25+
`create_seed`) in the default configuration.
26+
27+
`req.user` now carries `systemPermissions` alongside `permissions`, with the
28+
same fail-closed default the neighbouring fields use: a non-array — or an
29+
ExecutionContext that has none, since the field is optional — becomes `[]`,
30+
never `undefined`. The two channels are copied **side by side and never merged**:
31+
flattening either into the other would corrupt every existing reader of
32+
`permissions` while appearing to fix this.
33+
34+
This is transport only. No route in this package gates on the new field, and the
35+
declared-but-unenforced `route.permissions` mechanism is untouched — consumers
36+
decide policy, on the platform's existing `systemPermissions` contract.
37+
38+
The other producer of an AI-route `req.user``dispatcher-plugin`'s
39+
`resolveRequestUser`, backing the concrete per-route mounts — has no
40+
ExecutionContext to read and stays capability-less on purpose. It now says so in
41+
the same shape (`systemPermissions: []`, spelled out rather than omitted) so a
42+
consumer never sees `undefined` on one path and `[]` on the other, and so needs
43+
no fallback of its own to tell them apart.

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,13 +1164,33 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
11641164
// populated from the ExecutionContext by the /ai/* dispatch path
11651165
// (http-dispatcher → resolveExecutionContext, the single scope-correct
11661166
// source). This concrete-route resolver returns an empty set.
1167+
//
1168+
// [#4705] `systemPermissions` — the CAPABILITY channel
1169+
// (`manage_metadata`, `studio.access`, …) that domains/ai.ts
1170+
// now carries across from the ExecutionContext — is spelled
1171+
// out here as an empty array for the same reason
1172+
// `permissions` is: this resolver has no ExecutionContext to
1173+
// read, and inventing a capability source for it would hand
1174+
// out authority the platform never granted. Written
1175+
// explicitly rather than omitted so the two producers of an
1176+
// AI-route `req.user` agree on the SHAPE: a consumer sees
1177+
// "holds no capabilities", never `undefined`, so it never
1178+
// needs a `?? []` of its own to tell the two apart.
1179+
//
1180+
// Reachability, for the record: these concrete mounts are
1181+
// shadowed in practice — `registerAIRoutes` mounts the
1182+
// `${prefix}/ai/*` method-wildcards through
1183+
// `dispatcher.dispatch()` EARLIER in this same `start()`, so
1184+
// the ExecutionContext-backed path is the one that answers a
1185+
// real `/api/v1/ai/...` request.
11671186
return {
11681187
userId,
11691188
id: userId,
11701189
displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId,
11711190
email: sessionData?.user?.email,
11721191
positions: [],
11731192
permissions: [],
1193+
systemPermissions: [],
11741194
organizationId: sessionData?.session?.activeOrganizationId,
11751195
};
11761196
} catch {
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4705 — the AI routes' `req.user` must carry the CAPABILITY channel, and it
5+
* must stay a channel of its own.
6+
*
7+
* `resolveAuthzContext` resolves a caller into two lists that look alike and
8+
* are not (core/src/security/resolve-authz-context.ts):
9+
*
10+
* - `permissions` — permission-SET NAMES (`admin_full_access`,
11+
* `organization_admin`, `member_default`) plus the
12+
* synthesized `ai_seat`.
13+
* - `systemPermissions` — CAPABILITIES (`manage_metadata`, `studio.access`,
14+
* `setup.access`, …), the union of every resolved
15+
* set's `systemPermissions[]`.
16+
*
17+
* `resolveExecutionContext` surfaces both, side by side. `domains/ai.ts` copied
18+
* only the first onto `req.user`, which made `/ai/*` the single route domain in
19+
* the repo where a capability gate could not be written: every other surface
20+
* tests `systemPermissions` (`domains/meta.ts`'s `manage_metadata` gate,
21+
* `action-execution.ts`, `rest/src/rest-server.ts`), so the same test written
22+
* against an AI route's `req.user.permissions` is permanently false — which
23+
* shuts the route on platform admins instead of tightening it. That is the
24+
* blocker cloud#1015 hit when it went to gate
25+
* `POST /api/v1/ai/tools/:toolName/execute`.
26+
*
27+
* These tests pin the transport, not a policy: no route in this repo gates on
28+
* the field. Three things must hold, and the third is why the first two are not
29+
* enough on their own —
30+
*
31+
* 1. a capability held on the ExecutionContext ARRIVES on
32+
* `req.user.systemPermissions`;
33+
* 2. a caller holding none gets `[]`, never `undefined` (fail-closed, and it
34+
* spares every consumer a `?? []` of its own);
35+
* 3. `permissions` still carries set names + `ai_seat` VERBATIM — the two
36+
* channels are copied side by side, never merged. Flattening one into the
37+
* other would corrupt every existing reader of `permissions` while looking
38+
* like it fixed this.
39+
*/
40+
41+
import { describe, it, expect } from 'vitest';
42+
43+
import { handleAIRequest } from './ai.js';
44+
import { createDispatcherPlugin } from '../dispatcher-plugin.js';
45+
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
46+
import type { HttpProtocolContext } from '../http-dispatcher.js';
47+
48+
const TOOL_ROUTE = '/api/v1/ai/tools/:toolName/execute';
49+
50+
/** Captures the `req` the AI route handler is dispatched with. */
51+
function makeDeps(seen: { req?: any }): DomainHandlerDeps {
52+
const aiService: any = { chat: async () => ({ text: 'ok' }) };
53+
return {
54+
resolveService: (async (name: string) => (name === 'ai' ? aiService : undefined)) as any,
55+
getRegisteredAiRoutes: () => [
56+
{
57+
method: 'POST',
58+
path: TOOL_ROUTE,
59+
auth: true,
60+
handler: async (req: any) => {
61+
seen.req = req;
62+
return { status: 200, body: { success: true, data: { ok: true } } };
63+
},
64+
},
65+
],
66+
success: (data: any) => ({ status: 200, body: { success: true, data } }),
67+
error: (message: string, httpStatus = 500) => ({ status: httpStatus, body: { success: false, error: { message } } }),
68+
routeNotFound: (route: string) => ({ status: 404, body: { success: false, error: { code: 'ROUTE_NOT_FOUND', route } } }),
69+
} as unknown as DomainHandlerDeps;
70+
}
71+
72+
/** Dispatch `POST /ai/tools/create_object/execute` under `executionContext`. */
73+
async function dispatchToolExecute(executionContext: any) {
74+
const seen: { req?: any } = {};
75+
const context = { executionContext } as unknown as HttpProtocolContext;
76+
const result = await handleAIRequest(
77+
makeDeps(seen),
78+
'/ai/tools/create_object/execute',
79+
'POST',
80+
{},
81+
{},
82+
context,
83+
);
84+
return { result, user: seen.req?.user, req: seen.req };
85+
}
86+
87+
describe('#4705 — /ai/* req.user carries the capability channel', () => {
88+
it('surfaces ec.systemPermissions on req.user.systemPermissions', async () => {
89+
// A platform admin as `resolveAuthzContext` actually resolves one: the
90+
// set NAME in `permissions`, the capabilities it grants in
91+
// `systemPermissions` (plugin-security's `admin_full_access` declares
92+
// `manage_metadata` / `studio.access` / `setup.access` there).
93+
const { user, result } = await dispatchToolExecute({
94+
userId: 'usr_admin',
95+
userEmail: 'admin@objectos.ai',
96+
positions: ['platform_admin'],
97+
permissions: ['admin_full_access', 'ai_seat'],
98+
systemPermissions: ['manage_users', 'manage_metadata', 'studio.access', 'setup.access'],
99+
tenantId: 'org_1',
100+
});
101+
102+
expect(result.handled).toBe(true);
103+
// The whole point of the issue: a capability gate on an AI route can
104+
// now be written and can actually pass for someone who holds it.
105+
expect(user.systemPermissions).toEqual([
106+
'manage_users', 'manage_metadata', 'studio.access', 'setup.access',
107+
]);
108+
expect(new Set<string>(user.systemPermissions).has('manage_metadata')).toBe(true);
109+
});
110+
111+
it('keeps `permissions` = permission-set names + ai_seat, unmerged with capabilities', async () => {
112+
const { user } = await dispatchToolExecute({
113+
userId: 'usr_admin',
114+
permissions: ['admin_full_access', 'ai_seat'],
115+
systemPermissions: ['manage_metadata', 'studio.access'],
116+
positions: ['platform_admin'],
117+
});
118+
119+
// Verbatim — the set-name channel is untouched by the addition, and
120+
// `ai_seat` (synthesized by resolveExecutionContext) still rides it.
121+
expect(user.permissions).toEqual(['admin_full_access', 'ai_seat']);
122+
expect(user.roles).toEqual(['platform_admin']);
123+
// …and neither list has absorbed the other. A capability must NOT be
124+
// readable off `permissions`, nor a set name off `systemPermissions`:
125+
// that conflation is the failure mode this issue exists to prevent.
126+
expect(user.permissions).not.toContain('manage_metadata');
127+
expect(user.systemPermissions).not.toContain('admin_full_access');
128+
expect(user.systemPermissions).not.toContain('ai_seat');
129+
});
130+
131+
it('gives a caller with no capabilities [] — not undefined', async () => {
132+
// An ordinary member: holds an AI seat, holds no capability. The
133+
// ExecutionContext omits the field entirely (it is optional on
134+
// `ExecutionContextSchema`), which is the shape a consumer would
135+
// otherwise have to tolerate with a `?? []` of its own.
136+
const { user } = await dispatchToolExecute({
137+
userId: 'usr_member',
138+
permissions: ['member_default', 'ai_seat'],
139+
positions: ['member'],
140+
});
141+
142+
expect(user.systemPermissions).toEqual([]);
143+
expect(user.systemPermissions).not.toBeUndefined();
144+
expect(new Set<string>(user.systemPermissions).has('manage_metadata')).toBe(false);
145+
});
146+
147+
it('fails closed when ec.systemPermissions is not an array', async () => {
148+
const { user } = await dispatchToolExecute({
149+
userId: 'usr_member',
150+
permissions: ['member_default'],
151+
// A malformed/stringified value must never become authority.
152+
systemPermissions: 'manage_metadata',
153+
});
154+
155+
expect(user.systemPermissions).toEqual([]);
156+
});
157+
});
158+
159+
// ── The OTHER producer of an AI-route `req.user` ────────────────────────────
160+
// `dispatcher-plugin`'s `resolveRequestUser` backs the concrete per-route
161+
// mounts. It has no ExecutionContext to read, so it stays capability-less on
162+
// purpose — but it must say so in the SAME shape, otherwise a consumer sees
163+
// `undefined` on one path and `[]` on the other and reaches for a fallback to
164+
// paper over the difference.
165+
166+
function makeFakeServer() {
167+
const routes: string[] = [];
168+
const handlers: Record<string, (req: any, res: any) => any> = {};
169+
const rec = (verb: string) => (path: string, handler: any) => {
170+
routes.push(`${verb} ${path}`);
171+
handlers[`${verb} ${path}`] = handler;
172+
};
173+
return {
174+
routes,
175+
handlers,
176+
server: {
177+
get: rec('GET'), post: rec('POST'), put: rec('PUT'),
178+
delete: rec('DELETE'), patch: rec('PATCH'),
179+
},
180+
};
181+
}
182+
183+
function makeCtx(fakeServer: any, aiRoutes: any[], onRequest: (req: any) => void) {
184+
const kernel: any = {
185+
getService: () => undefined,
186+
getServiceAsync: async () => undefined,
187+
// The AIServicePlugin's cross-plugin cache the dispatcher recovers
188+
// routes from when the `ai:routes` hook fired before it was listening.
189+
__aiRoutes: aiRoutes.map((r) => ({
190+
...r,
191+
handler: async (req: any) => { onRequest(req); return { status: 200, body: { ok: true } }; },
192+
})),
193+
};
194+
const authService: any = {
195+
api: {
196+
getSession: async () => ({
197+
user: { id: 'usr_admin', name: 'Admin', email: 'admin@objectos.ai' },
198+
session: { activeOrganizationId: 'org_1' },
199+
}),
200+
},
201+
};
202+
return {
203+
getKernel: () => kernel,
204+
getService: (name: string) =>
205+
name === 'http.server' ? fakeServer : name === 'auth' ? authService : undefined,
206+
environmentId: undefined,
207+
logger: { info() {}, warn() {}, error() {}, debug() {} },
208+
hook: () => {},
209+
on: () => {},
210+
} as any;
211+
}
212+
213+
describe('#4705 — the concrete-mount producer agrees on the shape', () => {
214+
it('resolveRequestUser emits systemPermissions: [] (fail-closed, never undefined)', async () => {
215+
const { server, handlers } = makeFakeServer();
216+
let seen: any;
217+
const ctx = makeCtx(
218+
server,
219+
[{ method: 'POST', path: '/ai/tools/:toolName/execute', description: 'x', auth: true }],
220+
(req) => { seen = req; },
221+
);
222+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
223+
await plugin.start?.(ctx);
224+
225+
const handler = handlers[`POST ${TOOL_ROUTE}`];
226+
expect(handler).toBeTypeOf('function');
227+
const res = { status() { return res; }, header() { return res; }, json() { return res; } } as any;
228+
await handler({ headers: {}, body: {}, params: { toolName: 'create_object' }, query: {} }, res);
229+
230+
expect(seen.user.userId).toBe('usr_admin');
231+
// No ExecutionContext here → no authority, stated explicitly. Same
232+
// shape as the dispatch path, so a consumer needs no `?? []`.
233+
expect(seen.user.permissions).toEqual([]);
234+
expect(seen.user.systemPermissions).toEqual([]);
235+
});
236+
237+
it('mounts the /ai/* dispatch wildcard BEFORE the concrete AI routes', async () => {
238+
// Why the capability-less resolver above is not the live path: the
239+
// method-wildcards `registerAIRoutes` mounts go through
240+
// `dispatcher.dispatch()` → `domains/ai.ts`, i.e. the
241+
// ExecutionContext-backed `req.user`. They are registered earlier in
242+
// `start()`, so they answer a real `/api/v1/ai/...` request first.
243+
// Reordering these two would silently hand `/ai/*` back to the
244+
// capability-less producer — and a gate built on cloud#1015's contract
245+
// would start 403-ing platform admins again.
246+
const { server, routes } = makeFakeServer();
247+
const ctx = makeCtx(
248+
server,
249+
[{ method: 'POST', path: '/ai/tools/:toolName/execute', description: 'x', auth: true }],
250+
() => {},
251+
);
252+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
253+
await plugin.start?.(ctx);
254+
255+
const wildcard = routes.indexOf('POST /api/v1/ai/*');
256+
const concrete = routes.indexOf(`POST ${TOOL_ROUTE}`);
257+
expect(wildcard).toBeGreaterThanOrEqual(0);
258+
expect(concrete).toBeGreaterThanOrEqual(0);
259+
expect(wildcard).toBeLessThan(concrete);
260+
});
261+
});

packages/runtime/src/domains/ai.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,35 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
142142
// `ai_seat` is synthesized into ec.permissions by resolveExecutionContext
143143
// (the single, scope-correct source — security/resolve-execution-context.ts),
144144
// so it flows through here with no extra per-request lookup.
145+
//
146+
// [#4705] `permissions` and `systemPermissions` are TWO channels, and
147+
// both have to cross this seam — they are not interchangeable and must
148+
// never be flattened into one another:
149+
//
150+
// - `ec.permissions` → permission-SET NAMES (`admin_full_access`,
151+
// `organization_admin`, `member_default`) plus
152+
// the synthesized `ai_seat`.
153+
// (core/src/security/resolve-authz-context.ts,
154+
// `grants.permissions.push(ps.name)`)
155+
// - `ec.systemPermissions` → CAPABILITIES (`manage_metadata`,
156+
// `studio.access`, `setup.access`, …), the
157+
// union of every resolved permission set's
158+
// `systemPermissions[]`. (same file, the
159+
// `grants.systemPermissions.push(p)` loop)
160+
//
161+
// Only the first used to be copied, which made `/ai/*` the one route
162+
// domain in the repo where a capability check was impossible: every
163+
// other surface reads `systemPermissions` (domains/meta.ts, the
164+
// `manage_metadata` gate; action-execution.ts; rest-server.ts), so a
165+
// capability test written against `req.user.permissions` is
166+
// permanently false — closing the route on platform admins too rather
167+
// than tightening it. Copying the channel through is transport only:
168+
// no route in THIS repo gates on it; the consumer decides.
169+
//
170+
// Fail-closed default, same as `roles`/`permissions`: a non-array (or
171+
// absent — `ExecutionContext.systemPermissions` is optional) becomes
172+
// `[]`, never `undefined`, so a consumer reads "holds nothing" instead
173+
// of having to tolerate a missing field.
145174
const user = ec?.userId
146175
? {
147176
userId: ec.userId,
@@ -150,6 +179,7 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
150179
email: ec.userEmail,
151180
roles: Array.isArray(ec.positions) ? ec.positions : [],
152181
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
182+
systemPermissions: Array.isArray(ec.systemPermissions) ? ec.systemPermissions : [],
153183
organizationId: ec.tenantId,
154184
}
155185
: undefined;

0 commit comments

Comments
 (0)