feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
This commit was merged in pull request #1465.
This commit is contained in:
@@ -176,7 +176,18 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
|
||||
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
|
||||
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
|
||||
const scopes = deriveMcpToolScopesForUser({ role });
|
||||
expect([...scopes].sort()).toEqual([...memberScopes].sort());
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
@@ -311,14 +322,20 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
|
||||
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
|
||||
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
|
||||
// ownership sees exactly what an unprivileged member with the same
|
||||
// ownership would see — here, only the one project they own, and nothing
|
||||
// tenant-wide or platform-wide.
|
||||
const fixtures = {
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant',
|
||||
name: 'same tenant, unowned',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
@@ -328,39 +345,50 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
};
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const actors = [
|
||||
makeAdminActor('role-bearing-user', 'tenant-a'),
|
||||
makePlatformAdminActor('role-bearing-user'),
|
||||
];
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
for (const actor of actors) {
|
||||
const { service } = makeService(fixtures);
|
||||
const { server, tools } = makeCapturingServer();
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
// §1.1 retirement: content visibility comes from ownership, not the
|
||||
// tenant-admin role — the acting user owns the tenant-a project.
|
||||
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
@@ -373,7 +401,29 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
// Platform role no longer derives task-write scopes (§1.1 retirement):
|
||||
// a role-derived admin actor is scope-denied before any tenant logic.
|
||||
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
service.registerTools(server, roleDerivedAdmin);
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
|
||||
).rejects.toThrow('MCP tool scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
// The tenant-scoping checks below sit behind the scope gate; exercise
|
||||
// them with explicitly granted task-write scopes (how grant-mapped
|
||||
// scopes will arrive), not with a platform role.
|
||||
tools.clear();
|
||||
const actor = createMcpActorContext({
|
||||
userId: 'tenant-admin-user',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'member',
|
||||
scopes: [
|
||||
...deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
],
|
||||
});
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
@@ -416,7 +466,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
@@ -433,10 +483,25 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
// §1.1 retirement: platform-admin no longer derives coord scopes either.
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
await expect(platformAdminTool.handler({})).rejects.toThrow(
|
||||
'MCP tool scope denied: coord:read',
|
||||
);
|
||||
|
||||
// An explicitly granted coord:read scope reaches the server-derived
|
||||
// path (caller-supplied projectPath is stripped by the schema).
|
||||
tools.clear();
|
||||
const grantedActor = createMcpActorContext({
|
||||
userId: 'granted-user',
|
||||
role: 'member',
|
||||
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
|
||||
});
|
||||
service.registerTools(server, grantedActor);
|
||||
const grantedTool = getTool(tools, 'coord_list_tasks');
|
||||
await grantedTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user