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,124 @@
/**
* Smoke tests: `mcpctl passwd` end-to-end against live mcpd.
*
* Exercises the full password-change contract:
* 1. Admin creates a throwaway user (gets the default self-permission seeded).
* 2. That user self-changes their password (POST /users/me/password) with the
* correct current password → succeeds; new password logs in, old does not.
* 3. Wrong current password → 401, password unchanged.
* 4. Admin resets the user's password (PUT /users/:id/password) → new one logs in.
* 5. Cleanup: delete the throwaway user.
*
* Target: $MCPD_URL (default https://mcpctl.ad.itaz.eu). Admin token is read
* from the local mcpctl credentials file (the box running smoke tests is logged
* in as admin). If /healthz fails or no admin token is found, the suite skips.
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const STAMP = Date.now().toString(36);
const EMAIL = `smoke-passwd-${STAMP}@example.com`;
const P1 = `Smoke-${STAMP}-aaa1`;
const P2 = `Smoke-${STAMP}-bbb2`;
const P3 = `Smoke-${STAMP}-ccc3`;
interface Resp { status: number; body: string; json: <T = unknown>() => T }
function request(method: string, url: string, token?: string, payload?: unknown): Promise<Resp> {
return new Promise((resolve, reject) => {
const u = new URL(url);
const driver = u.protocol === 'https:' ? https : http;
const data = payload === undefined ? undefined : JSON.stringify(payload);
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(data)); }
const req = driver.request(url, { method, headers, timeout: 15_000 }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ status: res.statusCode ?? 0, body, json: <T,>() => JSON.parse(body) as T });
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (data) req.write(data);
req.end();
});
}
async function healthz(): Promise<boolean> {
try {
const res = await request('GET', `${MCPD_URL.replace(/\/$/, '')}/healthz`);
return res.status === 200;
} catch { return false; }
}
function adminToken(): string | undefined {
try {
const raw = readFileSync(join(homedir(), '.config', 'mcpctl', 'credentials'), 'utf-8');
return (JSON.parse(raw) as { token?: string }).token;
} catch { return undefined; }
}
async function login(email: string, password: string): Promise<Resp> {
return request('POST', `${MCPD_URL}/api/v1/auth/login`, undefined, { email, password });
}
describe('passwd smoke', () => {
let token: string | undefined;
let ready = false;
let userId: string | undefined;
beforeAll(async () => {
token = adminToken();
const up = await healthz();
ready = up && token !== undefined;
if (!ready) {
console.warn(`\n ○ passwd smoke: skipped — ${MCPD_URL}/healthz up=${up}, adminToken=${token !== undefined}.\n`);
return;
}
// Create the throwaway user (admin).
const res = await request('POST', `${MCPD_URL}/api/v1/users`, token, { email: EMAIL, password: P1 });
expect([201, 409]).toContain(res.status);
const me = await request('GET', `${MCPD_URL}/api/v1/users/${encodeURIComponent(EMAIL)}`, token);
userId = me.json<{ id: string }>().id;
});
it('self-change succeeds with correct current password; new logs in, old does not', async () => {
if (!ready) return;
const userTok = (await login(EMAIL, P1)).json<{ token: string }>().token;
const chg = await request('POST', `${MCPD_URL}/api/v1/users/me/password`, userTok, { currentPassword: P1, newPassword: P2 });
expect(chg.status).toBe(200);
expect((await login(EMAIL, P2)).status).toBe(200);
expect((await login(EMAIL, P1)).status).toBe(401);
});
it('self-change with wrong current password is rejected (401)', async () => {
if (!ready) return;
const userTok = (await login(EMAIL, P2)).json<{ token: string }>().token;
const chg = await request('POST', `${MCPD_URL}/api/v1/users/me/password`, userTok, { currentPassword: 'definitely-wrong', newPassword: P3 });
expect(chg.status).toBe(401);
expect((await login(EMAIL, P2)).status).toBe(200); // unchanged
});
it('admin reset sets a new password without the current one', async () => {
if (!ready || !userId) return;
const reset = await request('PUT', `${MCPD_URL}/api/v1/users/${userId}/password`, token, { newPassword: P3 });
expect(reset.status).toBe(200);
expect((await login(EMAIL, P3)).status).toBe(200);
});
it('cleanup: delete the throwaway user', async () => {
if (!ready || !userId) return;
const del = await request('DELETE', `${MCPD_URL}/api/v1/users/${userId}`, token);
expect([204, 404]).toContain(del.status);
});
});