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>
2026-02-23 11:05:19 +00:00
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-23 11:31:15 +00:00
|
|
|
|
|
|
|
|
describe('unknown/legacy roles are denied', () => {
|
|
|
|
|
let service: RbacService;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
const repo = mockRepo([
|
|
|
|
|
makeDef({
|
|
|
|
|
roleBindings: [{ role: 'admin', resource: '*' }],
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
const prisma = mockPrisma({
|
|
|
|
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
|
|
|
|
groupMember: { findMany: vi.fn(async () => []) },
|
|
|
|
|
});
|
|
|
|
|
service = new RbacService(repo, prisma);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('denies view when only legacy admin role exists', async () => {
|
|
|
|
|
expect(await service.canAccess('user-1', 'view', 'servers')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('denies create when only legacy admin role exists', async () => {
|
|
|
|
|
expect(await service.canAccess('user-1', 'create', 'servers')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('denies edit when only legacy admin role exists', async () => {
|
|
|
|
|
expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('denies delete when only legacy admin role exists', async () => {
|
|
|
|
|
expect(await service.canAccess('user-1', 'delete', 'servers')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('denies any made-up role', async () => {
|
|
|
|
|
const repo = mockRepo([
|
|
|
|
|
makeDef({
|
|
|
|
|
roleBindings: [{ role: 'superuser', resource: 'servers' }],
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
const prisma = mockPrisma({
|
|
|
|
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
|
|
|
|
groupMember: { findMany: vi.fn(async () => []) },
|
|
|
|
|
});
|
|
|
|
|
const svc = new RbacService(repo, prisma);
|
|
|
|
|
expect(await svc.canAccess('user-1', 'view', 'servers')).toBe(false);
|
|
|
|
|
expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
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>
2026-02-23 11:05:19 +00:00
|
|
|
});
|