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

@@ -0,0 +1,72 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface PasswdPromptDeps {
password(message: string): Promise<string>;
}
export interface PasswdCommandDeps {
client: ApiClient;
log: (...args: string[]) => void;
prompt: PasswdPromptDeps;
}
interface Me {
id: string;
email: string;
}
interface UserView {
id: string;
email: string;
}
async function defaultPassword(message: string): Promise<string> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message, mask: '*' }]);
return answer as string;
}
function validateNew(newPassword: string, confirm: string): void {
if (newPassword !== confirm) {
throw new Error('Passwords do not match');
}
if (newPassword.length < 8) {
throw new Error('Password must be at least 8 characters');
}
}
export function createPasswdCommand(deps?: Partial<PasswdCommandDeps>): Command {
const log = deps?.log ?? ((...args: string[]): void => { console.log(...args); });
const prompt: PasswdPromptDeps = deps?.prompt ?? { password: defaultPassword };
return new Command('passwd')
.description('Change a user password (your own when called without an argument)')
.argument('[user]', 'email or id of the user whose password to change (defaults to yourself)')
.action(async (target: string | undefined) => {
const client = deps?.client;
if (!client) throw new Error('passwd: no API client configured');
const me = await client.get<Me>('/api/v1/auth/me');
const isSelf = target === undefined || target === me.email || target === me.id;
if (isSelf) {
// Self-service: prove identity with the current password, then set the new one.
const currentPassword = await prompt.password('Current password');
const newPassword = await prompt.password('New password');
const confirm = await prompt.password('Retype new password');
validateNew(newPassword, confirm);
await client.post('/api/v1/users/me/password', { currentPassword, newPassword });
log(`Password updated for ${me.email}.`);
return;
}
// Admin reset of another user — requires edit:users.
const targetUser = await client.get<UserView>(`/api/v1/users/${encodeURIComponent(target as string)}`);
const newPassword = await prompt.password(`New password for ${targetUser.email}`);
const confirm = await prompt.password('Retype new password');
validateNew(newPassword, confirm);
await client.put(`/api/v1/users/${targetUser.id}/password`, { newPassword });
log(`Password reset for ${targetUser.email}.`);
});
}

View File

@@ -25,6 +25,7 @@ import { createMigrateCommand } from './commands/migrate.js';
import { createRotateCommand } from './commands/rotate.js';
import { createReviewCommand } from './commands/review.js';
import { createSkillsCommand } from './commands/skills.js';
import { createPasswdCommand } from './commands/passwd.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
@@ -257,6 +258,11 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createPasswdCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createBackupCommand({
client,
log: (...args) => console.log(...args),