Skip to content

Commit b4c5e86

Browse files
os-zhuangclaude
andcommitted
fix(runtime): synthesize ai_seat in resolveExecutionContext (scope-correct engine)
The per-user AI-seat gate (fw#2334) synthesized `ai_seat` from sys_user.ai_access in the AI-route dispatch via getObjectQLService() (no-arg). That resolves the env engine on single-env local but NOT on the SHARDED multi-tenant runtime (OS_OBJECTOS_SHARDS>1), where only the per-request scope selects the right engine -> the lookup returned empty -> a SEATED user (ai_access=true) was denied (/ai/agents=[]). Caught live on staging enforce. Move the synthesis into resolveExecutionContext, which is already handed the scope-correct `ql` engine (the same one that resolves permission sets) -> works on local AND sharded staging/prod. ec.permissions now carries `ai_seat`, which flows to req.user via the existing dispatch path. Drop the two earlier heuristic spots (http-dispatcher wildcard + dispatcher-plugin resolveRequestUser) so there is a single, correct source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e8a81f0 commit b4c5e86

3 files changed

Lines changed: 23 additions & 56 deletions

File tree

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -971,35 +971,17 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
971971
const sessionData = await api.getSession({ headers: headersInstance });
972972
const userId: string | undefined = sessionData?.user?.id ?? sessionData?.session?.userId;
973973
if (!userId) return undefined;
974-
// Env-side AI-seat synthesis (ADR-0024, sys_user.ai_access boolean
975-
// model). The per-agent gate (evaluateAgentAccess → requires the
976-
// `ai_seat` capability) reads `req.user.permissions`, which the AI
977-
// routes get from THIS resolver — but it previously returned an empty
978-
// permission set, so a SEATED user (ai_access=true) was still denied
979-
// (empty /ai/agents catalog). Mirror plugin-hono-server's data-route
980-
// resolveCtx: read the boolean with a GUARDED system query on the
981-
// per-request env kernel's `sys_user` and, when true, synthesize the
982-
// `ai_seat` capability. Absent/false/missing-column/error → no seat
983-
// (deny, unchanged) so this can only ever ADD access, never break auth.
984-
const permissions: string[] = [];
985-
try {
986-
const dataEngine: any = ctx.getService('data');
987-
if (dataEngine && typeof dataEngine.find === 'function') {
988-
const uRows = await dataEngine
989-
.find('sys_user', { where: { id: userId }, limit: 1 }, { context: { isSystem: true } })
990-
.catch(() => []);
991-
if ((uRows?.[0] as any)?.ai_access === true) permissions.push('ai_seat');
992-
}
993-
} catch {
994-
/* no ai_access column / query failed → no seat (safe) */
995-
}
974+
// AI-route req.user permissions (incl. the synthesized `ai_seat`) are
975+
// populated from the ExecutionContext by the /ai/* dispatch path
976+
// (http-dispatcher → resolveExecutionContext, the single scope-correct
977+
// source). This concrete-route resolver returns an empty set.
996978
return {
997979
userId,
998980
id: userId,
999981
displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId,
1000982
email: sessionData?.user?.email,
1001983
roles: [],
1002-
permissions,
984+
permissions: [],
1003985
organizationId: sessionData?.session?.activeOrganizationId,
1004986
};
1005987
} catch {

packages/runtime/src/http-dispatcher.ts

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3217,46 +3217,17 @@ export class HttpDispatcher {
32173217
// for anonymous requests — the route's own `auth: true` guard
32183218
// is enforced by upstream middleware.
32193219
const ec: any = context.executionContext;
3220-
// Build the caller's permission set, then synthesize the env-side
3221-
// AI seat. The per-agent gate (evaluateAgentAccess → requires the
3222-
// `ai_seat` capability) reads `req.user.permissions`, but the
3223-
// dispatch ExecutionContext does NOT carry the seat — so a SEATED
3224-
// user (sys_user.ai_access=true) was denied (empty /ai/agents
3225-
// catalog). Read the boolean on the per-request (env) kernel via the
3226-
// env-scoped ObjectQL service and, when true, ADD `ai_seat`. Copy the
3227-
// array first so we never mutate the shared ec. Guarded system read →
3228-
// can only ever ADD access, never break auth (absent/false/error →
3229-
// no seat, unchanged).
3230-
const permissions: string[] = ec?.userId && Array.isArray(ec.permissions)
3231-
? [...ec.permissions]
3232-
: [];
3233-
if (ec?.userId && !permissions.includes('ai_seat')) {
3234-
try {
3235-
// Resolve via the DEFAULT (current per-request env) ObjectQL
3236-
// service. Do NOT pass `context.environmentId`: in the cloud
3237-
// runtime that is the control-plane env UUID, which keys a
3238-
// DISTINCT, empty per-scope engine — the env's own `sys_user`
3239-
// (where `ai_access` lives) is served by the default service.
3240-
// Passing the id yields an empty result and the seat is lost.
3241-
const ql: any = await this.getObjectQLService();
3242-
if (ql && typeof ql.find === 'function') {
3243-
const rows = await ql
3244-
.find('sys_user', { where: { id: ec.userId }, limit: 1 }, { context: { isSystem: true } })
3245-
.catch(() => []);
3246-
if ((rows?.[0] as any)?.ai_access === true) permissions.push('ai_seat');
3247-
}
3248-
} catch {
3249-
/* no ai_access column / lookup failed → no seat (safe) */
3250-
}
3251-
}
3220+
// `ai_seat` is synthesized into ec.permissions by resolveExecutionContext
3221+
// (the single, scope-correct source — security/resolve-execution-context.ts),
3222+
// so it flows through here with no extra per-request lookup.
32523223
const user = ec?.userId
32533224
? {
32543225
userId: ec.userId,
32553226
id: ec.userId,
32563227
displayName: ec.userDisplayName ?? ec.userName ?? ec.userId,
32573228
email: ec.userEmail,
32583229
roles: Array.isArray(ec.roles) ? ec.roles : [],
3259-
permissions,
3230+
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
32603231
organizationId: ec.tenantId,
32613232
}
32623233
: undefined;

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,20 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
405405
ctx.roles!.unshift(BUILTIN_ROLE_PLATFORM_ADMIN);
406406
}
407407

408+
// [ADR-0024] Env-side AI seat. The per-agent gate (evaluateAgentAccess →
409+
// requires the `ai_seat` capability) reads ctx.permissions. Synthesize it from
410+
// the boolean `sys_user.ai_access`, read via the SAME scope-correct `ql` used
411+
// for the permission sets above — so it resolves on single-env local AND on the
412+
// sharded multi-tenant runtime, where only the per-request scope selects the
413+
// right engine (a no-arg / wrong-scope lookup returns empty and loses the seat).
414+
// Guarded (tryFind swallows errors) → can only ever ADD access, never break auth.
415+
if (userId && !ctx.permissions!.includes('ai_seat')) {
416+
const seatRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
417+
if ((seatRows?.[0] as { ai_access?: unknown } | undefined)?.ai_access === true) {
418+
ctx.permissions!.push('ai_seat');
419+
}
420+
}
421+
408422
// 4. Localization (ADR-0053 Phase 2) — reference timezone + locale resolved
409423
// once per request from the `localization` settings and carried on the
410424
// context. Consumers: formula today()/datetime, analytics date buckets,

0 commit comments

Comments
 (0)