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

@@ -37,6 +37,8 @@ describe('login command', () => {
user: { email },
}),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Logged in as alice@test.com');
@@ -58,6 +60,8 @@ describe('login command', () => {
log,
loginRequest: async () => { throw new Error('Invalid credentials'); },
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Login failed');
@@ -83,6 +87,8 @@ describe('login command', () => {
return { token: 'tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(capturedUrl).toBe('http://custom:3100');
@@ -103,12 +109,74 @@ describe('login command', () => {
return { token: 'tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' });
expect(capturedUrl).toBe('http://override:3100');
});
});
describe('login bootstrap flow', () => {
it('bootstraps first admin when no users exist', async () => {
let bootstrapCalled = false;
const cmd = createLoginCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
prompt: {
input: async (msg) => {
if (msg.includes('Name')) return 'Admin User';
return 'admin@test.com';
},
password: async () => 'admin-pass',
},
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: false }),
bootstrapRequest: async (_url, email, _password) => {
bootstrapCalled = true;
return { token: 'admin-token', user: { email } };
},
});
await cmd.parseAsync([], { from: 'user' });
expect(bootstrapCalled).toBe(true);
expect(output.join('\n')).toContain('No users configured');
expect(output.join('\n')).toContain('admin@test.com');
expect(output.join('\n')).toContain('admin');
const creds = loadCredentials({ configDir: tempDir });
expect(creds).not.toBeNull();
expect(creds!.token).toBe('admin-token');
expect(creds!.user).toBe('admin@test.com');
});
it('falls back to normal login when users exist', async () => {
let loginCalled = false;
const cmd = createLoginCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
prompt: {
input: async () => 'alice@test.com',
password: async () => 'secret',
},
log,
loginRequest: async (_url, email) => {
loginCalled = true;
return { token: 'session-tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => { throw new Error('Should not be called'); },
});
await cmd.parseAsync([], { from: 'user' });
expect(loginCalled).toBe(true);
expect(output.join('\n')).not.toContain('No users configured');
});
});
describe('logout command', () => {
it('removes credentials on logout', async () => {
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir });
@@ -120,6 +188,8 @@ describe('logout command', () => {
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => { logoutCalled = true; },
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Logged out successfully');
@@ -137,6 +207,8 @@ describe('logout command', () => {
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Not logged in');