diff --git a/src/runtime/exec-graph-cache.ts b/src/runtime/exec-graph-cache.ts new file mode 100644 index 00000000..435e9b09 --- /dev/null +++ b/src/runtime/exec-graph-cache.ts @@ -0,0 +1,12 @@ +import type { ExecGraph } from './defs.js'; + +const graphCache = new Map(); + +/** Drop cached graphs so workflow/agent structure changes after module reload are picked up. */ +export function clearExecutionGraphCache(): void { + graphCache.clear(); +} + +export function getExecutionGraphCache(): Map { + return graphCache; +} diff --git a/src/runtime/exec-graph.ts b/src/runtime/exec-graph.ts index 8581c6df..1dc29b55 100644 --- a/src/runtime/exec-graph.ts +++ b/src/runtime/exec-graph.ts @@ -56,10 +56,10 @@ import { makeFqName, nameToPath, } from './util.js'; - -const GraphCache = new Map(); +import { getExecutionGraphCache } from './exec-graph-cache.js'; export async function generateExecutionGraph(eventName: string): Promise { + const GraphCache = getExecutionGraphCache(); const cg = GraphCache.get(eventName); if (cg) return cg; const wf = getWorkflowForEvent(eventName); diff --git a/src/runtime/interpreter.ts b/src/runtime/interpreter.ts index 952ff540..75d908b3 100644 --- a/src/runtime/interpreter.ts +++ b/src/runtime/interpreter.ts @@ -34,6 +34,7 @@ import { import { Agent, defineAgentEvent, + eventAgentName, Event, getOneOfRef, getRelationship, @@ -2348,7 +2349,8 @@ export async function handleAgentInvocation( agentEventInst: Instance, env: Environment ): Promise { - const agent: AgentInstance = await findAgentByName(agentEventInst.name, env); + const agentLookupName = eventAgentName(agentEventInst) ?? agentEventInst.name; + const agent: AgentInstance = await findAgentByName(agentLookupName, env); if (agent.role) { env.setAssumedRole(agent.role); } diff --git a/src/runtime/loader.ts b/src/runtime/loader.ts index 1548b122..82ede245 100644 --- a/src/runtime/loader.ts +++ b/src/runtime/loader.ts @@ -80,6 +80,7 @@ import { asStringLiteralsMap, escapeSpecialChars, findRbacSchema, + isCoreModule, isFqName, makeFqName, maybeExtends, @@ -105,6 +106,7 @@ import { parseStatement, parseWorkflow, } from '../language/parser.js'; +import { clearExecutionGraphCache } from './exec-graph-cache.js'; import { logger } from './logger.js'; import { Environment, @@ -1305,6 +1307,9 @@ export async function internModule( moduleFileName?: string ): Promise { const mn = module.name; + if (!isCoreModule(mn)) { + clearExecutionGraphCache(); + } const r = addModule(mn); // Process imports sequentially to ensure all JS modules are loaded before definitions for (const imp of module.imports as Import[]) { diff --git a/src/runtime/modules/ai.ts b/src/runtime/modules/ai.ts index 91cc5071..8f0e5f65 100644 --- a/src/runtime/modules/ai.ts +++ b/src/runtime/modules/ai.ts @@ -1286,8 +1286,16 @@ async function parseHelper(stmt: string, env: Environment): Promise { return env.getLastResult(); } -export async function findAgentByName(name: string, env: Environment): Promise { - const result = await parseHelper(`{${AgentFqName} {name? "${name}"}}`, env); +export async function findAgentByName(name: string, _env: Environment): Promise { + // Match findProviderForLLM: Agent rows are upserted at load under GlobalEnvironment / bootstrap + // tenant. A request env (e.g. after setActiveEvent) may not see those rows; use a GlobalEnvironment + // child for the lookup. + const lookupEnv = new Environment('agent-by-name-lookup', GlobalEnvironment); + const result = await parseAndEvaluateStatement( + `{${AgentFqName} {name? "${name}"}}`, + undefined, + lookupEnv + ); if (result instanceof Array && result.length > 0) { const agentInstance: Instance = result[0]; return AgentInstance.FromInstance(agentInstance); diff --git a/src/runtime/modules/auth.ts b/src/runtime/modules/auth.ts index ed7a1b6b..29e091c8 100644 --- a/src/runtime/modules/auth.ts +++ b/src/runtime/modules/auth.ts @@ -934,6 +934,11 @@ async function updatePermissionCacheForRole(role: string, env: Environment) { } } +/** Matches the built-in superuser role name (case-insensitive). */ +function isGlobalAdminRoleName(role: string | undefined | null): boolean { + return typeof role === 'string' && role.length > 0 && role.toLowerCase() === 'admin'; +} + export async function userHasPermissions( userId: string, resourceFqName: string, @@ -959,6 +964,10 @@ export async function userHasPermissions( } UserRoleCache.set(userId, userRoles); } + // Real admin users keep full access even when agents/workflows set assumedRole (narrowing). + if (userRoles?.some((r: string) => isGlobalAdminRoleName(r))) { + return true; + } let tempRoles = userRoles; const assumedRole = env.getAssumedRole(); if (assumedRole) { @@ -967,12 +976,7 @@ export async function userHasPermissions( await updatePermissionCacheForRole(assumedRole, env); } } - if ( - tempRoles && - tempRoles.find((role: string) => { - return role === 'admin'; - }) - ) { + if (tempRoles?.some((r: string) => isGlobalAdminRoleName(r))) { return true; } const [c, r, u, d] = [ diff --git a/test/runtime/agent-rbac.test.ts b/test/runtime/agent-rbac.test.ts index d3aa7940..c0ffee9f 100644 --- a/test/runtime/agent-rbac.test.ts +++ b/test/runtime/agent-rbac.test.ts @@ -1,5 +1,5 @@ import { assert, describe, test } from 'vitest'; -import { assignUserToRole, createUser } from '../../src/runtime/modules/auth.js'; +import { assignUserToRole, createRole, createUser } from '../../src/runtime/modules/auth.js'; import { Environment, parseAndEvaluateStatement } from '../../src/runtime/interpreter.js'; import { isInstanceOfType } from '../../src/runtime/module.js'; import { doInternModule, expectError } from '../util.js'; @@ -147,4 +147,44 @@ describe('Agent RBAC role enforcement', () => { ); }); }); + + test('real admin role bypasses assumedRole narrowing', async () => { + await callWithRbac(async () => { + await doInternModule( + 'AgentRbacAdmin', + `entity Note { + id Int @id, + body String, + @rbac [(roles: [reader], allow: [read]), + (roles: [writer], allow: [create, read, update])] + }` + ); + + const userId = crypto.randomUUID(); + const env = new Environment(); + async function setup() { + await createUser(userId, 'admin-agent@test.com', 'Admin', 'User', env); + await createRole('admin', env); + assert( + (await assignUserToRole(userId, 'admin', env)) === true, + 'assign admin role' + ); + } + await env.callInTransaction(setup); + + const envWithRole = new Environment('agent-env-admin'); + envWithRole.setActiveUser(userId); + envWithRole.setAssumedRole('reader'); + + const n1 = await parseAndEvaluateStatement( + `{AgentRbacAdmin/Note {id 1, body "admin create under reader assumedRole"}}`, + userId, + envWithRole + ); + assert( + isInstanceOfType(n1, 'AgentRbacAdmin/Note'), + 'Admin should create despite reader assumedRole' + ); + }); + }); });