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

@@ -159,4 +159,351 @@ projects:
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies users (no role field)', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
users:
- email: alice@test.com
password: password123
name: Alice
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
expect(callBody).toEqual(expect.objectContaining({
email: 'alice@test.com',
password: 'password123',
name: 'Alice',
}));
expect(callBody).not.toHaveProperty('role');
expect(output.join('\n')).toContain('Created user: alice@test.com');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing users matched by email', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
users:
- email: alice@test.com
password: newpassword
name: Alice Updated
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({
email: 'alice@test.com',
name: 'Alice Updated',
}));
expect(output.join('\n')).toContain('Updated user: alice@test.com');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies groups', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
groups:
- name: dev-team
description: Development team
members:
- alice@test.com
- bob@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
name: 'dev-team',
description: 'Development team',
members: ['alice@test.com', 'bob@test.com'],
}));
expect(output.join('\n')).toContain('Created group: dev-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing groups', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
groups:
- name: dev-team
description: Updated devs
members:
- new@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({
name: 'dev-team',
description: 'Updated devs',
}));
expect(output.join('\n')).toContain('Updated group: dev-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbacBindings', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: developers
subjects:
- kind: User
name: alice@test.com
- kind: Group
name: dev-team
roleBindings:
- role: edit
resource: servers
- role: view
resource: instances
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
],
}));
expect(output.join('\n')).toContain('Created rbacBinding: developers');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing rbacBindings', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbacBindings:
- name: developers
subjects:
- kind: User
name: new@test.com
roleBindings:
- role: edit
resource: "*"
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({
name: 'developers',
}));
expect(output.join('\n')).toContain('Updated rbacBinding: developers');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies projects with servers and members', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
projects:
- name: smart-home
description: Home automation
proxyMode: filtered
llmProvider: gemini-cli
llmModel: gemini-2.0-flash
servers:
- my-grafana
- my-ha
members:
- alice@test.com
- bob@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],
members: ['alice@test.com', 'bob@test.com'],
}));
expect(output.join('\n')).toContain('Created project: smart-home');
rmSync(tmpDir, { recursive: true, force: true });
});
it('dry-run shows all new resource types', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
secrets:
- name: creds
data:
TOKEN: abc
users:
- email: alice@test.com
password: password123
groups:
- name: dev-team
members: []
projects:
- name: my-proj
description: A project
rbacBindings:
- name: admins
subjects:
- kind: User
name: admin@test.com
roleBindings:
- role: edit
resource: "*"
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const text = output.join('\n');
expect(text).toContain('Dry run');
expect(text).toContain('1 secret(s)');
expect(text).toContain('1 user(s)');
expect(text).toContain('1 group(s)');
expect(text).toContain('1 project(s)');
expect(text).toContain('1 rbacBinding(s)');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies resources in correct order', async () => {
const callOrder: string[] = [];
vi.mocked(client.post).mockImplementation(async (url: string) => {
callOrder.push(url);
return { id: 'new-id', name: 'test' };
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbacBindings:
- name: admins
subjects:
- kind: User
name: admin@test.com
roleBindings:
- role: edit
resource: "*"
users:
- email: admin@test.com
password: password123
secrets:
- name: creds
data:
KEY: val
groups:
- name: dev-team
servers:
- name: my-server
transport: STDIO
projects:
- name: my-proj
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
// Apply order: secrets → servers → users → groups → projects → templates → rbacBindings
expect(callOrder[0]).toBe('/api/v1/secrets');
expect(callOrder[1]).toBe('/api/v1/servers');
expect(callOrder[2]).toBe('/api/v1/users');
expect(callOrder[3]).toBe('/api/v1/groups');
expect(callOrder[4]).toBe('/api/v1/projects');
expect(callOrder[5]).toBe('/api/v1/rbac');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbac with operation bindings', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: ops-team
subjects:
- kind: Group
name: ops
roleBindings:
- role: edit
resource: servers
- role: run
action: backup
- role: run
action: logs
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'ops-team',
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'logs' },
],
}));
expect(output.join('\n')).toContain('Created rbacBinding: ops-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbac with name-scoped resource binding', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: ha-viewer
subjects:
- kind: User
name: alice@test.com
roleBindings:
- role: view
resource: servers
name: my-ha
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'ha-viewer',
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
],
}));
rmSync(tmpDir, { recursive: true, force: true });
});
});