feat: granular RBAC with resource/operation bindings, users, groups

- Replace admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
  operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-23 11:05:19 +00:00
parent 23ab2a497e
commit c5147e8270
67 changed files with 7256 additions and 498 deletions

View File

@@ -14,6 +14,9 @@ import {
ProjectRepository,
AuditLogRepository,
TemplateRepository,
RbacDefinitionRepository,
UserRepository,
GroupRepository,
} from './repositories/index.js';
import {
McpServerService,
@@ -30,7 +33,13 @@ import {
McpProxyService,
TemplateService,
HealthProbeRunner,
RbacDefinitionService,
RbacService,
UserService,
GroupService,
} from './services/index.js';
import type { RbacAction } from './services/index.js';
import { createAuthMiddleware } from './middleware/auth.js';
import {
registerMcpServerRoutes,
registerSecretRoutes,
@@ -42,8 +51,72 @@ import {
registerAuthRoutes,
registerMcpProxyRoutes,
registerTemplateRoutes,
registerRbacRoutes,
registerUserRoutes,
registerGroupRoutes,
} from './routes/index.js';
type PermissionCheck =
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
| { kind: 'operation'; operation: string }
| { kind: 'skip' };
/**
* Map an HTTP method + URL to a permission check.
* Returns 'skip' for URLs that should not be RBAC-checked.
*/
function mapUrlToPermission(method: string, url: string): PermissionCheck {
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
if (!match) return { kind: 'skip' };
const segment = match[1] as string;
// Operations (non-resource endpoints)
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
const resourceMap: Record<string, string | undefined> = {
'servers': 'servers',
'instances': 'instances',
'secrets': 'secrets',
'projects': 'projects',
'templates': 'templates',
'users': 'users',
'groups': 'groups',
'rbac': 'rbac',
'audit-logs': 'rbac',
'mcp': 'servers',
};
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
// Map HTTP method to action
let action: RbacAction;
switch (method) {
case 'GET':
case 'HEAD':
action = 'view';
break;
case 'POST':
action = 'create';
break;
case 'DELETE':
action = 'delete';
break;
default: // PUT, PATCH
action = 'edit';
break;
}
// Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId)
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
const resourceName = nameMatch?.[1];
return { kind: 'resource', resource, action, resourceName };
}
async function main(): Promise<void> {
const config = loadConfigFromEnv();
@@ -82,6 +155,9 @@ async function main(): Promise<void> {
const projectRepo = new ProjectRepository(prisma);
const auditLogRepo = new AuditLogRepository(prisma);
const templateRepo = new TemplateRepository(prisma);
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma);
// Orchestrator
const orchestrator = new DockerContainerManager();
@@ -91,15 +167,24 @@ async function main(): Promise<void> {
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
serverService.setInstanceService(instanceService);
const secretService = new SecretService(secretRepo);
const projectService = new ProjectService(projectRepo);
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector();
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
const backupService = new BackupService(serverRepo, projectRepo, secretRepo);
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const authService = new AuthService(prisma);
const templateService = new TemplateService(templateRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, userRepo);
// Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({
findSession: (token) => authService.findSession(token),
});
// Server
const app = await createServer(config, {
@@ -115,6 +200,43 @@ async function main(): Promise<void> {
},
});
// ── Global auth hook ──
// Runs on all /api/v1/* routes EXCEPT auth endpoints and health checks.
// Tests that use createServer() directly are NOT affected — this hook
// is only registered here in main.ts.
app.addHook('preHandler', async (request, reply) => {
const url = request.url;
// Skip auth for health, auth, and root
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
if (!url.startsWith('/api/v1/')) return;
// Run auth middleware
await authMiddleware(request, reply);
});
// ── Global RBAC hook ──
// Runs after the auth hook. Maps URL to resource+action and checks permissions.
app.addHook('preHandler', async (request, reply) => {
if (reply.sent) return; // Auth hook already rejected
const url = request.url;
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
if (!url.startsWith('/api/v1/')) return;
if (request.userId === undefined) return; // Auth hook will handle 401
const check = mapUrlToPermission(request.method, url);
if (check.kind === 'skip') return;
let allowed: boolean;
if (check.kind === 'operation') {
allowed = await rbacService.canRunOperation(request.userId, check.operation);
} else {
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName);
}
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
}
});
// Routes
registerMcpServerRoutes(app, serverService, instanceService);
registerTemplateRoutes(app, templateService);
@@ -124,12 +246,15 @@ async function main(): Promise<void> {
registerAuditLogRoutes(app, auditLogService);
registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector });
registerBackupRoutes(app, { backupService, restoreService });
registerAuthRoutes(app, { authService });
registerAuthRoutes(app, { authService, userService, groupService, rbacDefinitionService, rbacService });
registerMcpProxyRoutes(app, {
mcpProxyService,
auditLogService,
authDeps: { findSession: (token) => authService.findSession(token) },
});
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService);
// Start
await app.listen({ port: config.port, host: config.host });

View File

@@ -0,0 +1,36 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { RbacService, RbacAction } from '../services/rbac.service.js';
export function createRbacMiddleware(rbacService: RbacService) {
function requirePermission(resource: string, action: RbacAction, resourceName?: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (request.userId === undefined) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
const allowed = await rbacService.canAccess(request.userId, action, resource, resourceName);
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return;
}
};
}
function requireOperation(operation: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (request.userId === undefined) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
const allowed = await rbacService.canRunOperation(request.userId, operation);
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return;
}
};
}
return { requirePermission, requireOperation };
}

View File

@@ -0,0 +1,93 @@
import type { PrismaClient, Group } from '@prisma/client';
export interface GroupWithMembers extends Group {
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
}
export interface IGroupRepository {
findAll(): Promise<GroupWithMembers[]>;
findById(id: string): Promise<GroupWithMembers | null>;
findByName(name: string): Promise<GroupWithMembers | null>;
create(data: { name: string; description?: string }): Promise<Group>;
update(id: string, data: { description?: string }): Promise<Group>;
delete(id: string): Promise<void>;
setMembers(groupId: string, userIds: string[]): Promise<void>;
findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>>;
}
const MEMBERS_INCLUDE = {
members: {
select: {
id: true,
user: {
select: { id: true, email: true, name: true },
},
},
},
} as const;
export class GroupRepository implements IGroupRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<GroupWithMembers[]> {
return this.prisma.group.findMany({
orderBy: { name: 'asc' },
include: MEMBERS_INCLUDE,
});
}
async findById(id: string): Promise<GroupWithMembers | null> {
return this.prisma.group.findUnique({
where: { id },
include: MEMBERS_INCLUDE,
});
}
async findByName(name: string): Promise<GroupWithMembers | null> {
return this.prisma.group.findUnique({
where: { name },
include: MEMBERS_INCLUDE,
});
}
async create(data: { name: string; description?: string }): Promise<Group> {
const createData: Record<string, unknown> = { name: data.name };
if (data.description !== undefined) createData['description'] = data.description;
return this.prisma.group.create({
data: createData as Parameters<PrismaClient['group']['create']>[0]['data'],
});
}
async update(id: string, data: { description?: string }): Promise<Group> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.group.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.group.delete({ where: { id } });
}
async setMembers(groupId: string, userIds: string[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.groupMember.deleteMany({ where: { groupId } });
if (userIds.length > 0) {
await tx.groupMember.createMany({
data: userIds.map((userId) => ({ groupId, userId })),
});
}
});
}
async findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>> {
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: {
group: {
select: { id: true, name: true },
},
},
});
return memberships.map((m) => m.group);
}
}

View File

@@ -1,9 +1,15 @@
export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js';
export { SecretRepository } from './secret.repository.js';
export type { IProjectRepository } from './project.repository.js';
export type { IProjectRepository, ProjectWithRelations } from './project.repository.js';
export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';
export { AuditLogRepository } from './audit-log.repository.js';
export type { ITemplateRepository } from './template.repository.js';
export { TemplateRepository } from './template.repository.js';
export type { IRbacDefinitionRepository } from './rbac-definition.repository.js';
export { RbacDefinitionRepository } from './rbac-definition.repository.js';
export type { IUserRepository, SafeUser } from './user.repository.js';
export { UserRepository } from './user.repository.js';
export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
export { GroupRepository } from './group.repository.js';

View File

@@ -1,49 +1,89 @@
import type { PrismaClient, Project } from '@prisma/client';
import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js';
export interface ProjectWithRelations extends Project {
servers: Array<{ id: string; server: { id: string; name: string } }>;
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
}
const PROJECT_INCLUDE = {
servers: { include: { server: { select: { id: true, name: true } } } },
members: { include: { user: { select: { id: true, email: true, name: true } } } },
} as const;
export interface IProjectRepository {
findAll(ownerId?: string): Promise<Project[]>;
findById(id: string): Promise<Project | null>;
findByName(name: string): Promise<Project | null>;
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
update(id: string, data: UpdateProjectInput): Promise<Project>;
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: string): Promise<ProjectWithRelations | null>;
findByName(name: string): Promise<ProjectWithRelations | null>;
create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>;
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
delete(id: string): Promise<void>;
setServers(projectId: string, serverIds: string[]): Promise<void>;
setMembers(projectId: string, userIds: string[]): Promise<void>;
}
export class ProjectRepository implements IProjectRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(ownerId?: string): Promise<Project[]> {
async findAll(ownerId?: string): Promise<ProjectWithRelations[]> {
const where = ownerId !== undefined ? { ownerId } : {};
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } });
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations[]>;
}
async findById(id: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { id } });
async findById(id: string): Promise<ProjectWithRelations | null> {
return this.prisma.project.findUnique({ where: { id }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
}
async findByName(name: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { name } });
async findByName(name: string): Promise<ProjectWithRelations | null> {
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
}
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> {
async create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
const createData: Record<string, unknown> = {
name: data.name,
description: data.description,
ownerId: data.ownerId,
proxyMode: data.proxyMode,
};
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
return this.prisma.project.create({
data: {
name: data.name,
description: data.description,
ownerId: data.ownerId,
},
});
data: createData as Parameters<PrismaClient['project']['create']>[0]['data'],
include: PROJECT_INCLUDE,
}) as unknown as Promise<ProjectWithRelations>;
}
async update(id: string, data: UpdateProjectInput): Promise<Project> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.project.update({ where: { id }, data: updateData });
async update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations> {
return this.prisma.project.update({
where: { id },
data,
include: PROJECT_INCLUDE,
}) as unknown as Promise<ProjectWithRelations>;
}
async delete(id: string): Promise<void> {
await this.prisma.project.delete({ where: { id } });
}
async setServers(projectId: string, serverIds: string[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.projectServer.deleteMany({ where: { projectId } });
if (serverIds.length > 0) {
await tx.projectServer.createMany({
data: serverIds.map((serverId) => ({ projectId, serverId })),
});
}
});
}
async setMembers(projectId: string, userIds: string[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.projectMember.deleteMany({ where: { projectId } });
if (userIds.length > 0) {
await tx.projectMember.createMany({
data: userIds.map((userId) => ({ projectId, userId })),
});
}
});
}
}

View File

@@ -0,0 +1,48 @@
import type { PrismaClient, RbacDefinition } from '@prisma/client';
import type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js';
export interface IRbacDefinitionRepository {
findAll(): Promise<RbacDefinition[]>;
findById(id: string): Promise<RbacDefinition | null>;
findByName(name: string): Promise<RbacDefinition | null>;
create(data: CreateRbacDefinitionInput): Promise<RbacDefinition>;
update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition>;
delete(id: string): Promise<void>;
}
export class RbacDefinitionRepository implements IRbacDefinitionRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<RbacDefinition[]> {
return this.prisma.rbacDefinition.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<RbacDefinition | null> {
return this.prisma.rbacDefinition.findUnique({ where: { id } });
}
async findByName(name: string): Promise<RbacDefinition | null> {
return this.prisma.rbacDefinition.findUnique({ where: { name } });
}
async create(data: CreateRbacDefinitionInput): Promise<RbacDefinition> {
return this.prisma.rbacDefinition.create({
data: {
name: data.name,
subjects: data.subjects,
roleBindings: data.roleBindings,
},
});
}
async update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition> {
const updateData: Record<string, unknown> = {};
if (data.subjects !== undefined) updateData['subjects'] = data.subjects;
if (data.roleBindings !== undefined) updateData['roleBindings'] = data.roleBindings;
return this.prisma.rbacDefinition.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.rbacDefinition.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,76 @@
import type { PrismaClient, User } from '@prisma/client';
/** User without the passwordHash field — safe for API responses. */
export type SafeUser = Omit<User, 'passwordHash'>;
export interface IUserRepository {
findAll(): Promise<SafeUser[]>;
findById(id: string): Promise<SafeUser | null>;
findByEmail(email: string, includeHash?: boolean): Promise<SafeUser | null> | Promise<User | null>;
create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser>;
delete(id: string): Promise<void>;
count(): Promise<number>;
}
/** Fields to select when passwordHash must be excluded. */
const safeSelect = {
id: true,
email: true,
name: true,
role: true,
provider: true,
externalId: true,
version: true,
createdAt: true,
updatedAt: true,
} as const;
export class UserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<SafeUser[]> {
return this.prisma.user.findMany({
select: safeSelect,
orderBy: { email: 'asc' },
});
}
async findById(id: string): Promise<SafeUser | null> {
return this.prisma.user.findUnique({
where: { id },
select: safeSelect,
});
}
async findByEmail(email: string, includeHash?: boolean): Promise<User | SafeUser | null> {
if (includeHash === true) {
return this.prisma.user.findUnique({ where: { email } });
}
return this.prisma.user.findUnique({
where: { email },
select: safeSelect,
});
}
async create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser> {
const createData: Record<string, unknown> = {
email: data.email,
passwordHash: data.passwordHash,
};
if (data.name !== undefined) createData['name'] = data.name;
if (data.role !== undefined) createData['role'] = data.role;
return this.prisma.user.create({
data: createData as Parameters<PrismaClient['user']['create']>[0]['data'],
select: safeSelect,
});
}
async delete(id: string): Promise<void> {
await this.prisma.user.delete({ where: { id } });
}
async count(): Promise<number> {
return this.prisma.user.count();
}
}

View File

@@ -1,15 +1,76 @@
import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../services/auth.service.js';
import type { UserService } from '../services/user.service.js';
import type { GroupService } from '../services/group.service.js';
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
import type { RbacService } from '../services/rbac.service.js';
import { createAuthMiddleware } from '../middleware/auth.js';
import { createRbacMiddleware } from '../middleware/rbac.js';
export interface AuthRouteDeps {
authService: AuthService;
userService: UserService;
groupService: GroupService;
rbacDefinitionService: RbacDefinitionService;
rbacService: RbacService;
}
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void {
const authMiddleware = createAuthMiddleware({
findSession: (token) => deps.authService.findSession(token),
});
const { requireOperation } = createRbacMiddleware(deps.rbacService);
// GET /api/v1/auth/status — unauthenticated, returns whether any users exist
app.get('/api/v1/auth/status', async () => {
const count = await deps.userService.count();
return { hasUsers: count > 0 };
});
// POST /api/v1/auth/bootstrap — only works when no users exist (first-run setup)
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
const count = await deps.userService.count();
if (count > 0) {
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
return;
}
const { email, password, name } = request.body as { email: string; password: string; name?: string };
// Create the first admin user
await deps.userService.create({
email,
password,
...(name !== undefined ? { name } : {}),
});
// Create "admin" group and add the first user to it
await deps.groupService.create({
name: 'admin',
description: 'Bootstrap admin group',
members: [email],
});
// Create bootstrap RBAC: full resource access + all operations
await deps.rbacDefinitionService.create({
name: 'bootstrap-admin',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: '*' },
{ role: 'run', action: 'impersonate' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
{ role: 'run', action: 'audit-purge' },
],
});
// Auto-login so the caller gets a token immediately
const session = await deps.authService.login(email, password);
reply.code(201);
return session;
});
// POST /api/v1/auth/login — no auth required
app.post<{
@@ -28,4 +89,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
await deps.authService.logout(token);
return { success: true };
});
// POST /api/v1/auth/impersonate — requires auth + run:impersonate operation
app.post(
'/api/v1/auth/impersonate',
{ preHandler: [authMiddleware, requireOperation('impersonate')] },
async (request) => {
const { email } = request.body as { email: string };
const result = await deps.authService.impersonate(email);
return result;
},
);
}

