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),
|
||||
|
||||
33
src/mcpd/src/bootstrap/self-password-permission.ts
Normal file
33
src/mcpd/src/bootstrap/self-password-permission.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Self-service password change is modelled as a real, admin-revocable RBAC
|
||||
* permission — NOT an exception to the RBAC path. Every new user gets a
|
||||
* personal RbacDefinition (`self-<userId>`) granting the `set-own-password`
|
||||
* operation, gated at creation time by the `allowSelfPasswordChange` system
|
||||
* setting. Admins disable it for an individual by deleting that definition,
|
||||
* or for future users by flipping the setting.
|
||||
*/
|
||||
|
||||
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
|
||||
|
||||
/** Operation that gates `POST /api/v1/users/me/password`. */
|
||||
export const SET_OWN_PASSWORD_OPERATION = 'set-own-password';
|
||||
|
||||
/** Name of a user's personal RbacDefinition (currently just self password). */
|
||||
export function selfRbacName(userId: string): string {
|
||||
return `self-${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a user the default self-service password-change permission.
|
||||
* Idempotent (upsert by name) — safe to call on every creation/bootstrap.
|
||||
*/
|
||||
export async function grantSelfPasswordPermission(
|
||||
rbac: RbacDefinitionService,
|
||||
user: { id: string; email: string },
|
||||
): Promise<void> {
|
||||
await rbac.upsertByName({
|
||||
name: selfRbacName(user.id),
|
||||
subjects: [{ kind: 'User', name: user.email }],
|
||||
roleBindings: [{ role: 'run', action: SET_OWN_PASSWORD_OPERATION }],
|
||||
});
|
||||
}
|
||||
47
src/mcpd/src/bootstrap/system-settings.ts
Normal file
47
src/mcpd/src/bootstrap/system-settings.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Global mcpd settings, stored as JSON in a well-known Secret's `data` field.
|
||||
*
|
||||
* We deliberately reuse the Secret object instead of adding schema columns:
|
||||
* Secrets already have CRUD, RBAC, and backup/restore, so settings stay
|
||||
* admin-editable through the same tooling with no migration per flag.
|
||||
* See the `backup-ssh` secret for the original precedent.
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
/** Well-known secret holding global mcpd settings as `data` JSON. */
|
||||
export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings';
|
||||
|
||||
/** Settings key: whether newly-created users get self password-change permission. */
|
||||
export const SETTING_ALLOW_SELF_PASSWORD = 'allowSelfPasswordChange';
|
||||
|
||||
/** Coerce a stored JSON value (boolean, or "true"/"false"/"0" string) to boolean. */
|
||||
function toBool(v: unknown, dflt: boolean): boolean {
|
||||
if (typeof v === 'boolean') return v;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim().toLowerCase();
|
||||
if (s === 'false' || s === '0' || s === '') return false;
|
||||
return true;
|
||||
}
|
||||
return dflt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether new users should receive the `set-own-password` permission by default.
|
||||
*
|
||||
* Defaults to TRUE — a fresh install allows self-service password changes.
|
||||
* An admin disables it by setting `allowSelfPasswordChange: false` in the
|
||||
* `mcpctl-system-settings` secret. Missing secret / missing key / read error
|
||||
* all fall back to the permissive default.
|
||||
*/
|
||||
export async function getAllowSelfPasswordChange(prisma: PrismaClient): Promise<boolean> {
|
||||
try {
|
||||
const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } });
|
||||
if (!secret) return true;
|
||||
const data = (secret.data ?? {}) as Record<string, unknown>;
|
||||
if (!(SETTING_ALLOW_SELF_PASSWORD in data)) return true;
|
||||
return toBool(data[SETTING_ALLOW_SELF_PASSWORD], true);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { PersonalityService } from './services/personality.service.js';
|
||||
import { registerPersonalityRoutes } from './routes/personalities.js';
|
||||
import { registerWebUi } from './routes/web-ui.js';
|
||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||
import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js';
|
||||
import { bootstrapSystemSkills } from './bootstrap/system-skills.js';
|
||||
import {
|
||||
McpServerService,
|
||||
@@ -130,6 +131,13 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
|
||||
const segment = match[1] as string;
|
||||
|
||||
// Self-service password change — gated by the `set-own-password` operation
|
||||
// (a default, admin-revocable permission), NOT the broad edit:users that an
|
||||
// admin reset of another user needs. Must precede the generic users mapping.
|
||||
if (url.startsWith('/api/v1/users/me/password')) {
|
||||
return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION };
|
||||
}
|
||||
|
||||
// Operations (non-resource endpoints)
|
||||
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
||||
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
||||
@@ -414,6 +422,12 @@ async function main(): Promise<void> {
|
||||
mcptokens: mcpTokenRepo,
|
||||
llms: llmRepo,
|
||||
agents: agentRepo,
|
||||
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
||||
// match on PUT /api/v1/users/:id/password (admin reset).
|
||||
users: { findById: async (id: string) => {
|
||||
const u = await userRepo.findById(id);
|
||||
return u ? { name: u.email } : null;
|
||||
} },
|
||||
};
|
||||
|
||||
// Migrate legacy 'admin' role → granular roles
|
||||
@@ -701,7 +715,7 @@ async function main(): Promise<void> {
|
||||
authDeps,
|
||||
});
|
||||
registerRbacRoutes(app, rbacDefinitionService);
|
||||
registerUserRoutes(app, userService);
|
||||
registerUserRoutes(app, { userService, rbacDefinitionService, prisma });
|
||||
registerGroupRoutes(app, groupService);
|
||||
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
|
||||
registerPromptRoutes(app, promptService, projectRepo, agentRepo);
|
||||
|
||||
@@ -6,9 +6,11 @@ export type SafeUser = Omit<User, 'passwordHash'>;
|
||||
export interface IUserRepository {
|
||||
findAll(): Promise<SafeUser[]>;
|
||||
findById(id: string): Promise<SafeUser | null>;
|
||||
/** Like findById but includes the passwordHash — used by password verification. */
|
||||
findByIdWithHash(id: string): Promise<User | null>;
|
||||
findByEmail(email: string, includeHash?: boolean): Promise<SafeUser | null> | Promise<User | null>;
|
||||
create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser>;
|
||||
update(id: string, data: { name?: string; role?: string }): Promise<SafeUser>;
|
||||
update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise<SafeUser>;
|
||||
delete(id: string): Promise<void>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
@@ -43,6 +45,10 @@ export class UserRepository implements IUserRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdWithHash(id: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string, includeHash?: boolean): Promise<User | SafeUser | null> {
|
||||
if (includeHash === true) {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
@@ -67,10 +73,11 @@ export class UserRepository implements IUserRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: { name?: string; role?: string }): Promise<SafeUser> {
|
||||
async update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise<SafeUser> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData['name'] = data.name;
|
||||
if (data.role !== undefined) updateData['role'] = data.role;
|
||||
if (data.passwordHash !== undefined) updateData['passwordHash'] = data.passwordHash;
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RbacDefinitionService } from '../services/rbac-definition.service.
|
||||
import type { RbacService } from '../services/rbac.service.js';
|
||||
import { createAuthMiddleware } from '../middleware/auth.js';
|
||||
import { createRbacMiddleware } from '../middleware/rbac.js';
|
||||
import { grantSelfPasswordPermission } from '../bootstrap/self-password-permission.js';
|
||||
|
||||
export interface AuthRouteDeps {
|
||||
authService: AuthService;
|
||||
@@ -37,12 +38,18 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
|
||||
const { email, password, name } = request.body as { email: string; password: string; name?: string };
|
||||
|
||||
// Create the first admin user
|
||||
await deps.userService.create({
|
||||
const adminUser = await deps.userService.create({
|
||||
email,
|
||||
password,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
});
|
||||
|
||||
// Fresh install: the admin is also a user, so give them the default
|
||||
// self-service password-change permission (default-on; see
|
||||
// grantSelfPasswordPermission). Their bootstrap-admin edit:* already
|
||||
// covers resetting OTHER users.
|
||||
await grantSelfPasswordPermission(deps.rbacDefinitionService, adminUser);
|
||||
|
||||
// Create "admin" group and add the first user to it
|
||||
await deps.groupService.create({
|
||||
name: 'admin',
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { UserService } from '../services/user.service.js';
|
||||
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
|
||||
import { ChangeOwnPasswordSchema, ResetPasswordSchema } from '../validation/user.schema.js';
|
||||
import { getAllowSelfPasswordChange } from '../bootstrap/system-settings.js';
|
||||
import { grantSelfPasswordPermission } from '../bootstrap/self-password-permission.js';
|
||||
|
||||
export interface UserRouteDeps {
|
||||
userService: UserService;
|
||||
rbacDefinitionService: RbacDefinitionService;
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
|
||||
export function registerUserRoutes(app: FastifyInstance, deps: UserRouteDeps): void {
|
||||
const { userService: service, rbacDefinitionService, prisma } = deps;
|
||||
|
||||
export function registerUserRoutes(
|
||||
app: FastifyInstance,
|
||||
service: UserService,
|
||||
): void {
|
||||
app.get('/api/v1/users', async () => {
|
||||
return service.list();
|
||||
});
|
||||
@@ -20,12 +30,58 @@ export function registerUserRoutes(
|
||||
|
||||
app.post('/api/v1/users', async (request, reply) => {
|
||||
const user = await service.create(request.body);
|
||||
// Seed the default, admin-revocable self password-change permission,
|
||||
// gated by the system setting. Never fail user creation if seeding fails.
|
||||
try {
|
||||
if (await getAllowSelfPasswordChange(prisma)) {
|
||||
await grantSelfPasswordPermission(rbacDefinitionService, user);
|
||||
}
|
||||
} catch (err) {
|
||||
request.log.warn({ err, userId: user.id }, 'failed to seed self password permission');
|
||||
}
|
||||
reply.code(201);
|
||||
return user;
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => {
|
||||
await service.delete(_request.params.id);
|
||||
// ── Self-service password change ──
|
||||
// Gated by the `set-own-password` operation (global RBAC hook). Requires the
|
||||
// current password as proof — a user who forgot it must use an admin reset.
|
||||
app.post('/api/v1/users/me/password', async (request, reply) => {
|
||||
if (request.userId === undefined) {
|
||||
reply.code(401);
|
||||
return { error: 'Authentication required' };
|
||||
}
|
||||
const { currentPassword, newPassword } = ChangeOwnPasswordSchema.parse(request.body);
|
||||
const ok = await service.verifyPassword(request.userId, currentPassword);
|
||||
if (!ok) {
|
||||
reply.code(401);
|
||||
return { error: 'Current password is incorrect' };
|
||||
}
|
||||
await service.setPassword(request.userId, newPassword);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ── Admin reset of another user's password ──
|
||||
// Gated by edit:users (admins have edit:*). No current password required.
|
||||
app.put<{ Params: { id: string } }>('/api/v1/users/:id/password', async (request) => {
|
||||
const idOrEmail = request.params.id;
|
||||
const target = idOrEmail.includes('@')
|
||||
? await service.getByEmail(idOrEmail)
|
||||
: await service.getById(idOrEmail);
|
||||
const { newPassword } = ResetPasswordSchema.parse(request.body);
|
||||
await service.setPassword(target.id, newPassword);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (request, reply) => {
|
||||
// A user cannot delete their own account through the API — self bindings
|
||||
// grant password change, not account deletion. Admins delete others.
|
||||
if (request.userId !== undefined && request.userId === request.params.id) {
|
||||
reply.code(403);
|
||||
return { error: 'You cannot delete your own account' };
|
||||
}
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import type { IUserRepository, SafeUser } from '../repositories/user.repository.js';
|
||||
import { CreateUserSchema } from '../validation/user.schema.js';
|
||||
import { CreateUserSchema, PasswordSchema } from '../validation/user.schema.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
@@ -54,6 +54,27 @@ export class UserService {
|
||||
await this.userRepo.delete(id);
|
||||
}
|
||||
|
||||
/** Verify a plaintext password against the stored hash for a user id. */
|
||||
async verifyPassword(id: string, password: string): Promise<boolean> {
|
||||
const user = await this.userRepo.findByIdWithHash(id);
|
||||
if (user === null) {
|
||||
throw new NotFoundError(`User not found: ${id}`);
|
||||
}
|
||||
// Locked accounts (e.g. system user with `!locked`) never verify.
|
||||
if (!user.passwordHash || user.passwordHash.startsWith('!') || user.passwordHash.startsWith('__')) {
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compare(password, user.passwordHash);
|
||||
}
|
||||
|
||||
/** Hash + store a new password for a user. Throws NotFoundError if absent. */
|
||||
async setPassword(id: string, newPassword: string): Promise<void> {
|
||||
PasswordSchema.parse(newPassword);
|
||||
await this.getById(id); // 404 if missing
|
||||
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
||||
await this.userRepo.update(id, { passwordHash });
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.userRepo.count();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Shared password rules — reused by create, self-change, and admin-reset paths. */
|
||||
export const PasswordSchema = z.string().min(8).max(128);
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
password: PasswordSchema,
|
||||
name: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const UpdateUserSchema = z.object({
|
||||
name: z.string().max(100).optional(),
|
||||
password: z.string().min(8).max(128).optional(),
|
||||
password: PasswordSchema.optional(),
|
||||
});
|
||||
|
||||
/** Self-service change: requires the current password as proof. */
|
||||
export const ChangeOwnPasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: PasswordSchema,
|
||||
});
|
||||
|
||||
/** Admin reset of another user: no current password needed. */
|
||||
export const ResetPasswordSchema = z.object({
|
||||
newPassword: PasswordSchema,
|
||||
});
|
||||
|
||||
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
154
src/mcpd/tests/users-password.test.ts
Normal file
154
src/mcpd/tests/users-password.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerUserRoutes } from '../src/routes/users.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
|
||||
/**
|
||||
* Unit tests for the password endpoints. RBAC/auth is enforced by global hooks
|
||||
* in main.ts (not registerUserRoutes), so here we set request.userId via a test
|
||||
* preHandler from the `x-test-user` header to drive the route logic directly.
|
||||
*/
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function makeUser(over?: Partial<{ id: string; email: string }>) {
|
||||
return { id: 'user-1', email: 'me@example.com', name: null, role: 'user', provider: 'local', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...over };
|
||||
}
|
||||
|
||||
function makeDeps() {
|
||||
return {
|
||||
userService: {
|
||||
list: vi.fn(async () => []),
|
||||
getById: vi.fn(async (id: string) => makeUser({ id })),
|
||||
getByEmail: vi.fn(async (email: string) => makeUser({ email, id: 'user-2' })),
|
||||
create: vi.fn(async () => makeUser({ id: 'new-user' })),
|
||||
delete: vi.fn(async () => {}),
|
||||
verifyPassword: vi.fn(async () => true),
|
||||
setPassword: vi.fn(async () => {}),
|
||||
},
|
||||
rbacDefinitionService: {
|
||||
upsertByName: vi.fn(async () => ({})),
|
||||
},
|
||||
prisma: {
|
||||
secret: { findUnique: vi.fn(async () => null) }, // no settings secret → default ON
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Deps = ReturnType<typeof makeDeps>;
|
||||
|
||||
async function createApp(deps: Deps): Promise<FastifyInstance> {
|
||||
app = Fastify({ logger: false });
|
||||
app.setErrorHandler(errorHandler);
|
||||
// Emulate the global auth hook: set userId from a test header when present.
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const u = request.headers['x-test-user'];
|
||||
if (typeof u === 'string') request.userId = u;
|
||||
});
|
||||
registerUserRoutes(app, deps as unknown as Parameters<typeof registerUserRoutes>[1]);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(async () => { await app?.close(); });
|
||||
|
||||
describe('POST /api/v1/users/me/password (self-service)', () => {
|
||||
let deps: Deps;
|
||||
beforeEach(() => { deps = makeDeps(); });
|
||||
|
||||
it('changes password when current password verifies', async () => {
|
||||
await createApp(deps);
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/v1/users/me/password',
|
||||
headers: { 'x-test-user': 'user-1' },
|
||||
payload: { currentPassword: 'oldpass12', newPassword: 'newpass345' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true });
|
||||
expect(deps.userService.verifyPassword).toHaveBeenCalledWith('user-1', 'oldpass12');
|
||||
expect(deps.userService.setPassword).toHaveBeenCalledWith('user-1', 'newpass345');
|
||||
});
|
||||
|
||||
it('rejects with 401 when current password is wrong', async () => {
|
||||
deps.userService.verifyPassword.mockResolvedValueOnce(false);
|
||||
await createApp(deps);
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/v1/users/me/password',
|
||||
headers: { 'x-test-user': 'user-1' },
|
||||
payload: { currentPassword: 'wrong', newPassword: 'newpass345' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(deps.userService.setPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects with 401 when unauthenticated', async () => {
|
||||
await createApp(deps);
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/v1/users/me/password',
|
||||
payload: { currentPassword: 'x', newPassword: 'newpass345' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects with 400 when new password too short', async () => {
|
||||
await createApp(deps);
|
||||
const res = await app.inject({
|
||||
method: 'POST', url: '/api/v1/users/me/password',
|
||||
headers: { 'x-test-user': 'user-1' },
|
||||
payload: { currentPassword: 'oldpass12', newPassword: 'short' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(deps.userService.setPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/v1/users/:id/password (admin reset)', () => {
|
||||
let deps: Deps;
|
||||
beforeEach(() => { deps = makeDeps(); });
|
||||
|
||||
it('resets by id without requiring a current password', async () => {
|
||||
await createApp(deps);
|
||||
const res = await app.inject({
|
||||
method: 'PUT', url: '/api/v1/users/user-9/password',
|
||||
payload: { newPassword: 'resetpass99' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.userService.setPassword).toHaveBeenCalledWith('user-9', 'resetpass99');
|
||||
expect(deps.userService.verifyPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves an email target to its id before resetting', async () => {
|
||||
await createApp(deps);
|
||||
const res = await app.inject({
|
||||
method: 'PUT', url: `/api/v1/users/${encodeURIComponent('other@example.com')}/password`,
|
||||
payload: { newPassword: 'resetpass99' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.userService.getByEmail).toHaveBeenCalledWith('other@example.com');
|
||||
expect(deps.userService.setPassword).toHaveBeenCalledWith('user-2', 'resetpass99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/users — self-permission seeding', () => {
|
||||
let deps: Deps;
|
||||
beforeEach(() => { deps = makeDeps(); });
|
||||
|
||||
it('seeds the self password permission when the setting is ON (default)', async () => {
|
||||
await createApp(deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } });
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({
|
||||
name: 'self-new-user',
|
||||
subjects: [{ kind: 'User', name: 'me@example.com' }],
|
||||
roleBindings: [{ role: 'run', action: 'set-own-password' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT seed when the setting is disabled', async () => {
|
||||
deps.prisma.secret.findUnique.mockResolvedValueOnce({ name: 'mcpctl-system-settings', data: { allowSelfPasswordChange: false } } as never);
|
||||
await createApp(deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } });
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(deps.rbacDefinitionService.upsertByName).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
124
src/mcplocal/tests/smoke/passwd.smoke.test.ts
Normal file
124
src/mcplocal/tests/smoke/passwd.smoke.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user