feat: granular RBAC with resource/operation bindings, users, groups
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- 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:
Michal
2026-02-23 11:05:19 +00:00
parent a6b5e24a8d
commit dcda93d179
67 changed files with 7256 additions and 498 deletions

View File

@@ -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');
});
});
});