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:
683
src/mcpd/tests/rbac.test.ts
Normal file
683
src/mcpd/tests/rbac.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user