Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 5 additions & 23 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 4 additions & 33 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3217,46 +3217,17 @@ 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,
id: ec.userId,
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;
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,20 @@
ctx.roles!.unshift(BUILTIN_ROLE_PLATFORM_ADMIN);
}

// [ADR-0024] Env-side AI seat. The per-agent gate (evaluateAgentAccess →
// requires the `ai_seat` capability) reads ctx.permissions. Synthesize it from
// the boolean `sys_user.ai_access`, read via the SAME scope-correct `ql` used
// for the permission sets above — so it resolves on single-env local AND on the
// sharded multi-tenant runtime, where only the per-request scope selects the
// right engine (a no-arg / wrong-scope lookup returns empty and loses the seat).
// Guarded (tryFind swallows errors) → can only ever ADD access, never break auth.
if (userId && !ctx.permissions!.includes('ai_seat')) {

Check warning

Code scanning / CodeQL

Useless conditional Warning

This use of variable 'userId' always evaluates to true.
const seatRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
if ((seatRows?.[0] as { ai_access?: unknown } | undefined)?.ai_access === true) {
ctx.permissions!.push('ai_seat');
}
}

// 4. Localization (ADR-0053 Phase 2) — reference timezone + locale resolved
// once per request from the `localization` settings and carried on the
// context. Consumers: formula today()/datetime, analytics date buckets,
Expand Down