fix: RBAC name-scoped access — CUID resolution + list filtering
Two bugs fixed: - GET /api/v1/servers/:cuid now resolves CUID→name before RBAC check, so name-scoped bindings match correctly - List endpoints now filter responses via preSerialization hook using getAllowedScope(), so name-scoped users only see their resources Also adds fulldeploy.sh orchestrator script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -681,6 +681,199 @@ describe('RbacService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllowedScope', () => {
|
||||
describe('unscoped binding → wildcard', () => {
|
||||
it('returns wildcard:true for matching resource', async () => {
|
||||
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 () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(true);
|
||||
expect(scope.names.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns wildcard:true with wildcard resource binding', 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);
|
||||
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('name-scoped binding → restricted', () => {
|
||||
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('returns names containing the scoped name', async () => {
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||
});
|
||||
|
||||
it('returns empty names for wrong resource', async () => {
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'secrets');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty names for wrong action', async () => {
|
||||
const scope = await service.getAllowedScope('user-1', 'edit', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple name-scoped bindings → union of names', () => {
|
||||
it('collects names from multiple bindings', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
id: 'def-1',
|
||||
name: 'rbac-a',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers', name: 'server-a' }],
|
||||
}),
|
||||
makeDef({
|
||||
id: 'def-2',
|
||||
name: 'rbac-b',
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers', name: 'server-b' }],
|
||||
}),
|
||||
]);
|
||||
const prisma = mockPrisma({
|
||||
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||
groupMember: { findMany: vi.fn(async () => []) },
|
||||
});
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names).toEqual(new Set(['server-a', 'server-b']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed scoped + unscoped → wildcard wins', () => {
|
||||
it('returns wildcard:true when any binding is unscoped', async () => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||
{ role: 'view', 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);
|
||||
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no matching permissions → empty', () => {
|
||||
it('returns wildcard:false with empty names', async () => {
|
||||
const repo = mockRepo([]);
|
||||
const prisma = mockPrisma();
|
||||
const service = new RbacService(repo, prisma);
|
||||
|
||||
const scope = await service.getAllowedScope('unknown', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit role grants view scope', () => {
|
||||
let service: RbacService;
|
||||
|
||||
beforeEach(() => {
|
||||
const repo = mockRepo([
|
||||
makeDef({
|
||||
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||
roleBindings: [{ role: 'edit', 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('returns names for view action', async () => {
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||
});
|
||||
|
||||
it('returns names for create action', async () => {
|
||||
const scope = await service.getAllowedScope('user-1', 'create', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||
});
|
||||
|
||||
it('returns names for delete action', async () => {
|
||||
const scope = await service.getAllowedScope('user-1', 'delete', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('operation bindings are ignored', () => {
|
||||
it('returns empty names when only operation bindings exist', 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);
|
||||
|
||||
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||
expect(scope.wildcard).toBe(false);
|
||||
expect(scope.names.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown/legacy roles are denied', () => {
|
||||
let service: RbacService;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user