feat: granular RBAC with resource/operation bindings, users, groups
- Replace admin role with granular roles: view, create, delete, edit, run - Two binding types: resource bindings (role+resource+optional name) and operation bindings (role:run + action like backup, logs, impersonate) - Name-scoped resource bindings for per-instance access control - Remove role from project members (all permissions via RBAC) - Add users, groups, RBAC CRUD endpoints and CLI commands - describe user/group shows all RBAC access (direct + inherited) - create rbac supports --subject, --binding, --operation flags - Backup/restore handles users, groups, RBAC definitions - mcplocal project-based MCP endpoint discovery - Full test coverage for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
229
src/mcpd/tests/rbac-definition-service.test.ts
Normal file
229
src/mcpd/tests/rbac-definition-service.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RbacDefinitionService } from '../src/services/rbac-definition.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||
import type { RbacDefinition } from '@prisma/client';
|
||||
|
||||
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
|
||||
return {
|
||||
id: 'def-1',
|
||||
name: 'test-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockRepo(): IRbacDefinitionRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makeDef({ name: data.name, subjects: data.subjects, roleBindings: data.roleBindings })),
|
||||
update: vi.fn(async (id, data) => makeDef({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RbacDefinitionService', () => {
|
||||
let repo: ReturnType<typeof mockRepo>;
|
||||
let service: RbacDefinitionService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mockRepo();
|
||||
service = new RbacDefinitionService(repo);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all definitions', async () => {
|
||||
const defs = await service.list();
|
||||
expect(repo.findAll).toHaveBeenCalled();
|
||||
expect(defs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns definition when found', async () => {
|
||||
const def = makeDef();
|
||||
vi.mocked(repo.findById).mockResolvedValue(def);
|
||||
const result = await service.getById('def-1');
|
||||
expect(result.id).toBe('def-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByName', () => {
|
||||
it('returns definition when found', async () => {
|
||||
const def = makeDef();
|
||||
vi.mocked(repo.findByName).mockResolvedValue(def);
|
||||
const result = await service.getByName('test-rbac');
|
||||
expect(result.name).toBe('test-rbac');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getByName('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a definition with valid input', async () => {
|
||||
const result = await service.create({
|
||||
name: 'new-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
});
|
||||
expect(result.name).toBe('new-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(repo.findByName).mockResolvedValue(makeDef());
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'test-rbac',
|
||||
subjects: [{ kind: 'User', name: 'bob@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('throws on missing subjects', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing roleBindings', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid role', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'superadmin', resource: '*' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid subject kind', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'bad-rbac',
|
||||
subjects: [{ kind: 'Robot', name: 'bot-1' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid name format', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'Invalid Name!',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('normalizes singular resource names to plural', async () => {
|
||||
await service.create({
|
||||
name: 'singular-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'server' },
|
||||
{ role: 'edit', resource: 'secret', name: 'my-secret' },
|
||||
],
|
||||
});
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings[0]!.resource).toBe('servers');
|
||||
expect(call.roleBindings[1]!.resource).toBe('secrets');
|
||||
expect(call.roleBindings[1]!.name).toBe('my-secret');
|
||||
});
|
||||
|
||||
it('creates a definition with operation bindings', async () => {
|
||||
const result = await service.create({
|
||||
name: 'ops-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'run', action: 'logs' }],
|
||||
});
|
||||
expect(result.name).toBe('ops-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings[0]!.action).toBe('logs');
|
||||
});
|
||||
|
||||
it('creates a definition with mixed resource and operation bindings', async () => {
|
||||
const result = await service.create({
|
||||
name: 'mixed-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
],
|
||||
});
|
||||
expect(result.name).toBe('mixed-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings).toHaveLength(2);
|
||||
expect(call.roleBindings[0]!.resource).toBe('servers');
|
||||
expect(call.roleBindings[1]!.action).toBe('logs');
|
||||
});
|
||||
|
||||
it('creates a definition with name-scoped resource binding', async () => {
|
||||
const result = await service.create({
|
||||
name: 'scoped-rbac',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
||||
});
|
||||
expect(result.name).toBe('scoped-rbac');
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
const call = vi.mocked(repo.create).mock.calls[0]![0];
|
||||
expect(call.roleBindings[0]!.resource).toBe('servers');
|
||||
expect(call.roleBindings[0]!.name).toBe('my-ha');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates an existing definition', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeDef());
|
||||
await service.update('def-1', { subjects: [{ kind: 'User', name: 'bob@example.com' }] });
|
||||
expect(repo.update).toHaveBeenCalledWith('def-1', {
|
||||
subjects: [{ kind: 'User', name: 'bob@example.com' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundError when definition does not exist', async () => {
|
||||
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes an existing definition', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeDef());
|
||||
await service.delete('def-1');
|
||||
expect(repo.delete).toHaveBeenCalledWith('def-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when definition does not exist', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user