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;