View File

@@ -13,7 +13,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
app.post<{
Body: {
password?: string;
resources?: Array<'servers' | 'secrets' | 'projects'>;
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
};
}>('/api/v1/backup', async (request) => {
const opts: BackupOptions = {};
@@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
const result = await deps.restoreService.restore(bundle, restoreOpts);
if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0) {
if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0 && result.usersCreated === 0 && result.groupsCreated === 0 && result.rbacCreated === 0) {
reply.code(422);
}

View File

@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import type { GroupService } from '../services/group.service.js';
export function registerGroupRoutes(
app: FastifyInstance,
service: GroupService,
): void {
app.get('/api/v1/groups', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
// Try by ID first, fall back to name lookup
try {
return await service.getById(request.params.id);
} catch {
return service.getByName(request.params.id);
}
});
app.post('/api/v1/groups', async (request, reply) => {
const group = await service.create(request.body);
reply.code(201);
return group;
});
app.put<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/groups/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -14,3 +14,6 @@ export type { AuthRouteDeps } from './auth.js';
export { registerMcpProxyRoutes } from './mcp-proxy.js';
export type { McpProxyRouteDeps } from './mcp-proxy.js';
export { registerTemplateRoutes } from './templates.js';
export { registerRbacRoutes } from './rbac-definitions.js';
export { registerUserRoutes } from './users.js';
export { registerGroupRoutes } from './groups.js';

View File

@@ -8,7 +8,7 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
});
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.getById(request.params.id);
return service.resolveAndGet(request.params.id);
});
app.post('/api/v1/projects', async (request, reply) => {
@@ -19,11 +19,24 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.update(request.params.id, request.body);
const project = await service.resolveAndGet(request.params.id);
return service.update(project.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => {
await service.delete(request.params.id);
const project = await service.resolveAndGet(request.params.id);
await service.delete(project.id);
reply.code(204);
});
// Generate .mcp.json for a project
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
return service.generateMcpConfig(request.params.id);
});
// List servers in a project (for mcplocal discovery)
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
const project = await service.resolveAndGet(request.params.id);
return project.servers.map((ps) => ps.server);
});
}

View File

@@ -0,0 +1,30 @@
import type { FastifyInstance } from 'fastify';
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
export function registerRbacRoutes(
app: FastifyInstance,
service: RbacDefinitionService,
): void {
app.get('/api/v1/rbac', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/rbac', async (request, reply) => {
const def = await service.create(request.body);
reply.code(201);
return def;
});
app.put<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,31 @@
import type { FastifyInstance } from 'fastify';
import type { UserService } from '../services/user.service.js';
export function registerUserRoutes(
app: FastifyInstance,
service: UserService,
): void {
app.get('/api/v1/users', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/users/:id', async (request) => {
// Support lookup by email (contains @) or by id
const idOrEmail = request.params.id;
if (idOrEmail.includes('@')) {
return service.getByEmail(idOrEmail);
}
return service.getById(idOrEmail);
});
app.post('/api/v1/users', async (request, reply) => {
const user = await service.create(request.body);
reply.code(201);
return user;
});
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => {
await service.delete(_request.params.id);
reply.code(204);
});
}

View File

@@ -63,4 +63,32 @@ export class AuthService {
}
return { userId: session.userId, expiresAt: session.expiresAt };
}
/**
* Create a session for a user by email without requiring their password.
* Used for admin impersonation.
*/
async impersonate(email: string): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (user === null) {
throw new AuthenticationError('User not found');
}
const token = randomUUID();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await this.prisma.session.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
return {
token,
expiresAt,
user: { id: user.id, email: user.email, role: user.role },
};
}
}

View File

