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:
72
src/cli/src/commands/passwd.ts
Normal file
72
src/cli/src/commands/passwd.ts
Normal 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}.`);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user