134 lines
5.3 KiB
TypeScript
134 lines
5.3 KiB
TypeScript
|
|
import { describe, it, expect, vi } from 'vitest';
|
||
|
|
|
||
|
|
import type { RbacService } from '../../src/services/rbac.service.js';
|
||
|
|
import {
|
||
|
|
RunAuthorityResolver,
|
||
|
|
restrictedContextNotice,
|
||
|
|
unscopedAuthority,
|
||
|
|
} from '../../src/services/run-authority.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* An agent carries a project's prompts AND its MCP tools. Before this, the only
|
||
|
|
* check on a chat turn was `run:agents:<name>` — so anyone who could talk to an
|
||
|
|
* agent inherited tool access to every project that agent had joined.
|
||
|
|
*
|
||
|
|
* The rule these pin: knowledge travels with the agent, action is bounded by
|
||
|
|
* whoever invoked it.
|
||
|
|
*/
|
||
|
|
function rbac(allowed: string[]): RbacService {
|
||
|
|
return {
|
||
|
|
canAccess: vi.fn(async (_u: string, action: string, resource: string, name?: string) =>
|
||
|
|
action === 'run' && resource === 'projects' && name !== undefined && allowed.includes(name),
|
||
|
|
),
|
||
|
|
} as unknown as RbacService;
|
||
|
|
}
|
||
|
|
|
||
|
|
const SRE = { id: 'p-sre', name: 'sre' };
|
||
|
|
const PAY = { id: 'p-pay', name: 'payments' };
|
||
|
|
|
||
|
|
describe('caller authority', () => {
|
||
|
|
it('grants tools only for projects the caller can reach', async () => {
|
||
|
|
const r = new RunAuthorityResolver(rbac(['sre']));
|
||
|
|
const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] });
|
||
|
|
|
||
|
|
expect([...a.toolProjects]).toEqual(['p-sre']);
|
||
|
|
expect(a.restrictedProjects).toEqual(['payments']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('still lists the unreachable project as restricted, not absent', async () => {
|
||
|
|
// The distinction matters: the prompts DO load, so the model has to be told
|
||
|
|
// it is holding context it cannot act on.
|
||
|
|
const r = new RunAuthorityResolver(rbac([]));
|
||
|
|
const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] });
|
||
|
|
|
||
|
|
expect(a.toolProjects.size).toBe(0);
|
||
|
|
expect(a.restrictedProjects).toEqual(['sre', 'payments']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('gives full tool access when the caller can reach everything', async () => {
|
||
|
|
const r = new RunAuthorityResolver(rbac(['sre', 'payments']));
|
||
|
|
const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] });
|
||
|
|
expect(a.toolProjects.size).toBe(2);
|
||
|
|
expect(a.restrictedProjects).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('asks RBAC the same question the project-chat route asks', async () => {
|
||
|
|
// Reusing `run:projects:<name>` rather than inventing a parallel permission
|
||
|
|
// is the point — one grant means the same thing on both paths.
|
||
|
|
const svc = rbac(['sre']);
|
||
|
|
await new RunAuthorityResolver(svc).resolve({ kind: 'caller', userId: 'u1', projects: [SRE] });
|
||
|
|
expect(svc.canAccess).toHaveBeenCalledWith('u1', 'run', 'projects', 'sre');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('fails closed with no user', async () => {
|
||
|
|
const r = new RunAuthorityResolver(rbac(['sre']));
|
||
|
|
const a = await r.resolve({ kind: 'caller', projects: [SRE] });
|
||
|
|
expect(a.toolProjects.size).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('fails closed when RBAC is not wired', async () => {
|
||
|
|
// Older harnesses construct ChatService without it. Knowledge and no tools
|
||
|
|
// is the safe degradation; the reverse would be a silent grant.
|
||
|
|
const a = await new RunAuthorityResolver().resolve({ kind: 'caller', userId: 'u1', projects: [SRE] });
|
||
|
|
expect(a.toolProjects.size).toBe(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('McpToken project ceiling', () => {
|
||
|
|
it('refuses projects outside the token binding even when RBAC would allow', async () => {
|
||
|
|
// `project` travels in the request BODY, so the URL-based token scope check
|
||
|
|
// in main.ts never sees it. Without this a token for sre could drive an
|
||
|
|
// agent into payments' tools.
|
||
|
|
const r = new RunAuthorityResolver(rbac(['sre', 'payments']));
|
||
|
|
const a = await r.resolve({
|
||
|
|
kind: 'caller', userId: 'u1', projects: [SRE, PAY], mcpTokenProjectName: 'sre',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect([...a.toolProjects]).toEqual(['p-sre']);
|
||
|
|
expect(a.restrictedProjects).toEqual(['payments']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not widen a token beyond what its owner may reach', async () => {
|
||
|
|
const r = new RunAuthorityResolver(rbac([]));
|
||
|
|
const a = await r.resolve({
|
||
|
|
kind: 'caller', userId: 'u1', projects: [SRE], mcpTokenProjectName: 'sre',
|
||
|
|
});
|
||
|
|
expect(a.toolProjects.size).toBe(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('agent authority', () => {
|
||
|
|
it('takes every membership without consulting any user', async () => {
|
||
|
|
const svc = rbac([]);
|
||
|
|
const a = await new RunAuthorityResolver(svc).resolve({ kind: 'agent', projects: [SRE, PAY] });
|
||
|
|
|
||
|
|
expect(a.toolProjects.size).toBe(2);
|
||
|
|
expect(a.restrictedProjects).toEqual([]);
|
||
|
|
expect(svc.canAccess).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('service-account authority', () => {
|
||
|
|
it('refuses rather than running on a forgeable identity', async () => {
|
||
|
|
// A ServiceAccount today is an unauthenticated `x-service-account` header.
|
||
|
|
// Honouring it would grant tool access on a client-supplied string, so this
|
||
|
|
// fails loudly until there is a real credential behind it.
|
||
|
|
await expect(
|
||
|
|
new RunAuthorityResolver(rbac(['sre'])).resolve({ kind: 'service-account', projects: [SRE] }),
|
||
|
|
).rejects.toThrow(/not implemented|authenticated identity/);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('the boundary notice', () => {
|
||
|
|
it('names the projects and says the tools are absent', () => {
|
||
|
|
const n = restrictedContextNotice(['payments', 'sre']);
|
||
|
|
expect(n).toContain('payments, sre');
|
||
|
|
expect(n).toMatch(/NO tools/);
|
||
|
|
expect(n).toMatch(/not reproduce its contents verbatim/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('unscoped authority grants nothing', () => {
|
||
|
|
expect(unscopedAuthority('u1').toolProjects.size).toBe(0);
|
||
|
|
});
|
||
|
|
});
|