diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b0ddc77..9c42b73 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review skills console cache provider test migrate rotate" + local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -268,6 +268,9 @@ _mcpctl() { COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) fi return ;; + passwd) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + return ;; backup) local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$backup_sub" ]]; then diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 5e459dd..e9a8582 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review skills console cache provider test migrate rotate +set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -234,6 +234,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat -d 'Open an interactive chat session with an agent (REPL or one-shot).' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat-llm -d 'Stateless chat with any registered LLM (public or virtual). No threads, no tools.' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a passwd -d 'Change a user password (your own when called without an argument)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' diff --git a/src/cli/src/commands/passwd.ts b/src/cli/src/commands/passwd.ts new file mode 100644 index 0000000..69c725d --- /dev/null +++ b/src/cli/src/commands/passwd.ts @@ -0,0 +1,72 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface PasswdPromptDeps { + password(message: string): Promise; +} + +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 { + 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): 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('/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(`/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}.`); + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index cf2ecd6..892f46f 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -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), diff --git a/src/mcpd/src/bootstrap/self-password-permission.ts b/src/mcpd/src/bootstrap/self-password-permission.ts new file mode 100644 index 0000000..a8975a4 --- /dev/null +++ b/src/mcpd/src/bootstrap/self-password-permission.ts @@ -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-`) 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 { + await rbac.upsertByName({ + name: selfRbacName(user.id), + subjects: [{ kind: 'User', name: user.email }], + roleBindings: [{ role: 'run', action: SET_OWN_PASSWORD_OPERATION }], + }); +} diff --git a/src/mcpd/src/bootstrap/system-settings.ts b/src/mcpd/src/bootstrap/system-settings.ts new file mode 100644 index 0000000..9fb3f3e --- /dev/null +++ b/src/mcpd/src/bootstrap/system-settings.ts @@ -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 { + try { + const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } }); + if (!secret) return true; + const data = (secret.data ?? {}) as Record; + if (!(SETTING_ALLOW_SELF_PASSWORD in data)) return true; + return toBool(data[SETTING_ALLOW_SELF_PASSWORD], true); + } catch { + return true; + } +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 191b046..5754df6 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { mcptokens: mcpTokenRepo, llms: llmRepo, agents: agentRepo, + // Resolve user CUID → email so name-scoped `edit:users:` 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 { 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); diff --git a/src/mcpd/src/repositories/user.repository.ts b/src/mcpd/src/repositories/user.repository.ts index f008584..bb53e5a 100644 --- a/src/mcpd/src/repositories/user.repository.ts +++ b/src/mcpd/src/repositories/user.repository.ts @@ -6,9 +6,11 @@ export type SafeUser = Omit; export interface IUserRepository { findAll(): Promise; findById(id: string): Promise; + /** Like findById but includes the passwordHash — used by password verification. */ + findByIdWithHash(id: string): Promise; findByEmail(email: string, includeHash?: boolean): Promise | Promise; create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise; - update(id: string, data: { name?: string; role?: string }): Promise; + update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise; delete(id: string): Promise; count(): Promise; } @@ -43,6 +45,10 @@ export class UserRepository implements IUserRepository { }); } + async findByIdWithHash(id: string): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } + async findByEmail(email: string, includeHash?: boolean): Promise { 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 { + async update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise { const updateData: Record = {}; 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, diff --git a/src/mcpd/src/routes/auth.ts b/src/mcpd/src/routes/auth.ts index e69eba5..6dc3bd8 100644 --- a/src/mcpd/src/routes/auth.ts +++ b/src/mcpd/src/routes/auth.ts @@ -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', diff --git a/src/mcpd/src/routes/users.ts b/src/mcpd/src/routes/users.ts index 80adaf1..6b5dbcb 100644 --- a/src/mcpd/src/routes/users.ts +++ b/src/mcpd/src/routes/users.ts @@ -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; }); } diff --git a/src/mcpd/src/services/user.service.ts b/src/mcpd/src/services/user.service.ts index 81725fb..e8b2a4a 100644 --- a/src/mcpd/src/services/user.service.ts +++ b/src/mcpd/src/services/user.service.ts @@ -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 { + 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 { + 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 { return this.userRepo.count(); } diff --git a/src/mcpd/src/validation/user.schema.ts b/src/mcpd/src/validation/user.schema.ts index 3db8be4..fc2f203 100644 --- a/src/mcpd/src/validation/user.schema.ts +++ b/src/mcpd/src/validation/user.schema.ts @@ -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; diff --git a/src/mcpd/tests/auth-bootstrap.test.ts b/src/mcpd/tests/auth-bootstrap.test.ts index 051bff6..8a043ec 100644 --- a/src/mcpd/tests/auth-bootstrap.test.ts +++ b/src/mcpd/tests/auth-bootstrap.test.ts @@ -92,6 +92,7 @@ interface MockDeps { getByName: ReturnType; update: ReturnType; delete: ReturnType; + upsertByName: ReturnType; }; rbacService: { canAccess: ReturnType; @@ -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 () => { diff --git a/src/mcpd/tests/users-password.test.ts b/src/mcpd/tests/users-password.test.ts new file mode 100644 index 0000000..c25e704 --- /dev/null +++ b/src/mcpd/tests/users-password.test.ts @@ -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; + +async function createApp(deps: Deps): Promise { + 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[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(); + }); +}); diff --git a/src/mcplocal/tests/smoke/passwd.smoke.test.ts b/src/mcplocal/tests/smoke/passwd.smoke.test.ts new file mode 100644 index 0000000..f6ce344 --- /dev/null +++ b/src/mcplocal/tests/smoke/passwd.smoke.test.ts @@ -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 } + +function request(method: string, url: string, token?: string, payload?: unknown): Promise { + 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 = {}; + 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: () => 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 { + 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 { + 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); + }); +});