@@ -1,5 +1,8 @@
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js';
import type { IUserRepository } from '../../repositories/user.repository.js';
import type { IGroupRepository } from '../../repositories/group.repository.js';
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
import { encrypt, isSensitiveKey } from './crypto.js';
import type { EncryptedPayload } from './crypto.js';
import { APP_VERSION } from '@mcpctl/shared';
@@ -12,6 +15,9 @@ export interface BackupBundle {
servers: BackupServer[];
secrets: BackupSecret[];
projects: BackupProject[];
users?: BackupUser[];
groups?: BackupGroup[];
rbacBindings?: BackupRbacBinding[];
encryptedSecrets?: EncryptedPayload;
}
@@ -33,11 +39,35 @@ export interface BackupSecret {
export interface BackupProject {
name: string;
description: string;
proxyMode?: string;
llmProvider?: string | null;
llmModel?: string | null;
serverNames?: string[];
members?: string[];
}
export interface BackupUser {
email: string;
name: string | null;
role: string;
provider: string | null;
}
export interface BackupGroup {
name: string;
description: string;
memberEmails: string[];
}
export interface BackupRbacBinding {
name: string;
subjects: unknown;
roleBindings: unknown;
}
export interface BackupOptions {
password?: string;
resources?: Array<'servers' | 'secrets' | 'projects'>;
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
}
export class BackupService {
@@ -45,14 +75,20 @@ export class BackupService {
private serverRepo: IMcpServerRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
private userRepo?: IUserRepository,
private groupRepo?: IGroupRepository,
private rbacRepo?: IRbacDefinitionRepository,
) {}
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
const resources = options?.resources ?? ['servers', 'secrets', 'projects'];
const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac'];
let servers: BackupServer[] = [];
let secrets: BackupSecret[] = [];
let projects: BackupProject[] = [];
let users: BackupUser[] = [];
let groups: BackupGroup[] = [];
let rbacBindings: BackupRbacBinding[] = [];
if (resources.includes('servers')) {
const allServers = await this.serverRepo.findAll();
@@ -80,6 +116,39 @@ export class BackupService {
projects = allProjects.map((proj) => ({
name: proj.name,
description: proj.description,
proxyMode: proj.proxyMode,
llmProvider: proj.llmProvider,
llmModel: proj.llmModel,
serverNames: proj.servers.map((ps) => ps.server.name),
members: proj.members.map((pm) => pm.user.email),
}));
}
if (resources.includes('users') && this.userRepo) {
const allUsers = await this.userRepo.findAll();
users = allUsers.map((u) => ({
email: u.email,
name: u.name,
role: u.role,
provider: u.provider,
}));
}
if (resources.includes('groups') && this.groupRepo) {
const allGroups = await this.groupRepo.findAll();
groups = allGroups.map((g) => ({
name: g.name,
description: g.description,
memberEmails: g.members.map((m) => m.user.email),
}));
}
if (resources.includes('rbac') && this.rbacRepo) {
const allRbac = await this.rbacRepo.findAll();
rbacBindings = allRbac.map((r) => ({
name: r.name,
subjects: r.subjects,
roleBindings: r.roleBindings,
}));
}
@@ -91,6 +160,9 @@ export class BackupService {
servers,
secrets,
projects,
users,
groups,
rbacBindings,
};
if (options?.password && secrets.length > 0) {

View File

@@ -1,5 +1,8 @@
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js';
import type { IUserRepository } from '../../repositories/user.repository.js';
import type { IGroupRepository } from '../../repositories/group.repository.js';
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
import { decrypt } from './crypto.js';
import type { BackupBundle } from './backup-service.js';
@@ -17,6 +20,12 @@ export interface RestoreResult {
secretsSkipped: number;
projectsCreated: number;
projectsSkipped: number;
usersCreated: number;
usersSkipped: number;
groupsCreated: number;
groupsSkipped: number;
rbacCreated: number;
rbacSkipped: number;
errors: string[];
}
@@ -25,6 +34,9 @@ export class RestoreService {
private serverRepo: IMcpServerRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
private userRepo?: IUserRepository,
private groupRepo?: IGroupRepository,
private rbacRepo?: IRbacDefinitionRepository,
) {}
validateBundle(bundle: unknown): bundle is BackupBundle {
@@ -36,6 +48,7 @@ export class RestoreService {
Array.isArray(b['secrets']) &&
Array.isArray(b['projects'])
);
// users, groups, rbacBindings are optional for backwards compatibility
}
async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> {
@@ -47,6 +60,12 @@ export class RestoreService {
secretsSkipped: 0,
projectsCreated: 0,
projectsSkipped: 0,
usersCreated: 0,
usersSkipped: 0,
groupsCreated: 0,
groupsSkipped: 0,
rbacCreated: 0,
rbacSkipped: 0,
errors: [],
};
@@ -78,6 +97,37 @@ export class RestoreService {
}
}
// Restore order: secrets → servers → users → groups → projects → rbacBindings
// Restore secrets
for (const secret of bundle.secrets) {
try {
const existing = await this.secretRepo.findByName(secret.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Secret "${secret.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.secretsSkipped++;
continue;
}
// overwrite
await this.secretRepo.update(existing.id, { data: secret.data });
result.secretsCreated++;
continue;
}
await this.secretRepo.create({
name: secret.name,
data: secret.data,
});
result.secretsCreated++;
} catch (err) {
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// Restore servers
for (const server of bundle.servers) {
try {
@@ -121,36 +171,75 @@ export class RestoreService {
}
}
// Restore secrets
for (const secret of bundle.secrets) {
try {
const existing = await this.secretRepo.findByName(secret.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Secret "${secret.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.secretsSkipped++;
// Restore users
if (bundle.users && this.userRepo) {
for (const user of bundle.users) {
try {
const existing = await this.userRepo.findByEmail(user.email);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`User "${user.email}" already exists`);
return result;
}
result.usersSkipped++;
continue;
}
// overwrite
await this.secretRepo.update(existing.id, { data: secret.data });
result.secretsCreated++;
continue;
}
await this.secretRepo.create({
name: secret.name,
data: secret.data,
});
result.secretsCreated++;
} catch (err) {
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
// Create with placeholder passwordHash — user must reset password
const createData: { email: string; passwordHash: string; name?: string; role?: string } = {
email: user.email,
passwordHash: '__RESTORED_MUST_RESET__',
role: user.role,
};
if (user.name !== null) createData.name = user.name;
await this.userRepo.create(createData);
result.usersCreated++;
} catch (err) {
result.errors.push(`Failed to restore user "${user.email}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
// Restore projects
// Restore groups
if (bundle.groups && this.groupRepo && this.userRepo) {
for (const group of bundle.groups) {
try {
const existing = await this.groupRepo.findByName(group.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Group "${group.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.groupsSkipped++;
continue;
}
// overwrite: update description and re-set members
await this.groupRepo.update(existing.id, { description: group.description });
if (group.memberEmails.length > 0) {
const memberIds = await this.resolveUserEmails(group.memberEmails);
await this.groupRepo.setMembers(existing.id, memberIds);
}
result.groupsCreated++;
continue;
}
const created = await this.groupRepo.create({
name: group.name,
description: group.description,
});
if (group.memberEmails.length > 0) {
const memberIds = await this.resolveUserEmails(group.memberEmails);
await this.groupRepo.setMembers(created.id, memberIds);
}
result.groupsCreated++;
} catch (err) {
result.errors.push(`Failed to restore group "${group.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
// Restore projects (enriched)
for (const project of bundle.projects) {
try {
const existing = await this.projectRepo.findByName(project.name);
@@ -164,22 +253,120 @@ export class RestoreService {
continue;
}
// overwrite
await this.projectRepo.update(existing.id, { description: project.description });
const updateData: Record<string, unknown> = { description: project.description };
if (project.proxyMode) updateData['proxyMode'] = project.proxyMode;
if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider;
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
await this.projectRepo.update(existing.id, updateData);
// Re-link servers and members
if (project.serverNames && project.serverNames.length > 0) {
const serverIds = await this.resolveServerNames(project.serverNames);
await this.projectRepo.setServers(existing.id, serverIds);
}
if (project.members && project.members.length > 0 && this.userRepo) {
const memberData = await this.resolveProjectMembers(project.members);
await this.projectRepo.setMembers(existing.id, memberData);
}
result.projectsCreated++;
continue;
}
await this.projectRepo.create({
const projectCreateData: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string } = {
name: project.name,
description: project.description,
ownerId: 'system',
});
proxyMode: project.proxyMode ?? 'direct',
};
if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider;
if (project.llmModel != null) projectCreateData.llmModel = project.llmModel;
const created = await this.projectRepo.create(projectCreateData);
// Link servers
if (project.serverNames && project.serverNames.length > 0) {
const serverIds = await this.resolveServerNames(project.serverNames);
await this.projectRepo.setServers(created.id, serverIds);
}
// Link members
if (project.members && project.members.length > 0 && this.userRepo) {
const memberData = await this.resolveProjectMembers(project.members);
await this.projectRepo.setMembers(created.id, memberData);
}
result.projectsCreated++;
} catch (err) {
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// Restore RBAC bindings
if (bundle.rbacBindings && this.rbacRepo) {
for (const rbac of bundle.rbacBindings) {
try {
const existing = await this.rbacRepo.findByName(rbac.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`RBAC binding "${rbac.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.rbacSkipped++;
continue;
}
// overwrite
await this.rbacRepo.update(existing.id, {
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
roleBindings: rbac.roleBindings as Array<{ role: string; resource: string } | { role: 'run'; action: string }>,
});
result.rbacCreated++;
continue;
}
await this.rbacRepo.create({
name: rbac.name,
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
roleBindings: rbac.roleBindings as Array<{ role: string; resource: string } | { role: 'run'; action: string }>,
});
result.rbacCreated++;
} catch (err) {
result.errors.push(`Failed to restore RBAC binding "${rbac.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
return result;
}
/** Resolve email addresses to user IDs via the user repository. */
private async resolveUserEmails(emails: string[]): Promise<string[]> {
const ids: string[] = [];
for (const email of emails) {
const user = await this.userRepo!.findByEmail(email);
if (user) ids.push(user.id);
}
return ids;
}
/** Resolve server names to server IDs via the server repository. */
private async resolveServerNames(names: string[]): Promise<string[]> {
const ids: string[] = [];
for (const name of names) {
const server = await this.serverRepo.findByName(name);
if (server) ids.push(server.id);
}
return ids;
}
/** Resolve project member emails to user IDs. */
private async resolveProjectMembers(
members: string[],
): Promise<string[]> {
const resolved: string[] = [];
for (const email of members) {
const user = await this.userRepo!.findByEmail(email);
if (user) resolved.push(user.id);
}
return resolved;
}
}

View File

@@ -0,0 +1,89 @@
import type { GroupWithMembers, IGroupRepository } from '../repositories/group.repository.js';
import type { IUserRepository } from '../repositories/user.repository.js';
import { CreateGroupSchema, UpdateGroupSchema } from '../validation/group.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class GroupService {
constructor(
private readonly groupRepo: IGroupRepository,
private readonly userRepo: IUserRepository,
) {}
async list(): Promise<GroupWithMembers[]> {
return this.groupRepo.findAll();
}
async getById(id: string): Promise<GroupWithMembers> {
const group = await this.groupRepo.findById(id);
if (group === null) {
throw new NotFoundError(`Group not found: ${id}`);
}
return group;
}
async getByName(name: string): Promise<GroupWithMembers> {
const group = await this.groupRepo.findByName(name);
if (group === null) {
throw new NotFoundError(`Group not found: ${name}`);
}
return group;
}
async create(input: unknown): Promise<GroupWithMembers> {
const data = CreateGroupSchema.parse(input);
const existing = await this.groupRepo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Group already exists: ${data.name}`);
}
const group = await this.groupRepo.create({
name: data.name,
description: data.description,
});
if (data.members.length > 0) {
const userIds = await this.resolveEmails(data.members);
await this.groupRepo.setMembers(group.id, userIds);
}
const result = await this.groupRepo.findById(group.id);
// Should always exist since we just created it
return result!;
}
async update(id: string, input: unknown): Promise<GroupWithMembers> {
const data = UpdateGroupSchema.parse(input);
// Verify exists
await this.getById(id);
if (data.description !== undefined) {
await this.groupRepo.update(id, { description: data.description });
}
if (data.members !== undefined) {
const userIds = await this.resolveEmails(data.members);
await this.groupRepo.setMembers(id, userIds);
}
return this.getById(id);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.groupRepo.delete(id);
}
private async resolveEmails(emails: string[]): Promise<string[]> {
const userIds: string[] = [];
for (const email of emails) {
const user = await this.userRepo.findByEmail(email);
if (user === null) {
throw new NotFoundError(`User not found: ${email}`);
}
userIds.push(user.id);
}
return userIds;
}
}

View File

@@ -27,3 +27,8 @@ export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js';
export { TemplateService } from './template.service.js';
export { HealthProbeRunner } from './health-probe.service.js';
export type { HealthCheckSpec, ProbeResult } from './health-probe.service.js';
export { RbacDefinitionService } from './rbac-definition.service.js';
export { RbacService } from './rbac.service.js';
export type { RbacAction, Permission } from './rbac.service.js';
export { UserService } from './user.service.js';
export { GroupService } from './group.service.js';

View File

@@ -1,8 +1,10 @@
import type { McpServer } from '@prisma/client';
export interface McpConfigServer {
command: string;
args: string[];
command?: string;
args?: string[];
url?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}
@@ -19,16 +21,24 @@ export function generateMcpConfig(
const mcpServers: Record<string, McpConfigServer> = {};
for (const { server, resolvedEnv } of servers) {
const config: McpConfigServer = {
command: 'npx',
args: ['-y', server.packageName ?? server.name],
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
// Point at mcpd proxy URL for non-STDIO transports
mcpServers[server.name] = {
url: `http://localhost:3100/api/v1/mcp/proxy/${server.name}`,
};
} else {
// STDIO — npx command approach
const config: McpConfigServer = {
command: 'npx',
args: ['-y', server.packageName ?? server.name],
};
if (Object.keys(resolvedEnv).length > 0) {
config.env = resolvedEnv;
if (Object.keys(resolvedEnv).length > 0) {
config.env = resolvedEnv;
}
mcpServers[server.name] = config;
}
mcpServers[server.name] = config;
}
return { mcpServers };

View File

@@ -1,18 +1,26 @@
import type { Project } from '@prisma/client';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { McpServer } from '@prisma/client';
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
import type { IUserRepository } from '../repositories/user.repository.js';
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
import { resolveServerEnv } from './env-resolver.js';
import { generateMcpConfig } from './mcp-config-generator.js';
import type { McpConfig } from './mcp-config-generator.js';
export class ProjectService {
constructor(
private readonly projectRepo: IProjectRepository,
private readonly serverRepo: IMcpServerRepository,
private readonly secretRepo: ISecretRepository,
private readonly userRepo: IUserRepository,
) {}
async list(ownerId?: string): Promise<Project[]> {
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
return this.projectRepo.findAll(ownerId);
}
async getById(id: string): Promise<Project> {
async getById(id: string): Promise<ProjectWithRelations> {
const project = await this.projectRepo.findById(id);
if (project === null) {
throw new NotFoundError(`Project not found: ${id}`);
@@ -20,7 +28,20 @@ export class ProjectService {
return project;
}
async create(input: unknown, ownerId: string): Promise<Project> {
/** Resolve by ID or name. */
async resolveAndGet(idOrName: string): Promise<ProjectWithRelations> {
// Try by ID first
const byId = await this.projectRepo.findById(idOrName);
if (byId !== null) return byId;
// Fall back to name
const byName = await this.projectRepo.findByName(idOrName);
if (byName !== null) return byName;
throw new NotFoundError(`Project not found: ${idOrName}`);
}
async create(input: unknown, ownerId: string): Promise<ProjectWithRelations> {
const data = CreateProjectSchema.parse(input);
const existing = await this.projectRepo.findByName(data.name);
@@ -28,17 +49,111 @@ export class ProjectService {
throw new ConflictError(`Project already exists: ${data.name}`);
}
return this.projectRepo.create({ ...data, ownerId });
// Resolve server names to IDs
const serverIds = await this.resolveServerNames(data.servers);
// Resolve member emails to user IDs
const resolvedMembers = await this.resolveMemberEmails(data.members);
const project = await this.projectRepo.create({
name: data.name,
description: data.description,
ownerId,
proxyMode: data.proxyMode,
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
});
// Link servers and members
if (serverIds.length > 0) {
await this.projectRepo.setServers(project.id, serverIds);
}
if (resolvedMembers.length > 0) {
await this.projectRepo.setMembers(project.id, resolvedMembers);
}
// Re-fetch to include relations
return this.getById(project.id);
}
async update(id: string, input: unknown): Promise<Project> {
async update(id: string, input: unknown): Promise<ProjectWithRelations> {
const data = UpdateProjectSchema.parse(input);
await this.getById(id);
return this.projectRepo.update(id, data);
const project = await this.getById(id);
// Build update data for scalar fields
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
// Update scalar fields if any changed
if (Object.keys(updateData).length > 0) {
await this.projectRepo.update(project.id, updateData);
}
// Update servers if provided
if (data.servers !== undefined) {
const serverIds = await this.resolveServerNames(data.servers);
await this.projectRepo.setServers(project.id, serverIds);
}
// Update members if provided
if (data.members !== undefined) {
const resolvedMembers = await this.resolveMemberEmails(data.members);
await this.projectRepo.setMembers(project.id, resolvedMembers);
}
// Re-fetch to include updated relations
return this.getById(project.id);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.projectRepo.delete(id);
}
async generateMcpConfig(idOrName: string): Promise<McpConfig> {
const project = await this.resolveAndGet(idOrName);
if (project.proxyMode === 'filtered') {
// Single entry pointing at mcplocal proxy
return {
mcpServers: {
[project.name]: {
url: `http://localhost:3100/api/v1/mcp/proxy/project/${project.name}`,
},
},
};
}
// Direct mode: fetch full servers and resolve env
const serverEntries: Array<{ server: McpServer; resolvedEnv: Record<string, string> }> = [];
for (const ps of project.servers) {
const server = await this.serverRepo.findById(ps.server.id);
if (server === null) continue;
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
serverEntries.push({ server, resolvedEnv });
}
return generateMcpConfig(serverEntries);
}
private async resolveServerNames(names: string[]): Promise<string[]> {
return Promise.all(names.map(async (name) => {
const server = await this.serverRepo.findByName(name);
if (server === null) throw new NotFoundError(`Server not found: ${name}`);
return server.id;
}));
}
private async resolveMemberEmails(emails: string[]): Promise<string[]> {
return Promise.all(emails.map(async (email) => {
const user = await this.userRepo.findByEmail(email);
if (user === null) throw new NotFoundError(`User not found: ${email}`);
return user.id;
}));
}
}

View File

@@ -0,0 +1,54 @@
import type { RbacDefinition } from '@prisma/client';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema } from '../validation/rbac-definition.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class RbacDefinitionService {
constructor(private readonly repo: IRbacDefinitionRepository) {}
async list(): Promise<RbacDefinition[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<RbacDefinition> {
const def = await this.repo.findById(id);
if (def === null) {
throw new NotFoundError(`RbacDefinition not found: ${id}`);
}
return def;
}
async getByName(name: string): Promise<RbacDefinition> {
const def = await this.repo.findByName(name);
if (def === null) {
throw new NotFoundError(`RbacDefinition not found: ${name}`);
}
return def;
}
async create(input: unknown): Promise<RbacDefinition> {
const data = CreateRbacDefinitionSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`RbacDefinition already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<RbacDefinition> {
const data = UpdateRbacDefinitionSchema.parse(input);
// Verify exists
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
await this.repo.delete(id);
}
}

View File

@@ -0,0 +1,130 @@
import type { PrismaClient } from '@prisma/client';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import {
normalizeResource,
isResourceBinding,
isOperationBinding,
type RbacSubject,
type RbacRoleBinding,
} from '../validation/rbac-definition.schema.js';
export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run';
export interface ResourcePermission {
role: string;
resource: string;
name?: string;
}
export interface OperationPermission {
role: 'run';
action: string;
}
export type Permission = ResourcePermission | OperationPermission;
/** Maps roles to the set of actions they grant. */
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
edit: ['view', 'create', 'delete', 'edit'],
view: ['view'],
create: ['create'],
delete: ['delete'],
run: ['run'],
};
export class RbacService {
constructor(
private readonly rbacRepo: IRbacDefinitionRepository,
private readonly prisma: PrismaClient,
) {}
/**
* Check whether a user is allowed to perform an action on a resource.
* @param resourceName — optional specific resource name (e.g. 'my-ha').
* If provided, name-scoped bindings only match when their name equals this.
* If omitted (listing), name-scoped bindings still grant access.
*/
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId);
const normalized = normalizeResource(resource);
for (const perm of permissions) {
if (!('resource' in perm)) continue;
const actions = ROLE_ACTIONS[perm.role];
if (actions === undefined) continue;
if (!actions.includes(action)) continue;
const permResource = normalizeResource(perm.resource);
if (permResource !== '*' && permResource !== normalized) continue;
// Name-scoped check: if binding has a name AND caller specified a resourceName, must match
if (perm.name !== undefined && resourceName !== undefined && perm.name !== resourceName) continue;
return true;
}
return false;
}
/**
* Check whether a user is allowed to perform a named operation.
* Operations require an explicit 'run' role binding with a matching action.
*/
async canRunOperation(userId: string, operation: string): Promise<boolean> {
const permissions = await this.getPermissions(userId);
for (const perm of permissions) {
if ('action' in perm && perm.role === 'run' && perm.action === operation) {
return true;
}
}
return false;
}
/**
* Collect all permissions for a user across all matching RbacDefinitions.
*/
async getPermissions(userId: string): Promise<Permission[]> {
// 1. Resolve user email
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (user === null) return [];
// 2. Resolve group names the user belongs to
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: { group: { select: { name: true } } },
});
const groupNames = memberships.map((m) => m.group.name);
// 3. Load all RbacDefinitions
const definitions = await this.rbacRepo.findAll();
// 4. Find definitions where user is a subject
const permissions: Permission[] = [];
for (const def of definitions) {
const subjects = def.subjects as RbacSubject[];
const matched = subjects.some((s) => {
if (s.kind === 'User') return s.name === user.email;
if (s.kind === 'Group') return groupNames.includes(s.name);
return false;
});
if (!matched) continue;
// 5. Collect roleBindings
const bindings = def.roleBindings as RbacRoleBinding[];
for (const binding of bindings) {
if (isResourceBinding(binding)) {
const perm: ResourcePermission = { role: binding.role, resource: binding.resource };
if (binding.name !== undefined) perm.name = binding.name;
permissions.push(perm);
} else if (isOperationBinding(binding)) {
permissions.push({ role: 'run', action: binding.action });
}
}
}
return permissions;
}
}

View File

@@ -0,0 +1,60 @@
import bcrypt from 'bcrypt';
import type { IUserRepository, SafeUser } from '../repositories/user.repository.js';
import { CreateUserSchema } from '../validation/user.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
const SALT_ROUNDS = 10;
export class UserService {
constructor(private readonly userRepo: IUserRepository) {}
async list(): Promise<SafeUser[]> {
return this.userRepo.findAll();
}
async getById(id: string): Promise<SafeUser> {
const user = await this.userRepo.findById(id);
if (user === null) {
throw new NotFoundError(`User not found: ${id}`);
}
return user;
}
async getByEmail(email: string): Promise<SafeUser> {
const user = await this.userRepo.findByEmail(email);
if (user === null) {
throw new NotFoundError(`User not found: ${email}`);
}
return user;
}
async create(input: unknown): Promise<SafeUser> {
const data = CreateUserSchema.parse(input);
const existing = await this.userRepo.findByEmail(data.email);
if (existing !== null) {
throw new ConflictError(`User already exists: ${data.email}`);
}
const passwordHash = await bcrypt.hash(data.password, SALT_ROUNDS);
const createData: { email: string; passwordHash: string; name?: string } = {
email: data.email,
passwordHash,
};
if (data.name !== undefined) {
createData.name = data.name;
}
return this.userRepo.create(createData);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.userRepo.delete(id);
}
async count(): Promise<number> {
return this.userRepo.count();
}
}

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const CreateGroupSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
members: z.array(z.string().email()).default([]),
});
export const UpdateGroupSchema = z.object({
description: z.string().max(1000).optional(),
members: z.array(z.string().email()).optional(),
});
export type CreateGroupInput = z.infer<typeof CreateGroupSchema>;
export type UpdateGroupInput = z.infer<typeof UpdateGroupSchema>;

View File

@@ -1,4 +1,6 @@
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
export type { CreateProjectInput, UpdateProjectInput } from './project.schema.js';
export type { CreateProjectInput, UpdateProjectInput, ProjectMemberInput } from './project.schema.js';
export { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema, RbacSubjectSchema, RbacRoleBindingSchema, RBAC_ROLES, RBAC_RESOURCES } from './rbac-definition.schema.js';
export type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput, RbacSubject, RbacRoleBinding } from './rbac-definition.schema.js';

View File

@@ -3,10 +3,23 @@ import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
});
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().max(100).optional(),
llmModel: z.string().max(100).optional(),
servers: z.array(z.string().min(1)).default([]),
members: z.array(z.string().email()).default([]),
}).refine(
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
{ message: 'llmProvider is required when proxyMode is "filtered"' },
);
export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(),
proxyMode: z.enum(['direct', 'filtered']).optional(),
llmProvider: z.string().max(100).nullable().optional(),
llmModel: z.string().max(100).nullable().optional(),
servers: z.array(z.string().min(1)).optional(),
members: z.array(z.string().email()).optional(),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
/** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
instance: 'instances',
secret: 'secrets',
project: 'projects',
template: 'templates',
user: 'users',
group: 'groups',
};
/** Normalize a resource name to its canonical plural form. */
export function normalizeResource(resource: string): string {
return RESOURCE_ALIASES[resource] ?? resource;
}
export const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']),
name: z.string().min(1),
});
/** Resource binding: role grants access to a resource type (optionally scoped to a named instance). */
export const ResourceBindingSchema = z.object({
role: z.enum(RBAC_ROLES),
resource: z.string().min(1).transform(normalizeResource),
name: z.string().min(1).optional(),
});
/** Operation binding: 'run' role grants access to a named operation. */
export const OperationBindingSchema = z.object({
role: z.literal('run'),
action: z.string().min(1),
});
/** Union of both binding types. */
export const RbacRoleBindingSchema = z.union([
ResourceBindingSchema,
OperationBindingSchema,
]);
export type RbacSubject = z.infer<typeof RbacSubjectSchema>;
export type ResourceBinding = z.infer<typeof ResourceBindingSchema>;
export type OperationBinding = z.infer<typeof OperationBindingSchema>;
export type RbacRoleBinding = z.infer<typeof RbacRoleBindingSchema>;
export function isResourceBinding(b: RbacRoleBinding): b is ResourceBinding {
return 'resource' in b;
}
export function isOperationBinding(b: RbacRoleBinding): b is OperationBinding {
return 'action' in b;
}
export const CreateRbacDefinitionSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
subjects: z.array(RbacSubjectSchema).min(1),
roleBindings: z.array(RbacRoleBindingSchema).min(1),
});
export const UpdateRbacDefinitionSchema = z.object({
subjects: z.array(RbacSubjectSchema).min(1).optional(),
roleBindings: z.array(RbacRoleBindingSchema).min(1).optional(),
});
export type CreateRbacDefinitionInput = z.infer<typeof CreateRbacDefinitionSchema>;
export type UpdateRbacDefinitionInput = z.infer<typeof UpdateRbacDefinitionSchema>;

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
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(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

View File

@@ -0,0 +1,424 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerAuthRoutes } from '../src/routes/auth.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { AuthService, LoginResult } from '../src/services/auth.service.js';
import type { UserService } from '../src/services/user.service.js';
import type { GroupService } from '../src/services/group.service.js';
import type { RbacDefinitionService } from '../src/services/rbac-definition.service.js';
import type { RbacService, RbacAction } from '../src/services/rbac.service.js';
import type { SafeUser } from '../src/repositories/user.repository.js';
import type { RbacDefinition } from '@prisma/client';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
function makeLoginResult(overrides?: Partial<LoginResult>): LoginResult {
return {
token: 'test-token-123',
expiresAt: new Date(Date.now() + 86400_000),
user: { id: 'user-1', email: 'admin@example.com', role: 'user' },
...overrides,
};
}
function makeSafeUser(overrides?: Partial<SafeUser>): SafeUser {
return {
id: 'user-1',
email: 'admin@example.com',
name: null,
role: 'user',
provider: 'local',
externalId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeRbacDef(overrides?: Partial<RbacDefinition>): RbacDefinition {
return {
id: 'rbac-1',
name: 'bootstrap-admin',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: '*' },
{ role: 'run', action: 'impersonate' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
{ role: 'run', action: 'audit-purge' },
],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
interface MockDeps {
authService: {
login: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
findSession: ReturnType<typeof vi.fn>;
impersonate: ReturnType<typeof vi.fn>;
};
userService: {
count: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
getByEmail: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
groupService: {
create: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
getByName: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
rbacDefinitionService: {
create: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
getByName: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
rbacService: {
canAccess: ReturnType<typeof vi.fn>;
canRunOperation: ReturnType<typeof vi.fn>;
getPermissions: ReturnType<typeof vi.fn>;
};
}
function createMockDeps(): MockDeps {
return {
authService: {
login: vi.fn(async () => makeLoginResult()),
logout: vi.fn(async () => {}),
findSession: vi.fn(async () => null),
impersonate: vi.fn(async () => makeLoginResult({ token: 'impersonated-token' })),
},
userService: {
count: vi.fn(async () => 0),
create: vi.fn(async () => makeSafeUser()),
list: vi.fn(async () => []),
getById: vi.fn(async () => makeSafeUser()),
getByEmail: vi.fn(async () => makeSafeUser()),
delete: vi.fn(async () => {}),
},
groupService: {
create: vi.fn(async () => ({ id: 'grp-1', name: 'admin', description: 'Bootstrap admin group', members: [] })),
list: vi.fn(async () => []),
getById: vi.fn(async () => null),
getByName: vi.fn(async () => null),
update: vi.fn(async () => null),
delete: vi.fn(async () => {}),
},
rbacDefinitionService: {
create: vi.fn(async () => makeRbacDef()),
list: vi.fn(async () => []),
getById: vi.fn(async () => makeRbacDef()),
getByName: vi.fn(async () => null),
update: vi.fn(async () => makeRbacDef()),
delete: vi.fn(async () => {}),
},
rbacService: {
canAccess: vi.fn(async () => false),
canRunOperation: vi.fn(async () => false),
getPermissions: vi.fn(async () => []),
},
};
}
function createApp(deps: MockDeps): Promise<FastifyInstance> {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as unknown as {
authService: AuthService;
userService: UserService;
groupService: GroupService;
rbacDefinitionService: RbacDefinitionService;
rbacService: RbacService;
});
return app.ready();
}
describe('Auth Bootstrap', () => {
describe('GET /api/v1/auth/status', () => {
it('returns hasUsers: false when no users exist', async () => {
const deps = createMockDeps();
deps.userService.count.mockResolvedValue(0);
await createApp(deps);
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
expect(res.statusCode).toBe(200);
expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(false);
});
it('returns hasUsers: true when users exist', async () => {
const deps = createMockDeps();
deps.userService.count.mockResolvedValue(1);
await createApp(deps);
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' });
expect(res.statusCode).toBe(200);
expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(true);
});
});
describe('POST /api/v1/auth/bootstrap', () => {
it('creates admin user, admin group, RBAC definition targeting group, and returns session token', async () => {
const deps = createMockDeps();
deps.userService.count.mockResolvedValue(0);
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/bootstrap',
payload: { email: 'admin@example.com', password: 'securepass123' },
});
expect(res.statusCode).toBe(201);
const body = res.json<LoginResult>();
expect(body.token).toBe('test-token-123');
expect(body.user.email).toBe('admin@example.com');
// Verify user was created
expect(deps.userService.create).toHaveBeenCalledWith({
email: 'admin@example.com',
password: 'securepass123',
});
// Verify admin group was created with the user as member
expect(deps.groupService.create).toHaveBeenCalledWith({
name: 'admin',
description: 'Bootstrap admin group',
members: ['admin@example.com'],
});
// Verify RBAC definition targets the Group, not the User
expect(deps.rbacDefinitionService.create).toHaveBeenCalledWith({
name: 'bootstrap-admin',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: '*' },
{ role: 'run', action: 'impersonate' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
{ role: 'run', action: 'audit-purge' },
],
});
// Verify auto-login was called
expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123');
});
it('passes name when provided', async () => {
const deps = createMockDeps();
deps.userService.count.mockResolvedValue(0);
await createApp(deps);
await app.inject({
method: 'POST',
url: '/api/v1/auth/bootstrap',
payload: { email: 'admin@example.com', password: 'securepass123', name: 'Admin User' },
});
expect(deps.userService.create).toHaveBeenCalledWith({
email: 'admin@example.com',
password: 'securepass123',
name: 'Admin User',
});
});
it('returns 409 when users already exist', async () => {
const deps = createMockDeps();
deps.userService.count.mockResolvedValue(1);
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/bootstrap',
payload: { email: 'admin@example.com', password: 'securepass123' },
});
expect(res.statusCode).toBe(409);
expect(res.json<{ error: string }>().error).toContain('Users already exist');
// Should NOT have created user, group, or RBAC
expect(deps.userService.create).not.toHaveBeenCalled();
expect(deps.groupService.create).not.toHaveBeenCalled();
expect(deps.rbacDefinitionService.create).not.toHaveBeenCalled();
});
it('validates email and password via UserService', async () => {
const deps = createMockDeps();
deps.userService.count.mockResolvedValue(0);
// Simulate Zod validation error from UserService
deps.userService.create.mockRejectedValue(
Object.assign(new Error('Validation error'), { statusCode: 400, issues: [] }),
);
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/bootstrap',
payload: { email: 'not-an-email', password: 'short' },
});
// The error handler should handle the validation error
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
});
describe('POST /api/v1/auth/login', () => {
it('logs in successfully', async () => {
const deps = createMockDeps();
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@example.com', password: 'securepass123' },
});
expect(res.statusCode).toBe(200);
expect(res.json<LoginResult>().token).toBe('test-token-123');
});
});
describe('POST /api/v1/auth/logout', () => {
it('logs out with valid token', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue({
userId: 'user-1',
expiresAt: new Date(Date.now() + 86400_000),
});
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json<{ success: boolean }>().success).toBe(true);
expect(deps.authService.logout).toHaveBeenCalledWith('valid-token');
});
it('returns 401 without auth', async () => {
const deps = createMockDeps();
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
});
expect(res.statusCode).toBe(401);
});
});
describe('POST /api/v1/auth/impersonate', () => {
it('creates session for target user when caller is admin', async () => {
const deps = createMockDeps();
// Auth: valid session
deps.authService.findSession.mockResolvedValue({
userId: 'admin-user-id',
expiresAt: new Date(Date.now() + 86400_000),
});
// RBAC: allow impersonate operation
deps.rbacService.canRunOperation.mockResolvedValue(true);
// Impersonate returns token for target
deps.authService.impersonate.mockResolvedValue(
makeLoginResult({ token: 'impersonated-token', user: { id: 'user-2', email: 'target@example.com', role: 'user' } }),
);
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/impersonate',
headers: { authorization: 'Bearer admin-token' },
payload: { email: 'target@example.com' },
});
expect(res.statusCode).toBe(200);
const body = res.json<LoginResult>();
expect(body.token).toBe('impersonated-token');
expect(body.user.email).toBe('target@example.com');
expect(deps.authService.impersonate).toHaveBeenCalledWith('target@example.com');
});
it('returns 401 without auth', async () => {
const deps = createMockDeps();
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/impersonate',
payload: { email: 'target@example.com' },
});
expect(res.statusCode).toBe(401);
});
it('returns 403 when caller lacks admin permission on users', async () => {
const deps = createMockDeps();
// Auth: valid session
deps.authService.findSession.mockResolvedValue({
userId: 'non-admin-id',
expiresAt: new Date(Date.now() + 86400_000),
});
// RBAC: deny
deps.rbacService.canRunOperation.mockResolvedValue(false);
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/impersonate',
headers: { authorization: 'Bearer regular-token' },
payload: { email: 'target@example.com' },
});
expect(res.statusCode).toBe(403);
});
it('returns 401 when impersonation target does not exist', async () => {
const deps = createMockDeps();
// Auth: valid session
deps.authService.findSession.mockResolvedValue({
userId: 'admin-user-id',
expiresAt: new Date(Date.now() + 86400_000),
});
// RBAC: allow
deps.rbacService.canRunOperation.mockResolvedValue(true);
// Impersonate fails — user not found
const authError = new Error('User not found');
(authError as Error & { statusCode: number }).statusCode = 401;
deps.authService.impersonate.mockRejectedValue(authError);
await createApp(deps);
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/impersonate',
headers: { authorization: 'Bearer admin-token' },
payload: { email: 'nonexistent@example.com' },
});
expect(res.statusCode).toBe(401);
});
});
});

View File

@@ -6,6 +6,9 @@ import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.
import { registerBackupRoutes } from '../src/routes/backup.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IUserRepository } from '../src/repositories/user.repository.js';
import type { IGroupRepository } from '../src/repositories/group.repository.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
// Mock data
const mockServers = [
@@ -31,8 +34,33 @@ const mockSecrets = [
const mockProjects = [
{
id: 'proj1', name: 'my-project', description: 'Test project',
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
members: [{ id: 'pm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } }],
},
];
const mockUsers = [
{ id: 'u1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
{ id: 'u2', email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
];
const mockGroups = [
{
id: 'g1', name: 'dev-team', description: 'Developers', version: 1, createdAt: new Date(), updatedAt: new Date(),
members: [
{ id: 'gm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } },
{ id: 'gm2', user: { id: 'u2', email: 'bob@test.com', name: null } },
],
},
];
const mockRbacDefinitions = [
{
id: 'rbac1', name: 'admins', version: 1, createdAt: new Date(), updatedAt: new Date(),
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
},
];
@@ -63,9 +91,46 @@ function mockProjectRepo(): IProjectRepository {
findAll: vi.fn(async () => [...mockProjects]),
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], members: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
delete: vi.fn(async () => {}),
setServers: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
};
}
function mockUserRepo(): IUserRepository {
return {
findAll: vi.fn(async () => [...mockUsers]),
findById: vi.fn(async (id: string) => mockUsers.find((u) => u.id === id) ?? null),
findByEmail: vi.fn(async (email: string) => mockUsers.find((u) => u.email === email) ?? null),
create: vi.fn(async (data) => ({ id: 'new-u', ...data, provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockUsers[0])),
delete: vi.fn(async () => {}),
count: vi.fn(async () => mockUsers.length),
};
}
function mockGroupRepo(): IGroupRepository {
return {
findAll: vi.fn(async () => [...mockGroups]),
findById: vi.fn(async (id: string) => mockGroups.find((g) => g.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockGroups.find((g) => g.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-g', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockGroups[0])),
update: vi.fn(async (id, data) => ({ ...mockGroups.find((g) => g.id === id)!, ...data })),
delete: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
findGroupsForUser: vi.fn(async () => []),
};
}
function mockRbacRepo(): IRbacDefinitionRepository {
return {
findAll: vi.fn(async () => [...mockRbacDefinitions]),
findById: vi.fn(async (id: string) => mockRbacDefinitions.find((r) => r.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockRbacDefinitions.find((r) => r.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-rbac', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockRbacDefinitions[0])),
update: vi.fn(async (id, data) => ({ ...mockRbacDefinitions.find((r) => r.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
@@ -110,7 +175,7 @@ describe('BackupService', () => {
let backupService: BackupService;
beforeEach(() => {
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo(), mockUserRepo(), mockGroupRepo(), mockRbacRepo());
});
it('creates backup with all resources', async () => {
@@ -126,11 +191,51 @@ describe('BackupService', () => {
expect(bundle.projects[0]!.name).toBe('my-project');
});
it('includes users in backup', async () => {
const bundle = await backupService.createBackup();
expect(bundle.users).toHaveLength(2);
expect(bundle.users![0]!.email).toBe('alice@test.com');
expect(bundle.users![0]!.role).toBe('ADMIN');
expect(bundle.users![1]!.email).toBe('bob@test.com');
expect(bundle.users![1]!.provider).toBe('oidc');
});
it('includes groups in backup with member emails', async () => {
const bundle = await backupService.createBackup();
expect(bundle.groups).toHaveLength(1);
expect(bundle.groups![0]!.name).toBe('dev-team');
expect(bundle.groups![0]!.memberEmails).toEqual(['alice@test.com', 'bob@test.com']);
});
it('includes rbac bindings in backup', async () => {
const bundle = await backupService.createBackup();
expect(bundle.rbacBindings).toHaveLength(1);
expect(bundle.rbacBindings![0]!.name).toBe('admins');
expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]);
});
it('includes enriched projects with server names and members', async () => {
const bundle = await backupService.createBackup();
const proj = bundle.projects[0]!;
expect(proj.proxyMode).toBe('direct');
expect(proj.serverNames).toEqual(['github']);
expect(proj.members).toEqual(['alice@test.com']);
});
it('filters resources', async () => {
const bundle = await backupService.createBackup({ resources: ['servers'] });
expect(bundle.servers).toHaveLength(2);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
expect(bundle.users).toHaveLength(0);
expect(bundle.groups).toHaveLength(0);
expect(bundle.rbacBindings).toHaveLength(0);
});
it('filters to only users', async () => {
const bundle = await backupService.createBackup({ resources: ['users'] });
expect(bundle.servers).toHaveLength(0);
expect(bundle.users).toHaveLength(2);
});
it('encrypts sensitive secret values when password provided', async () => {
@@ -150,13 +255,22 @@ describe('BackupService', () => {
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProjectRepo = mockProjectRepo();
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyUserRepo = mockUserRepo();
(emptyUserRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyGroupRepo = mockGroupRepo();
(emptyGroupRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyRbacRepo = mockRbacRepo();
(emptyRbacRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo, emptyUserRepo, emptyGroupRepo, emptyRbacRepo);
const bundle = await service.createBackup();
expect(bundle.servers).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
expect(bundle.users).toHaveLength(0);
expect(bundle.groups).toHaveLength(0);
expect(bundle.rbacBindings).toHaveLength(0);
});
});
@@ -165,16 +279,25 @@ describe('RestoreService', () => {
let serverRepo: IMcpServerRepository;
let secretRepo: ISecretRepository;
let projectRepo: IProjectRepository;
let userRepo: IUserRepository;
let groupRepo: IGroupRepository;
let rbacRepo: IRbacDefinitionRepository;
beforeEach(() => {
serverRepo = mockServerRepo();
secretRepo = mockSecretRepo();
projectRepo = mockProjectRepo();
userRepo = mockUserRepo();
groupRepo = mockGroupRepo();
rbacRepo = mockRbacRepo();
// Default: nothing exists yet
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo);
});
const validBundle = {
@@ -187,6 +310,23 @@ describe('RestoreService', () => {
projects: [{ name: 'test-proj', description: 'Test' }],
};
const fullBundle = {
...validBundle,
users: [
{ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null },
{ email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc' },
],
groups: [
{ name: 'dev-team', description: 'Developers', memberEmails: ['alice@test.com', 'bob@test.com'] },
],
rbacBindings: [
{ name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] },
],
projects: [
{ name: 'test-proj', description: 'Test', proxyMode: 'filtered', llmProvider: 'openai', llmModel: 'gpt-4', serverNames: ['github'], members: ['alice@test.com'] },
],
};
it('validates valid bundle', () => {
expect(restoreService.validateBundle(validBundle)).toBe(true);
});
@@ -197,6 +337,11 @@ describe('RestoreService', () => {
expect(restoreService.validateBundle({ version: '1' })).toBe(false);
});
it('validates old bundles without new fields (backwards compatibility)', () => {
expect(restoreService.validateBundle(validBundle)).toBe(true);
// Old bundle has no users/groups/rbacBindings — should still validate
});
it('restores all resources', async () => {
const result = await restoreService.restore(validBundle);
@@ -209,6 +354,104 @@ describe('RestoreService', () => {
expect(projectRepo.create).toHaveBeenCalled();
});
it('restores users', async () => {
const result = await restoreService.restore(fullBundle);
expect(result.usersCreated).toBe(2);
expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({
email: 'alice@test.com',
name: 'Alice',
role: 'ADMIN',
passwordHash: '__RESTORED_MUST_RESET__',
}));
expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({
email: 'bob@test.com',
role: 'USER',
}));
});
it('restores groups with member resolution', async () => {
// After users are created, simulate they can be found by email
let callCount = 0;
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockImplementation(async (email: string) => {
// First calls during user restore return null (user doesn't exist yet)
// Later calls during group member resolution return the created user
callCount++;
if (callCount > 2) {
// After user creation phase, simulate finding created users
if (email === 'alice@test.com') return { id: 'new-u-alice', email };
if (email === 'bob@test.com') return { id: 'new-u-bob', email };
}
return null;
});
const result = await restoreService.restore(fullBundle);
expect(result.groupsCreated).toBe(1);
expect(groupRepo.create).toHaveBeenCalledWith(expect.objectContaining({
name: 'dev-team',
description: 'Developers',
}));
expect(groupRepo.setMembers).toHaveBeenCalled();
});
it('restores rbac bindings', async () => {
const result = await restoreService.restore(fullBundle);
expect(result.rbacCreated).toBe(1);
expect(rbacRepo.create).toHaveBeenCalledWith(expect.objectContaining({
name: 'admins',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
}));
});
it('restores enriched projects with server and member linking', async () => {
// Simulate servers exist (restored in prior step)
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
// After server restore, we can find them
let serverCallCount = 0;
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockImplementation(async (name: string) => {
serverCallCount++;
// During server restore phase, first call returns null (server doesn't exist)
// During project restore phase, server should be found
if (serverCallCount > 1 && name === 'github') return { id: 'restored-s1', name: 'github' };
return null;
});
// Simulate users exist for member resolution
let userCallCount = 0;
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockImplementation(async (email: string) => {
userCallCount++;
if (userCallCount > 2 && email === 'alice@test.com') return { id: 'restored-u1', email };
return null;
});
const result = await restoreService.restore(fullBundle);
expect(result.projectsCreated).toBe(1);
expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({
name: 'test-proj',
proxyMode: 'filtered',
llmProvider: 'openai',
llmModel: 'gpt-4',
}));
expect(projectRepo.setServers).toHaveBeenCalled();
expect(projectRepo.setMembers).toHaveBeenCalled();
});
it('restores old bundle without users/groups/rbac', async () => {
const result = await restoreService.restore(validBundle);
expect(result.serversCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
expect(result.projectsCreated).toBe(1);
expect(result.usersCreated).toBe(0);
expect(result.groupsCreated).toBe(0);
expect(result.rbacCreated).toBe(0);
expect(result.errors).toHaveLength(0);
});
it('skips existing resources with skip strategy', async () => {
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockServers[0]);
const result = await restoreService.restore(validBundle, { conflictStrategy: 'skip' });
@@ -218,6 +461,33 @@ describe('RestoreService', () => {
expect(serverRepo.create).not.toHaveBeenCalled();
});
it('skips existing users', async () => {
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(mockUsers[0]);
const bundle = { ...validBundle, users: [{ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null }] };
const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' });
expect(result.usersSkipped).toBe(1);
expect(result.usersCreated).toBe(0);
});
it('skips existing groups', async () => {
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockGroups[0]);
const bundle = { ...validBundle, groups: [{ name: 'dev-team', description: 'Devs', memberEmails: [] }] };
const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' });
expect(result.groupsSkipped).toBe(1);
expect(result.groupsCreated).toBe(0);
});
it('skips existing rbac bindings', async () => {
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockRbacDefinitions[0]);
const bundle = { ...validBundle, rbacBindings: [{ name: 'admins', subjects: [], roleBindings: [] }] };
const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' });
expect(result.rbacSkipped).toBe(1);
expect(result.rbacCreated).toBe(0);
});
it('aborts on conflict with fail strategy', async () => {
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockServers[0]);
const result = await restoreService.restore(validBundle, { conflictStrategy: 'fail' });
@@ -233,6 +503,18 @@ describe('RestoreService', () => {
expect(serverRepo.update).toHaveBeenCalled();
});
it('overwrites existing rbac bindings', async () => {
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(mockRbacDefinitions[0]);
const bundle = {
...validBundle,
rbacBindings: [{ name: 'admins', subjects: [{ kind: 'User', name: 'new@test.com' }], roleBindings: [{ role: 'view', resource: 'servers' }] }],
};
const result = await restoreService.restore(bundle, { conflictStrategy: 'overwrite' });
expect(result.rbacCreated).toBe(1);
expect(rbacRepo.update).toHaveBeenCalled();
});
it('fails restore with encrypted bundle and no password', async () => {
const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt('{}', 'pw') };
const result = await restoreService.restore(encBundle);
@@ -262,6 +544,26 @@ describe('RestoreService', () => {
const result = await restoreService.restore(encBundle, { password: 'wrong' });
expect(result.errors[0]).toContain('Failed to decrypt');
});
it('restores in correct order: secrets → servers → users → groups → projects → rbac', async () => {
const callOrder: string[] = [];
(secretRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('secret'); return { id: 'sec' }; });
(serverRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; });
(userRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; });
(groupRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; });
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [], members: [] }; });
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
await restoreService.restore(fullBundle);
expect(callOrder[0]).toBe('secret');
expect(callOrder[1]).toBe('server');
expect(callOrder[2]).toBe('user');
expect(callOrder[3]).toBe('user'); // second user
expect(callOrder[4]).toBe('group');
expect(callOrder[5]).toBe('project');
expect(callOrder[6]).toBe('rbac');
});
});
describe('Backup Routes', () => {
@@ -272,7 +574,7 @@ describe('Backup Routes', () => {
const sRepo = mockServerRepo();
const secRepo = mockSecretRepo();
const prRepo = mockProjectRepo();
backupService = new BackupService(sRepo, prRepo, secRepo);
backupService = new BackupService(sRepo, prRepo, secRepo, mockUserRepo(), mockGroupRepo(), mockRbacRepo());
const rSRepo = mockServerRepo();
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
@@ -280,7 +582,13 @@ describe('Backup Routes', () => {
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPrRepo = mockProjectRepo();
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
const rUserRepo = mockUserRepo();
(rUserRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rGroupRepo = mockGroupRepo();
(rGroupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rRbacRepo = mockRbacRepo();
(rRbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, rUserRepo, rGroupRepo, rRbacRepo);
});
async function buildApp() {
@@ -289,7 +597,7 @@ describe('Backup Routes', () => {
return app;
}
it('POST /api/v1/backup returns bundle', async () => {
it('POST /api/v1/backup returns bundle with new resource types', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
@@ -303,6 +611,9 @@ describe('Backup Routes', () => {
expect(body.servers).toBeDefined();
expect(body.secrets).toBeDefined();
expect(body.projects).toBeDefined();
expect(body.users).toBeDefined();
expect(body.groups).toBeDefined();
expect(body.rbacBindings).toBeDefined();
});
it('POST /api/v1/restore imports bundle', async () => {
@@ -318,6 +629,9 @@ describe('Backup Routes', () => {
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.serversCreated).toBeDefined();
expect(body.usersCreated).toBeDefined();
expect(body.groupsCreated).toBeDefined();
expect(body.rbacCreated).toBeDefined();
});
it('POST /api/v1/restore rejects invalid bundle', async () => {

View File

@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GroupService } from '../src/services/group.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IGroupRepository, GroupWithMembers } from '../src/repositories/group.repository.js';
import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js';
import type { Group } from '@prisma/client';
function makeGroup(overrides: Partial<Group> = {}): Group {
return {
id: 'grp-1',
name: 'developers',
description: 'Dev team',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeGroupWithMembers(overrides: Partial<Group> = {}, members: GroupWithMembers['members'] = []): GroupWithMembers {
return {
...makeGroup(overrides),
members,
};
}
function makeUser(overrides: Partial<SafeUser> = {}): SafeUser {
return {
id: 'user-1',
email: 'alice@example.com',
name: 'Alice',
role: 'USER',
provider: null,
externalId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockGroupRepo(): IGroupRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => makeGroup({ name: data.name, description: data.description ?? '' })),
update: vi.fn(async (id, data) => makeGroup({ id, description: data.description ?? '' })),
delete: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
findGroupsForUser: vi.fn(async () => []),
};
}
function mockUserRepo(): IUserRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByEmail: vi.fn(async () => null),
create: vi.fn(async () => makeUser()),
delete: vi.fn(async () => {}),
count: vi.fn(async () => 0),
};
}
describe('GroupService', () => {
let groupRepo: ReturnType<typeof mockGroupRepo>;
let userRepo: ReturnType<typeof mockUserRepo>;
let service: GroupService;
beforeEach(() => {
groupRepo = mockGroupRepo();
userRepo = mockUserRepo();
service = new GroupService(groupRepo, userRepo);
});
describe('list', () => {
it('returns empty list', async () => {
const result = await service.list();
expect(result).toEqual([]);
expect(groupRepo.findAll).toHaveBeenCalled();
});
it('returns groups with members', async () => {
const groups = [
makeGroupWithMembers({ id: 'g1', name: 'admins' }, [
{ id: 'gm-1', user: { id: 'u1', email: 'a@b.com', name: 'A' } },
]),
];
vi.mocked(groupRepo.findAll).mockResolvedValue(groups);
const result = await service.list();
expect(result).toHaveLength(1);
expect(result[0].members).toHaveLength(1);
});
});
describe('create', () => {
it('creates a group without members', async () => {
const created = makeGroupWithMembers({ name: 'my-group', description: '' }, []);
vi.mocked(groupRepo.findById).mockResolvedValue(created);
const result = await service.create({ name: 'my-group' });
expect(result.name).toBe('my-group');
expect(groupRepo.create).toHaveBeenCalledWith({ name: 'my-group', description: '' });
expect(groupRepo.setMembers).not.toHaveBeenCalled();
});
it('creates a group with members', async () => {
const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' });
const bob = makeUser({ id: 'u-bob', email: 'bob@example.com', name: 'Bob' });
vi.mocked(userRepo.findByEmail).mockImplementation(async (email: string) => {
if (email === 'alice@example.com') return alice;
if (email === 'bob@example.com') return bob;
return null;
});
const created = makeGroupWithMembers({ name: 'team' }, [
{ id: 'gm-1', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } },
{ id: 'gm-2', user: { id: 'u-bob', email: 'bob@example.com', name: 'Bob' } },
]);
vi.mocked(groupRepo.findById).mockResolvedValue(created);
const result = await service.create({
name: 'team',
members: ['alice@example.com', 'bob@example.com'],
});
expect(groupRepo.setMembers).toHaveBeenCalledWith('grp-1', ['u-alice', 'u-bob']);
expect(result.members).toHaveLength(2);
});
it('throws ConflictError when name exists', async () => {
vi.mocked(groupRepo.findByName).mockResolvedValue(makeGroupWithMembers({ name: 'taken' }));
await expect(service.create({ name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError for unknown member email', async () => {
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
await expect(
service.create({ name: 'team', members: ['unknown@example.com'] }),
).rejects.toThrow(NotFoundError);
});
it('validates input', async () => {
await expect(service.create({ name: '' })).rejects.toThrow();
await expect(service.create({ name: 'UPPERCASE' })).rejects.toThrow();
});
});
describe('getById', () => {
it('returns group when found', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const result = await service.getById('g1');
expect(result.id).toBe('g1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('getByName', () => {
it('returns group when found', async () => {
const group = makeGroupWithMembers({ name: 'admins' });
vi.mocked(groupRepo.findByName).mockResolvedValue(group);
const result = await service.getByName('admins');
expect(result.name).toBe('admins');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getByName('missing')).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('updates description', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const updated = makeGroupWithMembers({ id: 'g1', description: 'new desc' });
// After update, getById is called again to return fresh data
vi.mocked(groupRepo.findById).mockResolvedValue(updated);
const result = await service.update('g1', { description: 'new desc' });
expect(groupRepo.update).toHaveBeenCalledWith('g1', { description: 'new desc' });
expect(result.description).toBe('new desc');
});
it('updates members (full replacement)', async () => {
const group = makeGroupWithMembers({ id: 'g1' }, [
{ id: 'gm-1', user: { id: 'u-old', email: 'old@example.com', name: 'Old' } },
]);
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' });
vi.mocked(userRepo.findByEmail).mockResolvedValue(alice);
const updated = makeGroupWithMembers({ id: 'g1' }, [
{ id: 'gm-2', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } },
]);
vi.mocked(groupRepo.findById).mockResolvedValueOnce(group).mockResolvedValue(updated);
const result = await service.update('g1', { members: ['alice@example.com'] });
expect(groupRepo.setMembers).toHaveBeenCalledWith('g1', ['u-alice']);
expect(result.members).toHaveLength(1);
});
it('throws NotFoundError when group not found', async () => {
await expect(service.update('missing', { description: 'x' })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError for unknown member email on update', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
await expect(
service.update('g1', { members: ['unknown@example.com'] }),
).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes group', async () => {
const group = makeGroupWithMembers({ id: 'g1' });
vi.mocked(groupRepo.findById).mockResolvedValue(group);
await service.delete('g1');
expect(groupRepo.delete).toHaveBeenCalledWith('g1');
});
it('throws NotFoundError when group not found', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
describe('group includes resolved member info', () => {
it('members include user id, email, and name', async () => {
const group = makeGroupWithMembers({ id: 'g1', name: 'team' }, [
{ id: 'gm-1', user: { id: 'u1', email: 'alice@example.com', name: 'Alice' } },
{ id: 'gm-2', user: { id: 'u2', email: 'bob@example.com', name: null } },
]);
vi.mocked(groupRepo.findById).mockResolvedValue(group);
const result = await service.getById('g1');
expect(result.members[0].user).toEqual({ id: 'u1', email: 'alice@example.com', name: 'Alice' });
expect(result.members[1].user).toEqual({ id: 'u2', email: 'bob@example.com', name: null });
});
});
});

View File

@@ -11,10 +11,17 @@ function makeServer(overrides: Partial<McpServer> = {}): McpServer {
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env: [],
healthCheck: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
templateName: null,
templateVersion: null,
...overrides,
};
}
@@ -25,7 +32,7 @@ describe('generateMcpConfig', () => {
expect(result).toEqual({ mcpServers: {} });
});
it('generates config for a single server', () => {
it('generates config for a single STDIO server', () => {
const result = generateMcpConfig([
{ server: makeServer(), resolvedEnv: {} },
]);
@@ -34,7 +41,7 @@ describe('generateMcpConfig', () => {
expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
});
it('includes resolved env when present', () => {
it('includes resolved env when present for STDIO server', () => {
const result = generateMcpConfig([
{ server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } },
]);
@@ -67,4 +74,35 @@ describe('generateMcpConfig', () => {
]);
expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']);
});
it('generates URL-based config for SSE servers', () => {
const server = makeServer({ name: 'sse-server', transport: 'SSE' });
const result = generateMcpConfig([
{ server, resolvedEnv: { TOKEN: 'abc' } },
]);
const config = result.mcpServers['sse-server'];
expect(config?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server');
expect(config?.command).toBeUndefined();
expect(config?.args).toBeUndefined();
expect(config?.env).toBeUndefined();
});
it('generates URL-based config for STREAMABLE_HTTP servers', () => {
const server = makeServer({ name: 'stream-server', transport: 'STREAMABLE_HTTP' });
const result = generateMcpConfig([
{ server, resolvedEnv: {} },
]);
const config = result.mcpServers['stream-server'];
expect(config?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/stream-server');
expect(config?.command).toBeUndefined();
});
it('mixes STDIO and SSE servers correctly', () => {
const result = generateMcpConfig([
{ server: makeServer({ name: 'stdio-srv', transport: 'STDIO' }), resolvedEnv: {} },
{ server: makeServer({ name: 'sse-srv', transport: 'SSE' }), resolvedEnv: {} },
]);
expect(result.mcpServers['stdio-srv']?.command).toBe('npx');
expect(result.mcpServers['sse-srv']?.url).toBeDefined();
});
});

View File

@@ -1,66 +1,403 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IUserRepository } from '../src/repositories/user.repository.js';
import type { McpServer } from '@prisma/client';
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
return {
id: 'proj-1',
name: 'test-project',
description: '',
ownerId: 'user-1',
proxyMode: 'direct',
llmProvider: null,
llmModel: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
servers: [],
members: [],
...overrides,
};
}
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
return {
id: 'srv-1',
name: 'test-server',
description: '',
packageName: '@mcp/test',
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env: [],
healthCheck: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
templateName: null,
templateVersion: null,
...overrides,
};
}
function mockProjectRepo(): IProjectRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'proj-1',
create: vi.fn(async (data) => makeProject({
name: data.name,
description: data.description ?? '',
description: data.description,
ownerId: data.ownerId,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
proxyMode: data.proxyMode,
llmProvider: data.llmProvider ?? null,
llmModel: data.llmModel ?? null,
})),
update: vi.fn(async (id) => ({
id, name: 'test', description: '', ownerId: 'u1', version: 2,
createdAt: new Date(), updatedAt: new Date(),
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
delete: vi.fn(async () => {}),
setServers: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => makeServer()),
update: vi.fn(async () => makeServer()),
delete: vi.fn(async () => {}),
};
}
function mockSecretRepo(): ISecretRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
update: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })),
delete: vi.fn(async () => {}),
};
}
function mockUserRepo(): IUserRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByEmail: vi.fn(async () => null),
create: vi.fn(async () => ({
id: 'u-1', email: 'test@example.com', name: null, role: 'user',
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
count: vi.fn(async () => 0),
};
}
describe('ProjectService', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let secretRepo: ReturnType<typeof mockSecretRepo>;
let userRepo: ReturnType<typeof mockUserRepo>;
let service: ProjectService;
beforeEach(() => {
projectRepo = mockProjectRepo();
service = new ProjectService(projectRepo);
serverRepo = mockServerRepo();
secretRepo = mockSecretRepo();
userRepo = mockUserRepo();
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
});
describe('create', () => {
it('creates a project', async () => {
it('creates a basic project', async () => {
// After create, getById is called to re-fetch with relations
const created = makeProject({ name: 'my-project', ownerId: 'user-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(created);
const result = await service.create({ name: 'my-project' }, 'user-1');
expect(result.name).toBe('my-project');
expect(result.ownerId).toBe('user-1');
expect(projectRepo.create).toHaveBeenCalled();
});
it('throws ConflictError when name exists', async () => {
vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never);
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject());
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
});
it('validates input', async () => {
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
});
it('creates project with servers (resolves names)', async () => {
const srv1 = makeServer({ id: 'srv-1', name: 'github' });
const srv2 = makeServer({ id: 'srv-2', name: 'slack' });
vi.mocked(serverRepo.findByName).mockImplementation(async (name) => {
if (name === 'github') return srv1;
if (name === 'slack') return srv2;
return null;
});
const created = makeProject({ id: 'proj-new' });
vi.mocked(projectRepo.create).mockResolvedValue(created);
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
id: 'proj-new',
servers: [
{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } },
{ id: 'ps-2', server: { id: 'srv-2', name: 'slack' } },
],
}));
const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1');
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-2']);
expect(result.servers).toHaveLength(2);
});
it('creates project with members (resolves emails)', async () => {
vi.mocked(userRepo.findByEmail).mockImplementation(async (email) => {
if (email === 'alice@test.com') {
return { id: 'u-alice', email: 'alice@test.com', name: 'Alice', role: 'user', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() };
}
return null;
});
const created = makeProject({ id: 'proj-new' });
vi.mocked(projectRepo.create).mockResolvedValue(created);
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
id: 'proj-new',
members: [
{ id: 'pm-1', user: { id: 'u-alice', email: 'alice@test.com', name: 'Alice' } },
],
}));
const result = await service.create({
name: 'my-project',
members: ['alice@test.com'],
}, 'user-1');
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-new', ['u-alice']);
expect(result.members).toHaveLength(1);
});
it('creates project with proxyMode and llmProvider', async () => {
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
vi.mocked(projectRepo.create).mockResolvedValue(created);
vi.mocked(projectRepo.findById).mockResolvedValue(created);
const result = await service.create({
name: 'filtered-proj',
proxyMode: 'filtered',
llmProvider: 'openai',
}, 'user-1');
expect(result.proxyMode).toBe('filtered');
expect(result.llmProvider).toBe('openai');
});
it('rejects filtered project without llmProvider', async () => {
await expect(
service.create({ name: 'bad-proj', proxyMode: 'filtered' }, 'user-1'),
).rejects.toThrow();
});
it('throws NotFoundError when server name resolution fails', async () => {
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
await expect(
service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'),
).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when member email resolution fails', async () => {
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
await expect(
service.create({
name: 'my-project',
members: ['nobody@test.com'],
}, 'user-1'),
).rejects.toThrow(NotFoundError);
});
});
describe('getById', () => {
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
it('returns project when found', async () => {
const proj = makeProject({ id: 'found' });
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
const result = await service.getById('found');
expect(result.id).toBe('found');
});
});
describe('resolveAndGet', () => {
it('finds by ID first', async () => {
const proj = makeProject({ id: 'proj-id' });
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
const result = await service.resolveAndGet('proj-id');
expect(result.id).toBe('proj-id');
});
it('falls back to name when ID not found', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(null);
const proj = makeProject({ name: 'my-name' });
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
const result = await service.resolveAndGet('my-name');
expect(result.name).toBe('my-name');
});
it('throws NotFoundError when neither ID nor name found', async () => {
await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('updates servers (full replacement)', async () => {
const existing = makeProject({ id: 'proj-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
const srv = makeServer({ id: 'srv-new', name: 'new-srv' });
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
await service.update('proj-1', { servers: ['new-srv'] });
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
});
it('updates members (full replacement)', async () => {
const existing = makeProject({ id: 'proj-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
vi.mocked(userRepo.findByEmail).mockResolvedValue({
id: 'u-bob', email: 'bob@test.com', name: 'Bob', role: 'user',
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.update('proj-1', { members: ['bob@test.com'] });
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-1', ['u-bob']);
});
it('updates proxyMode', async () => {
const existing = makeProject({ id: 'proj-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' });
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
proxyMode: 'filtered',
llmProvider: 'anthropic',
});
});
});
describe('delete', () => {
it('deletes project', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
await service.delete('p1');
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
});
it('throws NotFoundError when project does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
describe('generateMcpConfig', () => {
it('generates direct mode config with STDIO servers', async () => {
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
const project = makeProject({
id: 'proj-1',
name: 'my-proj',
proxyMode: 'direct',
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
});
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
const config = await service.generateMcpConfig('proj-1');
expect(config.mcpServers['github']).toBeDefined();
expect(config.mcpServers['github']?.command).toBe('npx');
expect(config.mcpServers['github']?.args).toEqual(['-y', '@mcp/github']);
});
it('generates direct mode config with SSE servers (URL-based)', async () => {
const srv = makeServer({ id: 'srv-2', name: 'sse-server', transport: 'SSE' });
const project = makeProject({
id: 'proj-1',
proxyMode: 'direct',
servers: [{ id: 'ps-1', server: { id: 'srv-2', name: 'sse-server' } }],
});
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
const config = await service.generateMcpConfig('proj-1');
expect(config.mcpServers['sse-server']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server');
expect(config.mcpServers['sse-server']?.command).toBeUndefined();
});
it('generates filtered mode config (single mcplocal entry)', async () => {
const project = makeProject({
id: 'proj-1',
name: 'filtered-proj',
proxyMode: 'filtered',
llmProvider: 'openai',
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
});
vi.mocked(projectRepo.findById).mockResolvedValue(project);
const config = await service.generateMcpConfig('proj-1');
expect(Object.keys(config.mcpServers)).toHaveLength(1);
expect(config.mcpServers['filtered-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/filtered-proj');
});
it('resolves by name for mcp-config', async () => {
const project = makeProject({
id: 'proj-1',
name: 'my-proj',
proxyMode: 'direct',
servers: [],
});
vi.mocked(projectRepo.findById).mockResolvedValue(null);
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
const config = await service.generateMcpConfig('my-proj');
expect(config.mcpServers).toEqual({});
});
it('includes env for STDIO servers', async () => {
const srv = makeServer({
id: 'srv-1',
name: 'github',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', value: 'tok123' }],
});
const project = makeProject({
id: 'proj-1',
proxyMode: 'direct',
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
});
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findById).mockResolvedValue(srv);
const config = await service.generateMcpConfig('proj-1');
expect(config.mcpServers['github']?.env?.['GITHUB_TOKEN']).toBe('tok123');
});
});
});

View File

@@ -0,0 +1,229 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RbacDefinitionService } from '../src/services/rbac-definition.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import type { RbacDefinition } from '@prisma/client';
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
return {
id: 'def-1',
name: 'test-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockRepo(): IRbacDefinitionRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => makeDef({ name: data.name, subjects: data.subjects, roleBindings: data.roleBindings })),
update: vi.fn(async (id, data) => makeDef({ id, ...data })),
delete: vi.fn(async () => {}),
};
}
describe('RbacDefinitionService', () => {
let repo: ReturnType<typeof mockRepo>;
let service: RbacDefinitionService;
beforeEach(() => {
repo = mockRepo();
service = new RbacDefinitionService(repo);
});
describe('list', () => {
it('returns all definitions', async () => {
const defs = await service.list();
expect(repo.findAll).toHaveBeenCalled();
expect(defs).toEqual([]);
});
});
describe('getById', () => {
it('returns definition when found', async () => {
const def = makeDef();
vi.mocked(repo.findById).mockResolvedValue(def);
const result = await service.getById('def-1');
expect(result.id).toBe('def-1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('getByName', () => {
it('returns definition when found', async () => {
const def = makeDef();
vi.mocked(repo.findByName).mockResolvedValue(def);
const result = await service.getByName('test-rbac');
expect(result.name).toBe('test-rbac');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getByName('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a definition with valid input', async () => {
const result = await service.create({
name: 'new-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
expect(result.name).toBe('new-rbac');
expect(repo.create).toHaveBeenCalled();
});
it('throws ConflictError when name exists', async () => {
vi.mocked(repo.findByName).mockResolvedValue(makeDef());
await expect(
service.create({
name: 'test-rbac',
subjects: [{ kind: 'User', name: 'bob@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
).rejects.toThrow(ConflictError);
});
it('throws on missing subjects', async () => {
await expect(
service.create({
name: 'bad-rbac',
subjects: [],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
).rejects.toThrow();
});
it('throws on missing roleBindings', async () => {
await expect(
service.create({
name: 'bad-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [],
}),
).rejects.toThrow();
});
it('throws on invalid role', async () => {
await expect(
service.create({
name: 'bad-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'superadmin', resource: '*' }],
}),
).rejects.toThrow();
});
it('throws on invalid subject kind', async () => {
await expect(
service.create({
name: 'bad-rbac',
subjects: [{ kind: 'Robot', name: 'bot-1' }],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
).rejects.toThrow();
});
it('throws on invalid name format', async () => {
await expect(
service.create({
name: 'Invalid Name!',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
).rejects.toThrow();
});
it('normalizes singular resource names to plural', async () => {
await service.create({
name: 'singular-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [
{ role: 'view', resource: 'server' },
{ role: 'edit', resource: 'secret', name: 'my-secret' },
],
});
const call = vi.mocked(repo.create).mock.calls[0]![0];
expect(call.roleBindings[0]!.resource).toBe('servers');
expect(call.roleBindings[1]!.resource).toBe('secrets');
expect(call.roleBindings[1]!.name).toBe('my-secret');
});
it('creates a definition with operation bindings', async () => {
const result = await service.create({
name: 'ops-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'run', action: 'logs' }],
});
expect(result.name).toBe('ops-rbac');
expect(repo.create).toHaveBeenCalled();
const call = vi.mocked(repo.create).mock.calls[0]![0];
expect(call.roleBindings[0]!.action).toBe('logs');
});
it('creates a definition with mixed resource and operation bindings', async () => {
const result = await service.create({
name: 'mixed-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [
{ role: 'view', resource: 'servers' },
{ role: 'run', action: 'logs' },
],
});
expect(result.name).toBe('mixed-rbac');
expect(repo.create).toHaveBeenCalled();
const call = vi.mocked(repo.create).mock.calls[0]![0];
expect(call.roleBindings).toHaveLength(2);
expect(call.roleBindings[0]!.resource).toBe('servers');
expect(call.roleBindings[1]!.action).toBe('logs');
});
it('creates a definition with name-scoped resource binding', async () => {
const result = await service.create({
name: 'scoped-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
});
expect(result.name).toBe('scoped-rbac');
expect(repo.create).toHaveBeenCalled();
const call = vi.mocked(repo.create).mock.calls[0]![0];
expect(call.roleBindings[0]!.resource).toBe('servers');
expect(call.roleBindings[0]!.name).toBe('my-ha');
});
});
describe('update', () => {
it('updates an existing definition', async () => {
vi.mocked(repo.findById).mockResolvedValue(makeDef());
await service.update('def-1', { subjects: [{ kind: 'User', name: 'bob@example.com' }] });
expect(repo.update).toHaveBeenCalledWith('def-1', {
subjects: [{ kind: 'User', name: 'bob@example.com' }],
});
});
it('throws NotFoundError when definition does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing definition', async () => {
vi.mocked(repo.findById).mockResolvedValue(makeDef());
await service.delete('def-1');
expect(repo.delete).toHaveBeenCalledWith('def-1');
});
it('throws NotFoundError when definition does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});

683
src/mcpd/tests/rbac.test.ts Normal file
View File

@@ -0,0 +1,683 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RbacService } from '../src/services/rbac.service.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import type { RbacDefinition, PrismaClient } from '@prisma/client';
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
return {
id: 'def-1',
name: 'test-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockRepo(definitions: RbacDefinition[] = []): IRbacDefinitionRepository {
return {
findAll: vi.fn(async () => definitions),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => makeDef()),
update: vi.fn(async () => makeDef()),
delete: vi.fn(async () => {}),
};
}
interface MockPrisma {
user: { findUnique: ReturnType<typeof vi.fn> };
groupMember: { findMany: ReturnType<typeof vi.fn> };
}
function mockPrisma(overrides?: Partial<MockPrisma>): PrismaClient {
return {
user: {
findUnique: overrides?.user?.findUnique ?? vi.fn(async () => null),
},
groupMember: {
findMany: overrides?.groupMember?.findMany ?? vi.fn(async () => []),
},
} as unknown as PrismaClient;
}
describe('RbacService', () => {
describe('canAccess — edit:* (wildcard resource)', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('can view servers', async () => {
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
});
it('can edit users', async () => {
expect(await service.canAccess('user-1', 'edit', 'users')).toBe(true);
});
it('can create resources (edit includes create)', async () => {
expect(await service.canAccess('user-1', 'create', 'servers')).toBe(true);
});
it('can delete resources (edit includes delete)', async () => {
expect(await service.canAccess('user-1', 'delete', 'secrets')).toBe(true);
});
it('cannot run resources (edit does not include run)', async () => {
expect(await service.canAccess('user-1', 'run', 'projects')).toBe(false);
});
it('can edit any resource (wildcard)', async () => {
expect(await service.canAccess('user-1', 'edit', 'secrets')).toBe(true);
expect(await service.canAccess('user-1', 'edit', 'projects')).toBe(true);
expect(await service.canAccess('user-1', 'edit', 'instances')).toBe(true);
});
});
describe('canAccess — edit:servers', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'bob@example.com' }],
roleBindings: [{ role: 'edit', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'bob@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('can view servers', async () => {
expect(await service.canAccess('user-2', 'view', 'servers')).toBe(true);
});
it('can edit servers', async () => {
expect(await service.canAccess('user-2', 'edit', 'servers')).toBe(true);
});
it('can create servers (edit includes create)', async () => {
expect(await service.canAccess('user-2', 'create', 'servers')).toBe(true);
});
it('can delete servers (edit includes delete)', async () => {
expect(await service.canAccess('user-2', 'delete', 'servers')).toBe(true);
});
it('cannot edit users (wrong resource)', async () => {
expect(await service.canAccess('user-2', 'edit', 'users')).toBe(false);
});
});
describe('canAccess — view:servers', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'carol@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'carol@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('can view servers', async () => {
expect(await service.canAccess('user-3', 'view', 'servers')).toBe(true);
});
it('cannot edit servers', async () => {
expect(await service.canAccess('user-3', 'edit', 'servers')).toBe(false);
});
it('cannot create servers', async () => {
expect(await service.canAccess('user-3', 'create', 'servers')).toBe(false);
});
it('cannot delete servers', async () => {
expect(await service.canAccess('user-3', 'delete', 'servers')).toBe(false);
});
});
describe('canAccess — create role', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'dan@example.com' }],
roleBindings: [{ role: 'create', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'dan@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('can create servers', async () => {
expect(await service.canAccess('user-d', 'create', 'servers')).toBe(true);
});
it('cannot view servers', async () => {
expect(await service.canAccess('user-d', 'view', 'servers')).toBe(false);
});
it('cannot delete servers', async () => {
expect(await service.canAccess('user-d', 'delete', 'servers')).toBe(false);
});
it('cannot edit servers', async () => {
expect(await service.canAccess('user-d', 'edit', 'servers')).toBe(false);
});
});
describe('canAccess — delete role', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'eve@example.com' }],
roleBindings: [{ role: 'delete', resource: 'secrets' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'eve@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('can delete secrets', async () => {
expect(await service.canAccess('user-e', 'delete', 'secrets')).toBe(true);
});
it('cannot create secrets', async () => {
expect(await service.canAccess('user-e', 'create', 'secrets')).toBe(false);
});
it('cannot view secrets', async () => {
expect(await service.canAccess('user-e', 'view', 'secrets')).toBe(false);
});
});
describe('canAccess — run role on resource', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'frank@example.com' }],
roleBindings: [{ role: 'run', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'frank@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('can run projects', async () => {
expect(await service.canAccess('user-f', 'run', 'projects')).toBe(true);
});
it('cannot view projects (run does not include view)', async () => {
expect(await service.canAccess('user-f', 'view', 'projects')).toBe(false);
});
it('cannot run servers (wrong resource)', async () => {
expect(await service.canAccess('user-f', 'run', 'servers')).toBe(false);
});
});
describe('canAccess — no matching binding', () => {
it('returns false when user has no matching definitions', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'other@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'nobody@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-x', 'view', 'servers')).toBe(false);
});
it('returns false when user does not exist', async () => {
const repo = mockRepo([makeDef()]);
const prisma = mockPrisma(); // user.findUnique returns null
const service = new RbacService(repo, prisma);
expect(await service.canAccess('nonexistent', 'view', 'servers')).toBe(false);
});
});
describe('canAccess — empty subjects', () => {
it('matches nobody when subjects is empty', async () => {
const repo = mockRepo([
makeDef({
subjects: [],
roleBindings: [{ role: 'edit', resource: '*' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(false);
});
});
describe('canAccess — group membership', () => {
it('grants access through group subject', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'Group', name: 'devs' }],
roleBindings: [{ role: 'edit', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'dave@example.com' })) },
groupMember: {
findMany: vi.fn(async () => [{ group: { name: 'devs' } }]),
},
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-4', 'view', 'servers')).toBe(true);
expect(await service.canAccess('user-4', 'edit', 'servers')).toBe(true);
expect(await service.canAccess('user-4', 'run', 'servers')).toBe(false);
});
it('denies access when user is not in the group', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'Group', name: 'devs' }],
roleBindings: [{ role: 'edit', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'eve@example.com' })) },
groupMember: {
findMany: vi.fn(async () => [{ group: { name: 'ops' } }]),
},
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-5', 'view', 'servers')).toBe(false);
});
});
describe('canAccess — multiple definitions (union)', () => {
it('unions permissions from multiple matching definitions', async () => {
const repo = mockRepo([
makeDef({
id: 'def-1',
name: 'rbac-viewers',
subjects: [{ kind: 'User', name: 'frank@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
makeDef({
id: 'def-2',
name: 'rbac-editors',
subjects: [{ kind: 'User', name: 'frank@example.com' }],
roleBindings: [{ role: 'edit', resource: 'secrets' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'frank@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
// From def-1: view on servers
expect(await service.canAccess('user-6', 'view', 'servers')).toBe(true);
expect(await service.canAccess('user-6', 'edit', 'servers')).toBe(false);
// From def-2: edit on secrets (includes view, create, delete)
expect(await service.canAccess('user-6', 'view', 'secrets')).toBe(true);
expect(await service.canAccess('user-6', 'edit', 'secrets')).toBe(true);
expect(await service.canAccess('user-6', 'create', 'secrets')).toBe(true);
// No permission on other resources
expect(await service.canAccess('user-6', 'view', 'users')).toBe(false);
});
});
describe('canAccess — mixed user and group subjects', () => {
it('matches on either user or group subject', async () => {
const repo = mockRepo([
makeDef({
subjects: [
{ kind: 'User', name: 'grace@example.com' },
{ kind: 'Group', name: 'admins' },
],
roleBindings: [{ role: 'edit', resource: '*' }],
}),
]);
// Test user match (not in group)
const prismaUser = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'grace@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const serviceUser = new RbacService(repo, prismaUser);
expect(await serviceUser.canAccess('user-7', 'edit', 'servers')).toBe(true);
// Test group match (different email)
const prismaGroup = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'hank@example.com' })) },
groupMember: { findMany: vi.fn(async () => [{ group: { name: 'admins' } }]) },
});
const serviceGroup = new RbacService(repo, prismaGroup);
expect(await serviceGroup.canAccess('user-8', 'edit', 'servers')).toBe(true);
});
});
describe('canAccess — singular resource names', () => {
it('normalizes singular resource in binding to match plural check', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: 'server' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(true);
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
});
it('normalizes singular resource in check to match plural binding', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'edit', 'server')).toBe(true);
expect(await service.canAccess('user-1', 'view', 'instance')).toBe(false);
});
});
describe('canAccess — name-scoped resource bindings', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('allows access to the named resource', async () => {
expect(await service.canAccess('user-1', 'view', 'servers', 'my-ha')).toBe(true);
});
it('denies access to a different named resource', async () => {
expect(await service.canAccess('user-1', 'view', 'servers', 'other-server')).toBe(false);
});
it('allows listing (no resourceName specified)', async () => {
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
});
});
describe('canAccess — unnamed binding matches any resourceName', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'view', resource: 'servers' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
service = new RbacService(repo, prisma);
});
it('allows access to any named resource', async () => {
expect(await service.canAccess('user-1', 'view', 'servers', 'any-server')).toBe(true);
});
it('allows listing', async () => {
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true);
});
});
describe('canRunOperation', () => {
it('grants operation when run:action binding matches', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'run', action: 'logs' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canRunOperation('user-1', 'logs')).toBe(true);
});
it('denies operation when action does not match', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'run', action: 'logs' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canRunOperation('user-1', 'backup')).toBe(false);
});
it('ignores resource bindings (only checks operation bindings)', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canRunOperation('user-1', 'logs')).toBe(false);
});
});
describe('mixed resource + operation bindings', () => {
let service: RbacService;
beforeEach(() => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: '*' },
{ role: 'run', action: 'impersonate' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'admin@example.com' })) },
groupMember: { findMany: vi.fn(async () => [{ group: { name: 'admin' } }]) },
});
service = new RbacService(repo, prisma);
});
it('can access resources', async () => {
expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(true);
expect(await service.canAccess('user-1', 'view', 'users')).toBe(true);
expect(await service.canAccess('user-1', 'run', 'projects')).toBe(true);
});
it('can run operations', async () => {
expect(await service.canRunOperation('user-1', 'impersonate')).toBe(true);
expect(await service.canRunOperation('user-1', 'logs')).toBe(true);
expect(await service.canRunOperation('user-1', 'backup')).toBe(true);
});
it('cannot run undefined operations', async () => {
expect(await service.canRunOperation('user-1', 'destroy-all')).toBe(false);
});
});
describe('getPermissions', () => {
it('returns all permissions for a user', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'view', resource: 'secrets' },
],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
const perms = await service.getPermissions('user-1');
expect(perms).toEqual([
{ role: 'edit', resource: '*' },
{ role: 'view', resource: 'secrets' },
]);
});
it('returns mixed resource and operation permissions', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'logs' },
],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
const perms = await service.getPermissions('user-1');
expect(perms).toEqual([
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'logs' },
]);
});
it('includes name field in name-scoped permissions', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
const perms = await service.getPermissions('user-1');
expect(perms).toEqual([
{ role: 'view', resource: 'servers', name: 'my-ha' },
]);
});
it('returns empty for unknown user', async () => {
const repo = mockRepo([makeDef()]);
const prisma = mockPrisma();
const service = new RbacService(repo, prisma);
const perms = await service.getPermissions('nonexistent');
expect(perms).toEqual([]);
});
it('returns empty when no definitions match', async () => {
const repo = mockRepo([
makeDef({
subjects: [{ kind: 'User', name: 'other@example.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
const perms = await service.getPermissions('user-1');
expect(perms).toEqual([]);
});
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from '../src/services/user.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js';
function makeSafeUser(overrides: Partial<SafeUser> = {}): SafeUser {
return {
id: 'user-1',
email: 'alice@example.com',
name: 'Alice',
role: 'USER',
provider: null,
externalId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockUserRepo(): IUserRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByEmail: vi.fn(async () => null),
create: vi.fn(async (data) =>
makeSafeUser({ email: data.email, name: data.name ?? null }),
),
delete: vi.fn(async () => {}),
count: vi.fn(async () => 0),
};
}
describe('UserService', () => {
let repo: ReturnType<typeof mockUserRepo>;
let service: UserService;
beforeEach(() => {
repo = mockUserRepo();
service = new UserService(repo);
});
// ── list ──────────────────────────────────────────────────
describe('list', () => {
it('returns empty array when no users', async () => {
const result = await service.list();
expect(result).toEqual([]);
expect(repo.findAll).toHaveBeenCalledOnce();
});
it('returns all users', async () => {
const users = [
makeSafeUser({ id: 'u1', email: 'a@b.com' }),
makeSafeUser({ id: 'u2', email: 'c@d.com' }),
];
vi.mocked(repo.findAll).mockResolvedValue(users);
const result = await service.list();
expect(result).toHaveLength(2);
expect(result[0]!.email).toBe('a@b.com');
});
});
// ── create ────────────────────────────────────────────────
describe('create', () => {
it('creates a user and hashes password', async () => {
const result = await service.create({
email: 'alice@example.com',
password: 'securePass123',
});
expect(result.email).toBe('alice@example.com');
expect(repo.create).toHaveBeenCalledOnce();
// Verify the passwordHash was generated (not the plain password)
const createCall = vi.mocked(repo.create).mock.calls[0]![0]!;
expect(createCall.passwordHash).toBeDefined();
expect(createCall.passwordHash).not.toBe('securePass123');
expect(createCall.passwordHash.startsWith('$2b$')).toBe(true);
});
it('creates a user with optional name', async () => {
await service.create({
email: 'bob@example.com',
password: 'securePass123',
name: 'Bob',
});
const createCall = vi.mocked(repo.create).mock.calls[0]![0]!;
expect(createCall.email).toBe('bob@example.com');
expect(createCall.name).toBe('Bob');
});
it('returns user without passwordHash', async () => {
const result = await service.create({
email: 'alice@example.com',
password: 'securePass123',
});
// SafeUser type should not have passwordHash
expect(result).not.toHaveProperty('passwordHash');
});
it('throws ConflictError when email already exists', async () => {
vi.mocked(repo.findByEmail).mockResolvedValue(makeSafeUser());
await expect(
service.create({ email: 'alice@example.com', password: 'securePass123' }),
).rejects.toThrow(ConflictError);
});
it('throws ZodError for invalid email', async () => {
await expect(
service.create({ email: 'not-an-email', password: 'securePass123' }),
).rejects.toThrow();
});
it('throws ZodError for short password', async () => {
await expect(
service.create({ email: 'a@b.com', password: 'short' }),
).rejects.toThrow();
});
it('throws ZodError for missing email', async () => {
await expect(
service.create({ password: 'securePass123' }),
).rejects.toThrow();
});
it('throws ZodError for password exceeding max length', async () => {
await expect(
service.create({ email: 'a@b.com', password: 'x'.repeat(129) }),
).rejects.toThrow();
});
});
// ── getById ───────────────────────────────────────────────
describe('getById', () => {
it('returns user when found', async () => {
const user = makeSafeUser();
vi.mocked(repo.findById).mockResolvedValue(user);
const result = await service.getById('user-1');
expect(result.email).toBe('alice@example.com');
expect(repo.findById).toHaveBeenCalledWith('user-1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
// ── getByEmail ────────────────────────────────────────────
describe('getByEmail', () => {
it('returns user when found', async () => {
const user = makeSafeUser();
vi.mocked(repo.findByEmail).mockResolvedValue(user);
const result = await service.getByEmail('alice@example.com');
expect(result.email).toBe('alice@example.com');
expect(repo.findByEmail).toHaveBeenCalledWith('alice@example.com');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getByEmail('nobody@example.com')).rejects.toThrow(NotFoundError);
});
});
// ── delete ────────────────────────────────────────────────
describe('delete', () => {
it('deletes user by id', async () => {
vi.mocked(repo.findById).mockResolvedValue(makeSafeUser());
await service.delete('user-1');
expect(repo.delete).toHaveBeenCalledWith('user-1');
});
it('throws NotFoundError when user does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
// ── count ─────────────────────────────────────────────────
describe('count', () => {
it('returns 0 when no users', async () => {
const result = await service.count();
expect(result).toBe(0);
});
it('returns 1 when one user exists', async () => {
vi.mocked(repo.count).mockResolvedValue(1);
const result = await service.count();
expect(result).toBe(1);
});
it('returns correct count for multiple users', async () => {
vi.mocked(repo.count).mockResolvedValue(5);
const result = await service.count();
expect(result).toBe(5);
});
});
});