diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index bb8f31eadc..8cb135e048 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -971,35 +971,17 @@ 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) */ - } + // AI-route req.user permissions (incl. the synthesized `ai_seat`) are + // populated from the ExecutionContext by the /ai/* dispatch path + // (http-dispatcher → resolveExecutionContext, the single scope-correct + // source). This concrete-route resolver returns an empty set. 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 b930a73970..1b6cb3ef54 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -3217,38 +3217,9 @@ 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) */ - } - } + // `ai_seat` is synthesized into ec.permissions by resolveExecutionContext + // (the single, scope-correct source — security/resolve-execution-context.ts), + // so it flows through here with no extra per-request lookup. const user = ec?.userId ? { userId: ec.userId, @@ -3256,7 +3227,7 @@ export class HttpDispatcher { displayName: ec.userDisplayName ?? ec.userName ?? ec.userId, email: ec.userEmail, roles: Array.isArray(ec.roles) ? ec.roles : [], - permissions, + permissions: Array.isArray(ec.permissions) ? ec.permissions : [], organizationId: ec.tenantId, } : undefined; diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 1a193fadc8..1aef758aaa 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -405,6 +405,20 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise