feat(cli)!: migrate create rbac bindings to --roleBindings kv syntax
BREAKING: `mcpctl create rbac` no longer accepts `--binding` or
`--operation`. Use `--roleBindings` instead with key:value pairs:
# resource binding
--roleBindings role:view,resource:servers
--roleBindings role:view,resource:servers,name:my-ha
# operation binding (role:run is implied by action:)
--roleBindings action:logs
The on-disk YAML shape (`roleBindings: [{role, resource, name?}]` or
`{role:'run', action}`) is unchanged, so Git backups and existing
`apply -f` files continue to work. Only the command-line input format
changes.
The parser is extracted to src/cli/src/commands/rbac-bindings.ts so the
upcoming `mcpctl create mcptoken --bind <kv>` verb can reuse it.
Completions, tests, and the new parser unit test all pass (406/406).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -318,8 +318,8 @@ describe('create command', () => {
|
||||
'rbac', 'developers',
|
||||
'--subject', 'User:alice@test.com',
|
||||
'--subject', 'Group:dev-team',
|
||||
'--binding', 'edit:servers',
|
||||
'--binding', 'view:instances',
|
||||
'--roleBindings', 'role:edit,resource:servers',
|
||||
'--roleBindings', 'role:view,resource:instances',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
@@ -342,7 +342,7 @@ describe('create command', () => {
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'admins',
|
||||
'--subject', 'User:admin@test.com',
|
||||
'--binding', 'edit:*',
|
||||
'--roleBindings', 'role:edit,resource:*',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
@@ -371,18 +371,18 @@ describe('create command', () => {
|
||||
).rejects.toThrow('Invalid subject format');
|
||||
});
|
||||
|
||||
it('throws on invalid binding format', async () => {
|
||||
it('throws on invalid roleBindings format', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['rbac', 'bad', '--binding', 'no-colon'], { from: 'user' }),
|
||||
).rejects.toThrow('Invalid binding format');
|
||||
cmd.parseAsync(['rbac', 'bad', '--roleBindings', 'no-colon'], { from: 'user' }),
|
||||
).rejects.toThrow(/Invalid roleBindings/);
|
||||
});
|
||||
|
||||
it('throws on 409 without --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--binding', 'edit:servers'], { from: 'user' }),
|
||||
cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--roleBindings', 'role:edit,resource:servers'], { from: 'user' }),
|
||||
).rejects.toThrow('API error 409');
|
||||
});
|
||||
|
||||
@@ -393,7 +393,7 @@ describe('create command', () => {
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'developers',
|
||||
'--subject', 'User:new@test.com',
|
||||
'--binding', 'edit:*',
|
||||
'--roleBindings', 'role:edit,resource:*',
|
||||
'--force',
|
||||
], { from: 'user' });
|
||||
|
||||
@@ -404,15 +404,15 @@ describe('create command', () => {
|
||||
expect(output.join('\n')).toContain("rbac 'developers' updated");
|
||||
});
|
||||
|
||||
it('creates an RBAC definition with operation bindings', async () => {
|
||||
it('creates an RBAC definition with operation bindings (action:… shorthand)', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ops' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'ops',
|
||||
'--subject', 'Group:ops-team',
|
||||
'--binding', 'edit:servers',
|
||||
'--operation', 'logs',
|
||||
'--operation', 'backup',
|
||||
'--roleBindings', 'role:edit,resource:servers',
|
||||
'--roleBindings', 'action:logs',
|
||||
'--roleBindings', 'action:backup',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
@@ -433,7 +433,7 @@ describe('create command', () => {
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'ha-viewer',
|
||||
'--subject', 'User:alice@test.com',
|
||||
'--binding', 'view:servers:my-ha',
|
||||
'--roleBindings', 'role:view,resource:servers,name:my-ha',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
|
||||
54
src/cli/tests/commands/rbac-bindings.test.ts
Normal file
54
src/cli/tests/commands/rbac-bindings.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseRoleBinding } from '../../src/commands/rbac-bindings.js';
|
||||
|
||||
describe('parseRoleBinding', () => {
|
||||
it('parses an unscoped resource binding', () => {
|
||||
expect(parseRoleBinding('role:view,resource:servers')).toEqual({
|
||||
role: 'view',
|
||||
resource: 'servers',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a name-scoped resource binding', () => {
|
||||
expect(parseRoleBinding('role:view,resource:servers,name:my-ha')).toEqual({
|
||||
role: 'view',
|
||||
resource: 'servers',
|
||||
name: 'my-ha',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an operation binding via the action shorthand', () => {
|
||||
expect(parseRoleBinding('action:logs')).toEqual({
|
||||
role: 'run',
|
||||
action: 'logs',
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace around keys and values', () => {
|
||||
expect(parseRoleBinding('role: edit , resource: * ')).toEqual({
|
||||
role: 'edit',
|
||||
resource: '*',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a pair with no colon', () => {
|
||||
expect(() => parseRoleBinding('role=view')).toThrow(/key:value pairs/);
|
||||
});
|
||||
|
||||
it('rejects an unknown key', () => {
|
||||
expect(() => parseRoleBinding('role:view,resource:servers,scope:project')).toThrow(/Invalid roleBindings key 'scope'/);
|
||||
});
|
||||
|
||||
it('rejects an empty value', () => {
|
||||
expect(() => parseRoleBinding('role:view,resource:')).toThrow(/empty key or value/);
|
||||
});
|
||||
|
||||
it('rejects action combined with resource/name', () => {
|
||||
expect(() => parseRoleBinding('action:logs,resource:servers')).toThrow(/cannot be combined/);
|
||||
});
|
||||
|
||||
it('requires both role and resource when action is absent', () => {
|
||||
expect(() => parseRoleBinding('role:view')).toThrow(/need either 'action/);
|
||||
expect(() => parseRoleBinding('resource:servers')).toThrow(/need either 'action/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user