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:
@@ -287,4 +287,410 @@ describe('describe command', () => {
|
||||
expect(text).toContain('list_datasources');
|
||||
expect(text).toContain('mcpctl create server my-grafana --from-template=grafana');
|
||||
});
|
||||
|
||||
it('shows user detail (no Role field — RBAC is the auth mechanism)', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'alice@test.com',
|
||||
name: 'Alice Smith',
|
||||
provider: null,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-15',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', 'usr-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== User: alice@test.com ===');
|
||||
expect(text).toContain('Email:');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('Alice Smith');
|
||||
expect(text).not.toContain('Role:');
|
||||
expect(text).toContain('Provider:');
|
||||
expect(text).toContain('local');
|
||||
expect(text).toContain('ID:');
|
||||
expect(text).toContain('usr-1');
|
||||
});
|
||||
|
||||
it('shows user with no name as dash', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-2',
|
||||
email: 'bob@test.com',
|
||||
name: null,
|
||||
provider: 'oidc',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-2']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== User: bob@test.com ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('-');
|
||||
expect(text).not.toContain('Role:');
|
||||
expect(text).toContain('oidc');
|
||||
});
|
||||
|
||||
it('shows group detail with members', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-1',
|
||||
name: 'dev-team',
|
||||
description: 'Development team',
|
||||
members: [
|
||||
{ user: { email: 'alice@test.com' }, createdAt: '2025-01-01' },
|
||||
{ user: { email: 'bob@test.com' }, createdAt: '2025-01-02' },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-15',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', 'grp-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Group: dev-team ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('dev-team');
|
||||
expect(text).toContain('Description:');
|
||||
expect(text).toContain('Development team');
|
||||
expect(text).toContain('Members:');
|
||||
expect(text).toContain('EMAIL');
|
||||
expect(text).toContain('ADDED');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('bob@test.com');
|
||||
expect(text).toContain('ID:');
|
||||
expect(text).toContain('grp-1');
|
||||
});
|
||||
|
||||
it('shows group detail with no members', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-2',
|
||||
name: 'empty-group',
|
||||
description: '',
|
||||
members: [],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-2']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Group: empty-group ===');
|
||||
// No Members section when empty
|
||||
expect(text).not.toContain('EMAIL');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with subjects and bindings', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-1',
|
||||
name: 'developers',
|
||||
subjects: [
|
||||
{ kind: 'User', name: 'alice@test.com' },
|
||||
{ kind: 'Group', name: 'dev-team' },
|
||||
],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'view', resource: 'instances' },
|
||||
{ role: 'view', resource: 'projects' },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-15',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', 'rbac-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== RBAC: developers ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('developers');
|
||||
// Subjects section
|
||||
expect(text).toContain('Subjects:');
|
||||
expect(text).toContain('KIND');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('User');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('Group');
|
||||
expect(text).toContain('dev-team');
|
||||
// Role Bindings section
|
||||
expect(text).toContain('Resource Bindings:');
|
||||
expect(text).toContain('ROLE');
|
||||
expect(text).toContain('RESOURCE');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('servers');
|
||||
expect(text).toContain('view');
|
||||
expect(text).toContain('instances');
|
||||
expect(text).toContain('projects');
|
||||
expect(text).toContain('ID:');
|
||||
expect(text).toContain('rbac-1');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with wildcard resource', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-2',
|
||||
name: 'admins',
|
||||
subjects: [{ kind: 'User', name: 'admin@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-2']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== RBAC: admins ===');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('*');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with empty subjects and bindings', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-3',
|
||||
name: 'empty-rbac',
|
||||
subjects: [],
|
||||
roleBindings: [],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-3']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== RBAC: empty-rbac ===');
|
||||
// No Subjects or Role Bindings sections when empty
|
||||
expect(text).not.toContain('KIND');
|
||||
expect(text).not.toContain('ROLE');
|
||||
expect(text).not.toContain('RESOURCE');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with 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', resource: 'projects' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Resource Bindings:');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('*');
|
||||
expect(text).toContain('run');
|
||||
expect(text).toContain('projects');
|
||||
expect(text).toContain('Operations:');
|
||||
expect(text).toContain('ACTION');
|
||||
expect(text).toContain('logs');
|
||||
expect(text).toContain('backup');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with name-scoped resource binding', 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' },
|
||||
{ role: 'edit', resource: 'secrets' },
|
||||
],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Resource Bindings:');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('my-ha');
|
||||
expect(text).toContain('view');
|
||||
expect(text).toContain('servers');
|
||||
});
|
||||
|
||||
it('shows user with direct RBAC permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'alice@test.com',
|
||||
name: 'Alice',
|
||||
provider: null,
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // users list (resolveNameOrId)
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'dev-access',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
],
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never); // groups
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== User: alice@test.com ===');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Direct (dev-access)');
|
||||
expect(text).toContain('Resources:');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('servers');
|
||||
expect(text).toContain('Operations:');
|
||||
expect(text).toContain('logs');
|
||||
});
|
||||
|
||||
it('shows user with inherited group permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'bob@test.com',
|
||||
name: 'Bob',
|
||||
provider: null,
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // users list
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'team-perms',
|
||||
subjects: [{ kind: 'Group', name: 'dev-team' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: '*' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([ // groups
|
||||
{ name: 'dev-team', members: [{ user: { email: 'bob@test.com' } }] },
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Groups:');
|
||||
expect(text).toContain('dev-team');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Inherited (dev-team)');
|
||||
expect(text).toContain('view');
|
||||
expect(text).toContain('*');
|
||||
expect(text).toContain('backup');
|
||||
});
|
||||
|
||||
it('shows user with no permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'nobody@test.com',
|
||||
name: null,
|
||||
provider: null,
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Access: (none)');
|
||||
});
|
||||
|
||||
it('shows group with RBAC permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-1',
|
||||
name: 'admin',
|
||||
description: 'Admin group',
|
||||
members: [{ user: { email: 'alice@test.com' } }],
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // groups list (resolveNameOrId)
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'admin-access',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
{ role: 'run', action: 'restore' },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Group: admin ===');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Granted (admin-access)');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('*');
|
||||
expect(text).toContain('backup');
|
||||
expect(text).toContain('restore');
|
||||
});
|
||||
|
||||
it('shows group with name-scoped permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-1',
|
||||
name: 'ha-team',
|
||||
description: 'HA team',
|
||||
members: [],
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'ha-access',
|
||||
subjects: [{ kind: 'Group', name: 'ha-team' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers', name: 'my-ha' },
|
||||
{ role: 'view', resource: 'secrets' },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Granted (ha-access)');
|
||||
expect(text).toContain('my-ha');
|
||||
expect(text).toContain('NAME');
|
||||
});
|
||||
|
||||
it('outputs user detail as JSON', async () => {
|
||||
const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN' });
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1', '-o', 'json']);
|
||||
|
||||
const parsed = JSON.parse(deps.output[0] ?? '');
|
||||
expect(parsed.email).toBe('alice@test.com');
|
||||
expect(parsed.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('outputs group detail as YAML', async () => {
|
||||
const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Devs' });
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1', '-o', 'yaml']);
|
||||
|
||||
expect(deps.output[0]).toContain('name: dev-team');
|
||||
});
|
||||
|
||||
it('outputs rbac detail as JSON', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-1',
|
||||
name: 'devs',
|
||||
subjects: [{ kind: 'User', name: 'a@b.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'servers' }],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1', '-o', 'json']);
|
||||
|
||||
const parsed = JSON.parse(deps.output[0] ?? '');
|
||||
expect(parsed.subjects).toHaveLength(1);
|
||||
expect(parsed.roleBindings[0].role).toBe('edit');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user