|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #4705 — the AI routes' `req.user` must carry the CAPABILITY channel, and it |
| 5 | + * must stay a channel of its own. |
| 6 | + * |
| 7 | + * `resolveAuthzContext` resolves a caller into two lists that look alike and |
| 8 | + * are not (core/src/security/resolve-authz-context.ts): |
| 9 | + * |
| 10 | + * - `permissions` — permission-SET NAMES (`admin_full_access`, |
| 11 | + * `organization_admin`, `member_default`) plus the |
| 12 | + * synthesized `ai_seat`. |
| 13 | + * - `systemPermissions` — CAPABILITIES (`manage_metadata`, `studio.access`, |
| 14 | + * `setup.access`, …), the union of every resolved |
| 15 | + * set's `systemPermissions[]`. |
| 16 | + * |
| 17 | + * `resolveExecutionContext` surfaces both, side by side. `domains/ai.ts` copied |
| 18 | + * only the first onto `req.user`, which made `/ai/*` the single route domain in |
| 19 | + * the repo where a capability gate could not be written: every other surface |
| 20 | + * tests `systemPermissions` (`domains/meta.ts`'s `manage_metadata` gate, |
| 21 | + * `action-execution.ts`, `rest/src/rest-server.ts`), so the same test written |
| 22 | + * against an AI route's `req.user.permissions` is permanently false — which |
| 23 | + * shuts the route on platform admins instead of tightening it. That is the |
| 24 | + * blocker cloud#1015 hit when it went to gate |
| 25 | + * `POST /api/v1/ai/tools/:toolName/execute`. |
| 26 | + * |
| 27 | + * These tests pin the transport, not a policy: no route in this repo gates on |
| 28 | + * the field. Three things must hold, and the third is why the first two are not |
| 29 | + * enough on their own — |
| 30 | + * |
| 31 | + * 1. a capability held on the ExecutionContext ARRIVES on |
| 32 | + * `req.user.systemPermissions`; |
| 33 | + * 2. a caller holding none gets `[]`, never `undefined` (fail-closed, and it |
| 34 | + * spares every consumer a `?? []` of its own); |
| 35 | + * 3. `permissions` still carries set names + `ai_seat` VERBATIM — the two |
| 36 | + * channels are copied side by side, never merged. Flattening one into the |
| 37 | + * other would corrupt every existing reader of `permissions` while looking |
| 38 | + * like it fixed this. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, it, expect } from 'vitest'; |
| 42 | + |
| 43 | +import { handleAIRequest } from './ai.js'; |
| 44 | +import { createDispatcherPlugin } from '../dispatcher-plugin.js'; |
| 45 | +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; |
| 46 | +import type { HttpProtocolContext } from '../http-dispatcher.js'; |
| 47 | + |
| 48 | +const TOOL_ROUTE = '/api/v1/ai/tools/:toolName/execute'; |
| 49 | + |
| 50 | +/** Captures the `req` the AI route handler is dispatched with. */ |
| 51 | +function makeDeps(seen: { req?: any }): DomainHandlerDeps { |
| 52 | + const aiService: any = { chat: async () => ({ text: 'ok' }) }; |
| 53 | + return { |
| 54 | + resolveService: (async (name: string) => (name === 'ai' ? aiService : undefined)) as any, |
| 55 | + getRegisteredAiRoutes: () => [ |
| 56 | + { |
| 57 | + method: 'POST', |
| 58 | + path: TOOL_ROUTE, |
| 59 | + auth: true, |
| 60 | + handler: async (req: any) => { |
| 61 | + seen.req = req; |
| 62 | + return { status: 200, body: { success: true, data: { ok: true } } }; |
| 63 | + }, |
| 64 | + }, |
| 65 | + ], |
| 66 | + success: (data: any) => ({ status: 200, body: { success: true, data } }), |
| 67 | + error: (message: string, httpStatus = 500) => ({ status: httpStatus, body: { success: false, error: { message } } }), |
| 68 | + routeNotFound: (route: string) => ({ status: 404, body: { success: false, error: { code: 'ROUTE_NOT_FOUND', route } } }), |
| 69 | + } as unknown as DomainHandlerDeps; |
| 70 | +} |
| 71 | + |
| 72 | +/** Dispatch `POST /ai/tools/create_object/execute` under `executionContext`. */ |
| 73 | +async function dispatchToolExecute(executionContext: any) { |
| 74 | + const seen: { req?: any } = {}; |
| 75 | + const context = { executionContext } as unknown as HttpProtocolContext; |
| 76 | + const result = await handleAIRequest( |
| 77 | + makeDeps(seen), |
| 78 | + '/ai/tools/create_object/execute', |
| 79 | + 'POST', |
| 80 | + {}, |
| 81 | + {}, |
| 82 | + context, |
| 83 | + ); |
| 84 | + return { result, user: seen.req?.user, req: seen.req }; |
| 85 | +} |
| 86 | + |
| 87 | +describe('#4705 — /ai/* req.user carries the capability channel', () => { |
| 88 | + it('surfaces ec.systemPermissions on req.user.systemPermissions', async () => { |
| 89 | + // A platform admin as `resolveAuthzContext` actually resolves one: the |
| 90 | + // set NAME in `permissions`, the capabilities it grants in |
| 91 | + // `systemPermissions` (plugin-security's `admin_full_access` declares |
| 92 | + // `manage_metadata` / `studio.access` / `setup.access` there). |
| 93 | + const { user, result } = await dispatchToolExecute({ |
| 94 | + userId: 'usr_admin', |
| 95 | + userEmail: 'admin@objectos.ai', |
| 96 | + positions: ['platform_admin'], |
| 97 | + permissions: ['admin_full_access', 'ai_seat'], |
| 98 | + systemPermissions: ['manage_users', 'manage_metadata', 'studio.access', 'setup.access'], |
| 99 | + tenantId: 'org_1', |
| 100 | + }); |
| 101 | + |
| 102 | + expect(result.handled).toBe(true); |
| 103 | + // The whole point of the issue: a capability gate on an AI route can |
| 104 | + // now be written and can actually pass for someone who holds it. |
| 105 | + expect(user.systemPermissions).toEqual([ |
| 106 | + 'manage_users', 'manage_metadata', 'studio.access', 'setup.access', |
| 107 | + ]); |
| 108 | + expect(new Set<string>(user.systemPermissions).has('manage_metadata')).toBe(true); |
| 109 | + }); |
| 110 | + |
| 111 | + it('keeps `permissions` = permission-set names + ai_seat, unmerged with capabilities', async () => { |
| 112 | + const { user } = await dispatchToolExecute({ |
| 113 | + userId: 'usr_admin', |
| 114 | + permissions: ['admin_full_access', 'ai_seat'], |
| 115 | + systemPermissions: ['manage_metadata', 'studio.access'], |
| 116 | + positions: ['platform_admin'], |
| 117 | + }); |
| 118 | + |
| 119 | + // Verbatim — the set-name channel is untouched by the addition, and |
| 120 | + // `ai_seat` (synthesized by resolveExecutionContext) still rides it. |
| 121 | + expect(user.permissions).toEqual(['admin_full_access', 'ai_seat']); |
| 122 | + expect(user.roles).toEqual(['platform_admin']); |
| 123 | + // …and neither list has absorbed the other. A capability must NOT be |
| 124 | + // readable off `permissions`, nor a set name off `systemPermissions`: |
| 125 | + // that conflation is the failure mode this issue exists to prevent. |
| 126 | + expect(user.permissions).not.toContain('manage_metadata'); |
| 127 | + expect(user.systemPermissions).not.toContain('admin_full_access'); |
| 128 | + expect(user.systemPermissions).not.toContain('ai_seat'); |
| 129 | + }); |
| 130 | + |
| 131 | + it('gives a caller with no capabilities [] — not undefined', async () => { |
| 132 | + // An ordinary member: holds an AI seat, holds no capability. The |
| 133 | + // ExecutionContext omits the field entirely (it is optional on |
| 134 | + // `ExecutionContextSchema`), which is the shape a consumer would |
| 135 | + // otherwise have to tolerate with a `?? []` of its own. |
| 136 | + const { user } = await dispatchToolExecute({ |
| 137 | + userId: 'usr_member', |
| 138 | + permissions: ['member_default', 'ai_seat'], |
| 139 | + positions: ['member'], |
| 140 | + }); |
| 141 | + |
| 142 | + expect(user.systemPermissions).toEqual([]); |
| 143 | + expect(user.systemPermissions).not.toBeUndefined(); |
| 144 | + expect(new Set<string>(user.systemPermissions).has('manage_metadata')).toBe(false); |
| 145 | + }); |
| 146 | + |
| 147 | + it('fails closed when ec.systemPermissions is not an array', async () => { |
| 148 | + const { user } = await dispatchToolExecute({ |
| 149 | + userId: 'usr_member', |
| 150 | + permissions: ['member_default'], |
| 151 | + // A malformed/stringified value must never become authority. |
| 152 | + systemPermissions: 'manage_metadata', |
| 153 | + }); |
| 154 | + |
| 155 | + expect(user.systemPermissions).toEqual([]); |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +// ── The OTHER producer of an AI-route `req.user` ──────────────────────────── |
| 160 | +// `dispatcher-plugin`'s `resolveRequestUser` backs the concrete per-route |
| 161 | +// mounts. It has no ExecutionContext to read, so it stays capability-less on |
| 162 | +// purpose — but it must say so in the SAME shape, otherwise a consumer sees |
| 163 | +// `undefined` on one path and `[]` on the other and reaches for a fallback to |
| 164 | +// paper over the difference. |
| 165 | + |
| 166 | +function makeFakeServer() { |
| 167 | + const routes: string[] = []; |
| 168 | + const handlers: Record<string, (req: any, res: any) => any> = {}; |
| 169 | + const rec = (verb: string) => (path: string, handler: any) => { |
| 170 | + routes.push(`${verb} ${path}`); |
| 171 | + handlers[`${verb} ${path}`] = handler; |
| 172 | + }; |
| 173 | + return { |
| 174 | + routes, |
| 175 | + handlers, |
| 176 | + server: { |
| 177 | + get: rec('GET'), post: rec('POST'), put: rec('PUT'), |
| 178 | + delete: rec('DELETE'), patch: rec('PATCH'), |
| 179 | + }, |
| 180 | + }; |
| 181 | +} |
| 182 | + |
| 183 | +function makeCtx(fakeServer: any, aiRoutes: any[], onRequest: (req: any) => void) { |
| 184 | + const kernel: any = { |
| 185 | + getService: () => undefined, |
| 186 | + getServiceAsync: async () => undefined, |
| 187 | + // The AIServicePlugin's cross-plugin cache the dispatcher recovers |
| 188 | + // routes from when the `ai:routes` hook fired before it was listening. |
| 189 | + __aiRoutes: aiRoutes.map((r) => ({ |
| 190 | + ...r, |
| 191 | + handler: async (req: any) => { onRequest(req); return { status: 200, body: { ok: true } }; }, |
| 192 | + })), |
| 193 | + }; |
| 194 | + const authService: any = { |
| 195 | + api: { |
| 196 | + getSession: async () => ({ |
| 197 | + user: { id: 'usr_admin', name: 'Admin', email: 'admin@objectos.ai' }, |
| 198 | + session: { activeOrganizationId: 'org_1' }, |
| 199 | + }), |
| 200 | + }, |
| 201 | + }; |
| 202 | + return { |
| 203 | + getKernel: () => kernel, |
| 204 | + getService: (name: string) => |
| 205 | + name === 'http.server' ? fakeServer : name === 'auth' ? authService : undefined, |
| 206 | + environmentId: undefined, |
| 207 | + logger: { info() {}, warn() {}, error() {}, debug() {} }, |
| 208 | + hook: () => {}, |
| 209 | + on: () => {}, |
| 210 | + } as any; |
| 211 | +} |
| 212 | + |
| 213 | +describe('#4705 — the concrete-mount producer agrees on the shape', () => { |
| 214 | + it('resolveRequestUser emits systemPermissions: [] (fail-closed, never undefined)', async () => { |
| 215 | + const { server, handlers } = makeFakeServer(); |
| 216 | + let seen: any; |
| 217 | + const ctx = makeCtx( |
| 218 | + server, |
| 219 | + [{ method: 'POST', path: '/ai/tools/:toolName/execute', description: 'x', auth: true }], |
| 220 | + (req) => { seen = req; }, |
| 221 | + ); |
| 222 | + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); |
| 223 | + await plugin.start?.(ctx); |
| 224 | + |
| 225 | + const handler = handlers[`POST ${TOOL_ROUTE}`]; |
| 226 | + expect(handler).toBeTypeOf('function'); |
| 227 | + const res = { status() { return res; }, header() { return res; }, json() { return res; } } as any; |
| 228 | + await handler({ headers: {}, body: {}, params: { toolName: 'create_object' }, query: {} }, res); |
| 229 | + |
| 230 | + expect(seen.user.userId).toBe('usr_admin'); |
| 231 | + // No ExecutionContext here → no authority, stated explicitly. Same |
| 232 | + // shape as the dispatch path, so a consumer needs no `?? []`. |
| 233 | + expect(seen.user.permissions).toEqual([]); |
| 234 | + expect(seen.user.systemPermissions).toEqual([]); |
| 235 | + }); |
| 236 | + |
| 237 | + it('mounts the /ai/* dispatch wildcard BEFORE the concrete AI routes', async () => { |
| 238 | + // Why the capability-less resolver above is not the live path: the |
| 239 | + // method-wildcards `registerAIRoutes` mounts go through |
| 240 | + // `dispatcher.dispatch()` → `domains/ai.ts`, i.e. the |
| 241 | + // ExecutionContext-backed `req.user`. They are registered earlier in |
| 242 | + // `start()`, so they answer a real `/api/v1/ai/...` request first. |
| 243 | + // Reordering these two would silently hand `/ai/*` back to the |
| 244 | + // capability-less producer — and a gate built on cloud#1015's contract |
| 245 | + // would start 403-ing platform admins again. |
| 246 | + const { server, routes } = makeFakeServer(); |
| 247 | + const ctx = makeCtx( |
| 248 | + server, |
| 249 | + [{ method: 'POST', path: '/ai/tools/:toolName/execute', description: 'x', auth: true }], |
| 250 | + () => {}, |
| 251 | + ); |
| 252 | + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); |
| 253 | + await plugin.start?.(ctx); |
| 254 | + |
| 255 | + const wildcard = routes.indexOf('POST /api/v1/ai/*'); |
| 256 | + const concrete = routes.indexOf(`POST ${TOOL_ROUTE}`); |
| 257 | + expect(wildcard).toBeGreaterThanOrEqual(0); |
| 258 | + expect(concrete).toBeGreaterThanOrEqual(0); |
| 259 | + expect(wildcard).toBeLessThan(concrete); |
| 260 | + }); |
| 261 | +}); |
0 commit comments