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:
@@ -1,66 +1,403 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProjectService } from '../src/services/project.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||
return {
|
||||
id: 'proj-1',
|
||||
name: 'test-project',
|
||||
description: '',
|
||||
ownerId: 'user-1',
|
||||
proxyMode: 'direct',
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
servers: [],
|
||||
members: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
name: 'test-server',
|
||||
description: '',
|
||||
packageName: '@mcp/test',
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
env: [],
|
||||
healthCheck: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
templateName: null,
|
||||
templateVersion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({
|
||||
id: 'proj-1',
|
||||
create: vi.fn(async (data) => makeProject({
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
description: data.description,
|
||||
ownerId: data.ownerId,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
proxyMode: data.proxyMode,
|
||||
llmProvider: data.llmProvider ?? null,
|
||||
llmModel: data.llmModel ?? null,
|
||||
})),
|
||||
update: vi.fn(async (id) => ({
|
||||
id, name: 'test', description: '', ownerId: 'u1', version: 2,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setServers: vi.fn(async () => {}),
|
||||
setMembers: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockServerRepo(): IMcpServerRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => makeServer()),
|
||||
update: vi.fn(async () => makeServer()),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSecretRepo(): ISecretRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
||||
update: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockUserRepo(): IUserRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByEmail: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({
|
||||
id: 'u-1', email: 'test@example.com', name: null, role: 'user',
|
||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
count: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
||||
let userRepo: ReturnType<typeof mockUserRepo>;
|
||||
let service: ProjectService;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRepo = mockProjectRepo();
|
||||
service = new ProjectService(projectRepo);
|
||||
serverRepo = mockServerRepo();
|
||||
secretRepo = mockSecretRepo();
|
||||
userRepo = mockUserRepo();
|
||||
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a project', async () => {
|
||||
it('creates a basic project', async () => {
|
||||
// After create, getById is called to re-fetch with relations
|
||||
const created = makeProject({ name: 'my-project', ownerId: 'user-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
||||
|
||||
const result = await service.create({ name: 'my-project' }, 'user-1');
|
||||
expect(result.name).toBe('my-project');
|
||||
expect(result.ownerId).toBe('user-1');
|
||||
expect(projectRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when name exists', async () => {
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject());
|
||||
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('validates input', async () => {
|
||||
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates project with servers (resolves names)', async () => {
|
||||
const srv1 = makeServer({ id: 'srv-1', name: 'github' });
|
||||
const srv2 = makeServer({ id: 'srv-2', name: 'slack' });
|
||||
vi.mocked(serverRepo.findByName).mockImplementation(async (name) => {
|
||||
if (name === 'github') return srv1;
|
||||
if (name === 'slack') return srv2;
|
||||
return null;
|
||||
});
|
||||
|
||||
const created = makeProject({ id: 'proj-new' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
||||
id: 'proj-new',
|
||||
servers: [
|
||||
{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } },
|
||||
{ id: 'ps-2', server: { id: 'srv-2', name: 'slack' } },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1');
|
||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-2']);
|
||||
expect(result.servers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates project with members (resolves emails)', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockImplementation(async (email) => {
|
||||
if (email === 'alice@test.com') {
|
||||
return { id: 'u-alice', email: 'alice@test.com', name: 'Alice', role: 'user', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const created = makeProject({ id: 'proj-new' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
||||
id: 'proj-new',
|
||||
members: [
|
||||
{ id: 'pm-1', user: { id: 'u-alice', email: 'alice@test.com', name: 'Alice' } },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await service.create({
|
||||
name: 'my-project',
|
||||
members: ['alice@test.com'],
|
||||
}, 'user-1');
|
||||
|
||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-new', ['u-alice']);
|
||||
expect(result.members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates project with proxyMode and llmProvider', async () => {
|
||||
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
||||
|
||||
const result = await service.create({
|
||||
name: 'filtered-proj',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'openai',
|
||||
}, 'user-1');
|
||||
|
||||
expect(result.proxyMode).toBe('filtered');
|
||||
expect(result.llmProvider).toBe('openai');
|
||||
});
|
||||
|
||||
it('rejects filtered project without llmProvider', async () => {
|
||||
await expect(
|
||||
service.create({ name: 'bad-proj', proxyMode: 'filtered' }, 'user-1'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when server name resolution fails', async () => {
|
||||
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when member email resolution fails', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'my-project',
|
||||
members: ['nobody@test.com'],
|
||||
}, 'user-1'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('returns project when found', async () => {
|
||||
const proj = makeProject({ id: 'found' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
||||
const result = await service.getById('found');
|
||||
expect(result.id).toBe('found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndGet', () => {
|
||||
it('finds by ID first', async () => {
|
||||
const proj = makeProject({ id: 'proj-id' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
||||
const result = await service.resolveAndGet('proj-id');
|
||||
expect(result.id).toBe('proj-id');
|
||||
});
|
||||
|
||||
it('falls back to name when ID not found', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
||||
const proj = makeProject({ name: 'my-name' });
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
|
||||
const result = await service.resolveAndGet('my-name');
|
||||
expect(result.name).toBe('my-name');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when neither ID nor name found', async () => {
|
||||
await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates servers (full replacement)', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
const srv = makeServer({ id: 'srv-new', name: 'new-srv' });
|
||||
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
||||
|
||||
await service.update('proj-1', { servers: ['new-srv'] });
|
||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
||||
});
|
||||
|
||||
it('updates members (full replacement)', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue({
|
||||
id: 'u-bob', email: 'bob@test.com', name: 'Bob', role: 'user',
|
||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.update('proj-1', { members: ['bob@test.com'] });
|
||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-1', ['u-bob']);
|
||||
});
|
||||
|
||||
it('updates proxyMode', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' });
|
||||
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'anthropic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes project', async () => {
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await service.delete('p1');
|
||||
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when project does not exist', async () => {
|
||||
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMcpConfig', () => {
|
||||
it('generates direct mode config with STDIO servers', async () => {
|
||||
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
name: 'my-proj',
|
||||
proxyMode: 'direct',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(config.mcpServers['github']).toBeDefined();
|
||||
expect(config.mcpServers['github']?.command).toBe('npx');
|
||||
expect(config.mcpServers['github']?.args).toEqual(['-y', '@mcp/github']);
|
||||
});
|
||||
|
||||
it('generates direct mode config with SSE servers (URL-based)', async () => {
|
||||
const srv = makeServer({ id: 'srv-2', name: 'sse-server', transport: 'SSE' });
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
proxyMode: 'direct',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-2', name: 'sse-server' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(config.mcpServers['sse-server']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server');
|
||||
expect(config.mcpServers['sse-server']?.command).toBeUndefined();
|
||||
});
|
||||
|
||||
it('generates filtered mode config (single mcplocal entry)', async () => {
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
name: 'filtered-proj',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'openai',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(Object.keys(config.mcpServers)).toHaveLength(1);
|
||||
expect(config.mcpServers['filtered-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/filtered-proj');
|
||||
});
|
||||
|
||||
it('resolves by name for mcp-config', async () => {
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
name: 'my-proj',
|
||||
proxyMode: 'direct',
|
||||
servers: [],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
||||
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
|
||||
|
||||
const config = await service.generateMcpConfig('my-proj');
|
||||
expect(config.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('includes env for STDIO servers', async () => {
|
||||
const srv = makeServer({
|
||||
id: 'srv-1',
|
||||
name: 'github',
|
||||
transport: 'STDIO',
|
||||
env: [{ name: 'GITHUB_TOKEN', value: 'tok123' }],
|
||||
});
|
||||
const project = makeProject({
|
||||
id: 'proj-1',
|
||||
proxyMode: 'direct',
|
||||
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
||||
});
|
||||
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
|
||||
|
||||
const config = await service.generateMcpConfig('proj-1');
|
||||
expect(config.mcpServers['github']?.env?.['GITHUB_TOKEN']).toBe('tok123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user