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

@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GroupService } from '../src/services/group.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IGroupRepository, GroupWithMembers } from '../src/repositories/group.repository.js';
import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js';
import type { Group } from '@prisma/client';
function makeGroup(overrides: Partial<Group> = {}): Group {
return {
id: 'grp-1',
name: 'developers',
description: 'Dev team',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeGroupWithMembers(overrides: Partial<Group> = {}, members: GroupWithMembers['members'] = []): GroupWithMembers {
return {
...makeGroup(overrides),
members,
};
}
function makeUser(overrides: Partial<SafeUser> = {}): SafeUser {
return {
id: 'user-1',
email: 'alice@example.com',
name: 'Alice',
role: 'USER',
provider: null,
externalId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockGroupRepo(): IGroupRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => makeGroup({ name: data.name, description: data.description ?? '' })),
update: vi.fn(async (id, data) => makeGroup({ id, description: data.description ?? '' })),
delete: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
findGroupsForUser: 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 () => makeUser()),
delete: vi.fn(async () => {}),
count: vi.fn(async () => 0),
};
}
describe('GroupService', () => {
let groupRepo: ReturnType<typeof mockGroupRepo>;
let userRepo: ReturnType<typeof mockUserRepo>;
let service: GroupService;
beforeEach(() => {
groupRepo = mockGroupRepo();
userRepo = mockUserRepo();
service = new GroupService(groupRepo, userRepo);
});
describe('list', () => {
it('returns empty list', async () => {
const result = await service.list();
expect(result).toEqual([]);
expect(groupRepo.findAll).toHaveBeenCalled();
});
it('returns groups with members', async () => {
const groups = [
makeGroupWithMembers({ id: 'g1', name: 'admins' }, [
{ id: 'gm-1', user: { id: 'u1', email: 'a@b.com', name: 'A' } },
]),
];
vi.mocked(groupRepo.findAll).mockResolvedValue(groups);
const result = await service.list();
expect(result).toHaveLength(1);
expect(result[0].members).toHaveLength(1);
});
});
describe('create', () => {
it('creates a group without members', async () => {
const created = makeGroupWithMembers({ name: 'my-group', description: '' }, []);
vi.mocked(groupRepo.findById).mockResolvedValue(created);
const result = await service.create({ name: 'my-group' });
expect(result.name).toBe('my-group');
expect(groupRepo.create).toHaveBeenCalledWith({ name: 'my-group', description: '' });
expect(groupRepo.setMembers).not.toHaveBeenCalled();
});
it('creates a group with members', async () => {
const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' });
const bob = makeUser({ id: 'u-bob', email: 'bob@example.com', name: 'Bob' });
vi.mocked(userRepo.findByEmail).mockImplementation(async (email: string) => {
if (email === 'alice@example.com') return alice;
if (email === 'bob@example.com') return bob;
return null;
});
const created = makeGroupWithMembers({ name: 'team' }, [
{ id: 'gm-1', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } },
{ id: 'gm-2', user: { id: 'u-bob', email: 'bob@example.com', name: 'Bob' } },
]);
vi.mocked(groupRepo.findById).mockResolvedValue(created);
const result = await service.create({
name: 'team',
members: ['alice@example.com', 'bob@example.com'],
});
expect(groupRepo.setMembers).toHaveBeenCalledWith('grp-1', ['u-alice', 'u-bob']);
expect(result.members).toHaveLength(2);
});
it('throws ConflictError when name exists', async () => {
vi.mocked(groupRepo.findByName).mockResolvedValue(makeGroupWithMembers({ name: 'taken' }));
await expect(service.create({ name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError for unknown member email', async () => {
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
await expect(
service.create({ name: 'team', members: ['unknown@example.com'] }),
).rejects.toThrow(NotFoundError);
});
it('validates input', async () => {
await expect(service.create({ name: '' })).rejects.toThrow();
await expect(service.create({ name: 'UPPERCASE' })).rejects.toThrow();
});
});
describe('getById', () => {
it('returns group when found', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const result = await service.getById('g1');
expect(result.id).toBe('g1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('getByName', () => {
it('returns group when found', async () => {
const group = makeGroupWithMembers({ name: 'admins' });
vi.mocked(groupRepo.findByName).mockResolvedValue(group);
const result = await service.getByName('admins');
expect(result.name).toBe('admins');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getByName('missing')).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('updates description', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const updated = makeGroupWithMembers({ id: 'g1', description: 'new desc' });
// After update, getById is called again to return fresh data
vi.mocked(groupRepo.findById).mockResolvedValue(updated);
const result = await service.update('g1', { description: 'new desc' });
expect(groupRepo.update).toHaveBeenCalledWith('g1', { description: 'new desc' });
expect(result.description).toBe('new desc');
});
it('updates members (full replacement)', async () => {
const group = makeGroupWithMembers({ id: 'g1' }, [
{ id: 'gm-1', user: { id: 'u-old', email: 'old@example.com', name: 'Old' } },
]);
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' });
vi.mocked(userRepo.findByEmail).mockResolvedValue(alice);
const updated = makeGroupWithMembers({ id: 'g1' }, [
{ id: 'gm-2', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } },
]);
vi.mocked(groupRepo.findById).mockResolvedValueOnce(group).mockResolvedValue(updated);
const result = await service.update('g1', { members: ['alice@example.com'] });
expect(groupRepo.setMembers).toHaveBeenCalledWith('g1', ['u-alice']);
expect(result.members).toHaveLength(1);
});
it('throws NotFoundError when group not found', async () => {
await expect(service.update('missing', { description: 'x' })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError for unknown member email on update', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
await expect(
service.update('g1', { members: ['unknown@example.com'] }),
).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes group', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
await service.delete('g1');
expect(groupRepo.delete).toHaveBeenCalledWith('g1');
});
it('throws NotFoundError when group not found', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
describe('group includes resolved member info', () => {
it('members include user id, email, and name', async () => {
const group = makeGroupWithMembers({ id: 'g1', name: 'team' }, [
{ id: 'gm-1', user: { id: 'u1', email: 'alice@example.com', name: 'Alice' } },
{ id: 'gm-2', user: { id: 'u2', email: 'bob@example.com', name: null } },
]);
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const result = await service.getById('g1');
expect(result.members[0].user).toEqual({ id: 'u1', email: 'alice@example.com', name: 'Alice' });
expect(result.members[1].user).toEqual({ id: 'u2', email: 'bob@example.com', name: null });
});
});
});