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

@@ -85,4 +85,173 @@ describe('get command', () => {
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No servers found');
});
it('lists users with correct columns (no ROLE column)', async () => {
const deps = makeDeps([
{ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null },
{ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
const text = deps.output.join('\n');
expect(text).toContain('EMAIL');
expect(text).toContain('NAME');
expect(text).not.toContain('ROLE');
expect(text).toContain('PROVIDER');
expect(text).toContain('alice@test.com');
expect(text).toContain('Alice');
expect(text).toContain('bob@test.com');
expect(text).toContain('oidc');
});
it('resolves user alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'user']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
});
it('lists groups with correct columns', async () => {
const deps = makeDeps([
{
id: 'grp-1',
name: 'dev-team',
description: 'Developers',
members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }],
},
{ id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('MEMBERS');
expect(text).toContain('DESCRIPTION');
expect(text).toContain('dev-team');
expect(text).toContain('2');
expect(text).toContain('ops-team');
expect(text).toContain('0');
});
it('resolves group alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'group']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
});
it('lists rbac definitions with correct columns', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('SUBJECTS');
expect(text).toContain('BINDINGS');
expect(text).toContain('admins');
expect(text).toContain('User:admin@test.com');
expect(text).toContain('edit:*');
});
it('resolves rbac-definition alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
});
it('lists projects with new columns', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'smart-home',
description: 'Home automation',
proxyMode: 'filtered',
ownerId: 'usr-1',
servers: [{ server: { name: 'grafana' } }],
members: [{ user: { email: 'a@b.com' }, role: 'admin' }, { user: { email: 'c@d.com' }, role: 'member' }],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('MEMBERS');
expect(text).toContain('smart-home');
expect(text).toContain('filtered');
expect(text).toContain('1');
expect(text).toContain('2');
});
it('displays mixed resource and operation bindings', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
const text = deps.output.join('\n');
expect(text).toContain('edit:*');
expect(text).toContain('run>logs');
expect(text).toContain('run>backup');
});
it('displays name-scoped resource bindings', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
const text = deps.output.join('\n');
expect(text).toContain('view:servers:my-ha');
});
it('shows no results message for empty users list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.output[0]).toContain('No users found');
});
it('shows no results message for empty groups list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.output[0]).toContain('No groups found');
});
it('shows no results message for empty rbac list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.output[0]).toContain('No rbac found');
});
});