Files
mcpctl/src/mcpd/tests/rbac.test.ts

684 lines
24 KiB
TypeScript
Raw Normal View History

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([]);
});
});
});