feat(passwd): mcpctl passwd + RBAC-gated password change

Restores the lost `mcpctl passwd` command and builds the backend it needs.

Backend (mcpd):
- POST /api/v1/users/me/password — self-service change, requires current
  password. Gated by a new `set-own-password` operation.
- PUT /api/v1/users/:id/password — admin reset of another user, gated by
  edit:users (admins have edit:*). Added users name-resolver for CUID→email.
- UserService.setPassword/verifyPassword; UserRepository.update accepts
  passwordHash + findByIdWithHash.

RBAC, no exceptions: self password change is a default, admin-revocable
permission. Every new user gets a `self-<id>` RbacDefinition granting
`set-own-password`, seeded on create + bootstrap, gated by the
`allowSelfPasswordChange` system setting (stored in the mcpctl-system-settings
secret, default ON; admins disable globally or revoke per-user).

CLI: src/cli/src/commands/passwd.ts (self vs admin paths) + completions.

Tests: users-password route tests (8), auth-bootstrap grant assertion,
passwd live smoke test. Full suite 2214 passing; zero new lint errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-06-16 21:55:56 +01:00
parent 0e952dbf68
commit 2cceeb7093
15 changed files with 583 additions and 15 deletions

View File

@@ -92,6 +92,7 @@ interface MockDeps {
getByName: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
upsertByName: ReturnType<typeof vi.fn>;
};
rbacService: {
canAccess: ReturnType<typeof vi.fn>;
@@ -131,6 +132,7 @@ function createMockDeps(): MockDeps {
getByName: vi.fn(async () => null),
update: vi.fn(async () => makeRbacDef()),
delete: vi.fn(async () => {}),
upsertByName: vi.fn(async () => makeRbacDef()),
},
rbacService: {
canAccess: vi.fn(async () => false),
@@ -223,6 +225,13 @@ describe('Auth Bootstrap', () => {
// Verify auto-login was called
expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123');
// Verify the admin also got the default self password-change permission
expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({
name: 'self-user-1',
subjects: [{ kind: 'User', name: 'admin@example.com' }],
roleBindings: [{ role: 'run', action: 'set-own-password' }],
});
});
it('passes name when provided', async () => {