From 33a18a0f50a05cf4a1941f9ddc30148978a7254b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 26 Jun 2026 02:34:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20wire=20ai=5Faccess=E2=86=92ai?= =?UTF-8?q?=5Fseat=20synthesis=20into=20the=20AI=20route=20dispatch=20(ADR?= =?UTF-8?q?-0024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-user AI-seat gate (evaluateAgentAccess → requires the `ai_seat` capability) reads `req.user.permissions`, but the dispatch ExecutionContext serving `/ai/*` never carried the seat — so a SEATED user (`sys_user.ai_access=true`) was denied: `/ai/agents` returned an EMPTY catalog (and in-UI build/ask would 403), even though the #525 cap correctly counted them. The synthesis lived only on plugin-hono-server's data-route resolveCtx (which is why the cap, on the data-write path, worked but the gate did not). Add the guarded synthesis to BOTH AI req.user construction paths: - http-dispatcher.ts — the `/ai/*` wildcard dispatch (the ACTIVE path): build req.user.permissions, then read sys_user.ai_access via the DEFAULT (current per-request env) ObjectQL service and push `ai_seat` when true. NB do NOT pass context.environmentId — in the cloud runtime that is the control-plane env UUID, which keys a DISTINCT empty per-scope engine; the env's own sys_user is served by the default service (this exact mismatch returned [] in testing). - dispatcher-plugin.ts resolveRequestUser — the concrete-route mount path (defensive; guarded read via the env kernel's `data` service). Guarded system read → can only ever ADD access, never break auth (absent/false/missing-column/error → no seat, unchanged). Live-verified on a local cloud+objectos stack (OS_CLOUD_AI_SEAT_MODE=enforce): seated ai_access=true → /ai/agents=[build,ask]; false → []; restore → [build,ask]; cap still 400s past the licensed seat count. Unblocks flipping OS_CLOUD_AI_SEAT_MODE=enforce. Co-Authored-By: Claude Opus 4.8 --- packages/runtime/src/dispatcher-plugin.ts | 24 +++++++++++++++- packages/runtime/src/http-dispatcher.ts | 34 ++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 5b6f575bf7..bb8f31eadc 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -971,13 +971,35 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const sessionData = await api.getSession({ headers: headersInstance }); const userId: string | undefined = sessionData?.user?.id ?? sessionData?.session?.userId; if (!userId) return undefined; + // Env-side AI-seat synthesis (ADR-0024, sys_user.ai_access boolean + // model). The per-agent gate (evaluateAgentAccess → requires the + // `ai_seat` capability) reads `req.user.permissions`, which the AI + // routes get from THIS resolver — but it previously returned an empty + // permission set, so a SEATED user (ai_access=true) was still denied + // (empty /ai/agents catalog). Mirror plugin-hono-server's data-route + // resolveCtx: read the boolean with a GUARDED system query on the + // per-request env kernel's `sys_user` and, when true, synthesize the + // `ai_seat` capability. Absent/false/missing-column/error → no seat + // (deny, unchanged) so this can only ever ADD access, never break auth. + const permissions: string[] = []; + try { + const dataEngine: any = ctx.getService('data'); + if (dataEngine && typeof dataEngine.find === 'function') { + const uRows = await dataEngine + .find('sys_user', { where: { id: userId }, limit: 1 }, { context: { isSystem: true } }) + .catch(() => []); + if ((uRows?.[0] as any)?.ai_access === true) permissions.push('ai_seat'); + } + } catch { + /* no ai_access column / query failed → no seat (safe) */ + } return { userId, id: userId, displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId, email: sessionData?.user?.email, roles: [], - permissions: [], + permissions, organizationId: sessionData?.session?.activeOrganizationId, }; } catch { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index fe0531ff90..b930a73970 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -3217,6 +3217,38 @@ export class HttpDispatcher { // for anonymous requests — the route's own `auth: true` guard // is enforced by upstream middleware. const ec: any = context.executionContext; + // Build the caller's permission set, then synthesize the env-side + // AI seat. The per-agent gate (evaluateAgentAccess → requires the + // `ai_seat` capability) reads `req.user.permissions`, but the + // dispatch ExecutionContext does NOT carry the seat — so a SEATED + // user (sys_user.ai_access=true) was denied (empty /ai/agents + // catalog). Read the boolean on the per-request (env) kernel via the + // env-scoped ObjectQL service and, when true, ADD `ai_seat`. Copy the + // array first so we never mutate the shared ec. Guarded system read → + // can only ever ADD access, never break auth (absent/false/error → + // no seat, unchanged). + const permissions: string[] = ec?.userId && Array.isArray(ec.permissions) + ? [...ec.permissions] + : []; + if (ec?.userId && !permissions.includes('ai_seat')) { + try { + // Resolve via the DEFAULT (current per-request env) ObjectQL + // service. Do NOT pass `context.environmentId`: in the cloud + // runtime that is the control-plane env UUID, which keys a + // DISTINCT, empty per-scope engine — the env's own `sys_user` + // (where `ai_access` lives) is served by the default service. + // Passing the id yields an empty result and the seat is lost. + const ql: any = await this.getObjectQLService(); + if (ql && typeof ql.find === 'function') { + const rows = await ql + .find('sys_user', { where: { id: ec.userId }, limit: 1 }, { context: { isSystem: true } }) + .catch(() => []); + if ((rows?.[0] as any)?.ai_access === true) permissions.push('ai_seat'); + } + } catch { + /* no ai_access column / lookup failed → no seat (safe) */ + } + } const user = ec?.userId ? { userId: ec.userId, @@ -3224,7 +3256,7 @@ export class HttpDispatcher { displayName: ec.userDisplayName ?? ec.userName ?? ec.userId, email: ec.userEmail, roles: Array.isArray(ec.roles) ? ec.roles : [], - permissions: Array.isArray(ec.permissions) ? ec.permissions : [], + permissions, organizationId: ec.tenantId, } : undefined;