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:
Michal
2026-02-23 11:05:19 +00:00
parent 23ab2a497e
commit c5147e8270
67 changed files with 7256 additions and 498 deletions

View File

@@ -10,8 +10,6 @@ import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient, ApiError } from './api-client.js';
@@ -28,7 +26,6 @@ export function createProgram(): Command {
.option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand());
program.addCommand(createLoginCommand());
program.addCommand(createLogoutCommand());
@@ -48,6 +45,12 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
program.addCommand(createConfigCommand(undefined, {
client,
credentialsDeps: {},
log: (...args) => console.log(...args),
}));
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (nameOrId) {
// Glob pattern — use query param filtering
@@ -113,16 +116,6 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createClaudeCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createProjectCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createBackupCommand({
client,
log: (...args) => console.log(...args),
@@ -145,14 +138,28 @@ const isDirectRun =
if (isDirectRun) {
createProgram().parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof ApiError) {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string };
msg = parsed.error ?? parsed.message ?? err.body;
} catch {
msg = err.body;
if (err.status === 401) {
console.error("Error: you need to log in. Run 'mcpctl login' to authenticate.");
} else if (err.status === 403) {
console.error('Error: permission denied. You do not have access to this resource.');
} else {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string; details?: unknown };
msg = parsed.error ?? parsed.message ?? err.body;
if (parsed.details && Array.isArray(parsed.details)) {
const issues = parsed.details as Array<{ message?: string; path?: string[] }>;
const detail = issues.map((i) => {
const path = i.path?.join('.') ?? '';
return path ? `${path}: ${i.message}` : (i.message ?? '');
}).filter(Boolean).join('; ');
if (detail) msg += `: ${detail}`;
}
} catch {
msg = err.body;
}
console.error(`Error: ${msg}`);
}
console.error(`Error: ${msg}`);
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {