feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- Remove ProjectMember model entirely (RBAC manages project access)
- Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose)
- Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model
- Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER)
- Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach
- Remove members from backup/restore, apply, get, describe
- Prisma migration to drop ProjectMember table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-23 17:50:01 +00:00
parent 5844d6c73f
commit 783cf15179
23 changed files with 283 additions and 219 deletions

View File

@@ -93,6 +93,18 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
if (mcpConfigMatch?.[1]) {
return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] };
}
// Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit'
const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/);
if (projectServersMatch?.[1] && method !== 'GET') {
return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] };
}
// Map HTTP method to action
let action: RbacAction;
switch (method) {
@@ -223,7 +235,7 @@ 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, serverRepo, secretRepo, userRepo);
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo);
const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector();
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);

View File

@@ -2,12 +2,10 @@ import type { PrismaClient, Project } from '@prisma/client';
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 {
@@ -18,7 +16,8 @@ export interface IProjectRepository {
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>;
addServer(projectId: string, serverId: string): Promise<void>;
removeServer(projectId: string, serverId: string): Promise<void>;
}
export class ProjectRepository implements IProjectRepository {
@@ -76,14 +75,17 @@ export class ProjectRepository implements IProjectRepository {
});
}
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 })),
});
}
async addServer(projectId: string, serverId: string): Promise<void> {
await this.prisma.projectServer.upsert({
where: { projectId_serverId: { projectId, serverId } },
create: { projectId, serverId },
update: {},
});
}
async removeServer(projectId: string, serverId: string): Promise<void> {
await this.prisma.projectServer.deleteMany({
where: { projectId, serverId },
});
}
}

View File

@@ -34,6 +34,21 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
return service.generateMcpConfig(request.params.id);
});
// Attach a server to a project
app.post<{ Params: { id: string }; Body: { server: string } }>('/api/v1/projects/:id/servers', async (request) => {
const body = request.body as { server?: string };
if (!body.server) {
throw Object.assign(new Error('Missing "server" in request body'), { statusCode: 400 });
}
return service.addServer(request.params.id, body.server);
});
// Detach a server from a project
app.delete<{ Params: { id: string; serverName: string } }>('/api/v1/projects/:id/servers/:serverName', async (request, reply) => {
await service.removeServer(request.params.id, request.params.serverName);
reply.code(204);
});
// 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);

View File

@@ -43,7 +43,6 @@ export interface BackupProject {
llmProvider?: string | null;
llmModel?: string | null;
serverNames?: string[];
members?: string[];
}
export interface BackupUser {
@@ -120,7 +119,6 @@ export class BackupService {
llmProvider: proj.llmProvider,
llmModel: proj.llmModel,
serverNames: proj.servers.map((ps) => ps.server.name),
members: proj.members.map((pm) => pm.user.email),
}));
}

View File

@@ -260,15 +260,11 @@ export class RestoreService {
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
await this.projectRepo.update(existing.id, updateData);
// Re-link servers and members
// Re-link servers
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;
@@ -289,11 +285,6 @@ export class RestoreService {
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) {
@@ -359,15 +350,4 @@ export class RestoreService {
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

@@ -1,7 +1,6 @@
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';
@@ -13,7 +12,6 @@ export class ProjectService {
private readonly projectRepo: IProjectRepository,
private readonly serverRepo: IMcpServerRepository,
private readonly secretRepo: ISecretRepository,
private readonly userRepo: IUserRepository,
) {}
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
@@ -52,9 +50,6 @@ export class ProjectService {
// 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,
@@ -64,13 +59,10 @@ export class ProjectService {
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
});
// Link servers and members
// Link servers
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);
@@ -98,12 +90,6 @@ export class ProjectService {
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);
}
@@ -141,6 +127,22 @@ export class ProjectService {
return generateMcpConfig(serverEntries);
}
async addServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
const project = await this.resolveAndGet(idOrName);
const server = await this.serverRepo.findByName(serverName);
if (server === null) throw new NotFoundError(`Server not found: ${serverName}`);
await this.projectRepo.addServer(project.id, server.id);
return this.getById(project.id);
}
async removeServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
const project = await this.resolveAndGet(idOrName);
const server = await this.serverRepo.findByName(serverName);
if (server === null) throw new NotFoundError(`Server not found: ${serverName}`);
await this.projectRepo.removeServer(project.id, server.id);
return this.getById(project.id);
}
private async resolveServerNames(names: string[]): Promise<string[]> {
return Promise.all(names.map(async (name) => {
const server = await this.serverRepo.findByName(name);
@@ -148,12 +150,4 @@ export class ProjectService {
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

@@ -8,7 +8,7 @@ import {
type RbacRoleBinding,
} from '../validation/rbac-definition.schema.js';
export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run';
export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run' | 'expose';
export interface ResourcePermission {
role: string;
@@ -30,11 +30,12 @@ export interface AllowedScope {
/** Maps roles to the set of actions they grant. */
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
edit: ['view', 'create', 'delete', 'edit'],
edit: ['view', 'create', 'delete', 'edit', 'expose'],
view: ['view'],
create: ['create'],
delete: ['delete'],
run: ['run'],
expose: ['expose', 'view'],
};
export class RbacService {

View File

@@ -7,7 +7,6 @@ export const CreateProjectSchema = z.object({
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"' },
@@ -19,7 +18,6 @@ export const UpdateProjectSchema = z.object({
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

@@ -1,6 +1,6 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run'] as const;
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
/** Singular→plural map for resource names. */

View File

@@ -37,7 +37,6 @@ const mockProjects = [
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' } }],
},
];
@@ -91,11 +90,12 @@ 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, servers: [], members: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], 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 () => {}),
addServer: vi.fn(async () => {}),
removeServer: vi.fn(async () => {}),
};
}
@@ -214,12 +214,11 @@ describe('BackupService', () => {
expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]);
});
it('includes enriched projects with server names and members', async () => {
it('includes enriched projects with server names', 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 () => {
@@ -406,7 +405,7 @@ describe('RestoreService', () => {
}));
});
it('restores enriched projects with server and member linking', async () => {
it('restores enriched projects with server 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
@@ -419,14 +418,6 @@ describe('RestoreService', () => {
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);
@@ -437,7 +428,6 @@ describe('RestoreService', () => {
llmModel: 'gpt-4',
}));
expect(projectRepo.setServers).toHaveBeenCalled();
expect(projectRepo.setMembers).toHaveBeenCalled();
});
it('restores old bundle without users/groups/rbac', async () => {
@@ -551,7 +541,7 @@ describe('RestoreService', () => {
(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: [] }; });
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [] }; });
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
await restoreService.restore(fullBundle);

View File

@@ -3,7 +3,6 @@ import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.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 {
@@ -19,7 +18,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
createdAt: new Date(),
updatedAt: new Date(),
servers: [],
members: [],
...overrides,
};
}
@@ -64,7 +62,8 @@ function mockProjectRepo(): IProjectRepository {
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
delete: vi.fn(async () => {}),
setServers: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
addServer: vi.fn(async () => {}),
removeServer: vi.fn(async () => {}),
};
}
@@ -90,33 +89,17 @@ function mockSecretRepo(): ISecretRepository {
};
}
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();
serverRepo = mockServerRepo();
secretRepo = mockSecretRepo();
userRepo = mockUserRepo();
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
service = new ProjectService(projectRepo, serverRepo, secretRepo);
});
describe('create', () => {
@@ -164,32 +147,6 @@ describe('ProjectService', () => {
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);
@@ -219,16 +176,6 @@ describe('ProjectService', () => {
).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', () => {
@@ -277,19 +224,6 @@ describe('ProjectService', () => {
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);
@@ -314,6 +248,52 @@ describe('ProjectService', () => {
});
});
describe('addServer', () => {
it('attaches a server by name', async () => {
const project = makeProject({ id: 'proj-1' });
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
await service.addServer('proj-1', 'my-ha');
expect(projectRepo.addServer).toHaveBeenCalledWith('proj-1', 'srv-1');
});
it('throws NotFoundError when project not found', async () => {
await expect(service.addServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when server not found', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
await expect(service.addServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
});
});
describe('removeServer', () => {
it('detaches a server by name', async () => {
const project = makeProject({ id: 'proj-1' });
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
await service.removeServer('proj-1', 'my-ha');
expect(projectRepo.removeServer).toHaveBeenCalledWith('proj-1', 'srv-1');
});
it('throws NotFoundError when project not found', async () => {
await expect(service.removeServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when server not found', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
await expect(service.removeServer('proj-1', 'nonexistent')).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' });

View File

@@ -921,4 +921,92 @@ describe('RbacService', () => {
expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false);
});
});
describe('expose role', () => {
it('grants expose access with expose role binding', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects' }],
}),
]);
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', 'expose', 'projects')).toBe(true);
});
it('grants expose access with edit role binding (edit includes expose)', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'edit', resource: 'projects' }],
}),
]);
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', 'expose', 'projects')).toBe(true);
});
it('denies expose access with view role binding', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'view', resource: 'projects' }],
}),
]);
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', 'expose', 'projects')).toBe(false);
});
it('expose role also grants view access', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects' }],
}),
]);
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', 'projects')).toBe(true);
});
it('expose role with name-scoped binding', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects', name: 'my-project' }],
}),
]);
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', 'expose', 'projects', 'my-project')).toBe(true);
expect(await service.canAccess('user-1', 'expose', 'projects', 'other-project')).toBe(false);
});
it('getAllowedScope with expose role grants view scope', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
const scope = await service.getAllowedScope('user-1', 'view', 'projects');
expect(scope.wildcard).toBe(true);
});
});
});