Skip to content

Commit 635849f

Browse files
os-zhuangclaude
andauthored
fix(runtime): wire ai_access→ai_seat synthesis into the AI route dispatch (ADR-0024) (#2334)
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 <noreply@anthropic.com>
1 parent 1b00ba2 commit 635849f

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,13 +971,35 @@ 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+
}
974996
return {
975997
userId,
976998
id: userId,
977999
displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId,
9781000
email: sessionData?.user?.email,
9791001
roles: [],
980-
permissions: [],
1002+
permissions,
9811003
organizationId: sessionData?.session?.activeOrganizationId,
9821004
};
9831005
} catch {

packages/runtime/src/http-dispatcher.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3217,14 +3217,46 @@ 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+
}
32203252
const user = ec?.userId
32213253
? {
32223254
userId: ec.userId,
32233255
id: ec.userId,
32243256
displayName: ec.userDisplayName ?? ec.userName ?? ec.userId,
32253257
email: ec.userEmail,
32263258
roles: Array.isArray(ec.roles) ? ec.roles : [],
3227-
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
3259+
permissions,
32283260
organizationId: ec.tenantId,
32293261
}
32303262
: undefined;

0 commit comments

Comments
 (0)