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 { 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 }; groupMember: { findMany: ReturnType }; } function mockPrisma(overrides?: Partial): 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([]); }); }); });