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
12 changes: 12 additions & 0 deletions src/runtime/exec-graph-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ExecGraph } from './defs.js';

const graphCache = new Map<string, ExecGraph>();

/** Drop cached graphs so workflow/agent structure changes after module reload are picked up. */
export function clearExecutionGraphCache(): void {
graphCache.clear();
}

export function getExecutionGraphCache(): Map<string, ExecGraph> {
return graphCache;
}
4 changes: 2 additions & 2 deletions src/runtime/exec-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ import {
makeFqName,
nameToPath,
} from './util.js';

const GraphCache = new Map<string, ExecGraph>();
import { getExecutionGraphCache } from './exec-graph-cache.js';

export async function generateExecutionGraph(eventName: string): Promise<ExecGraph | undefined> {
const GraphCache = getExecutionGraphCache();
const cg = GraphCache.get(eventName);
if (cg) return cg;
const wf = getWorkflowForEvent(eventName);
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import {
Agent,
defineAgentEvent,
eventAgentName,
Event,
getOneOfRef,
getRelationship,
Expand Down Expand Up @@ -2348,7 +2349,8 @@ export async function handleAgentInvocation(
agentEventInst: Instance,
env: Environment
): Promise<void> {
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);
}
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
asStringLiteralsMap,
escapeSpecialChars,
findRbacSchema,
isCoreModule,
isFqName,
makeFqName,
maybeExtends,
Expand All @@ -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,
Expand Down Expand Up @@ -1305,6 +1307,9 @@ export async function internModule(
moduleFileName?: string
): Promise<Module> {
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[]) {
Expand Down
12 changes: 10 additions & 2 deletions src/runtime/modules/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,8 +1286,16 @@ async function parseHelper(stmt: string, env: Environment): Promise<any> {
return env.getLastResult();
}

export async function findAgentByName(name: string, env: Environment): Promise<AgentInstance> {
const result = await parseHelper(`{${AgentFqName} {name? "${name}"}}`, env);
export async function findAgentByName(name: string, _env: Environment): Promise<AgentInstance> {
// 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);
Expand Down
16 changes: 10 additions & 6 deletions src/runtime/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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] = [
Expand Down
42 changes: 41 additions & 1 deletion test/runtime/agent-rbac.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
);
});
});
});
Loading