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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user