fix: RBAC name-scoped access — CUID resolution + list filtering
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

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:
Michal
2026-02-23 12:26:37 +00:00
parent da14bb8c23
commit 604bd76d60
7 changed files with 736 additions and 1 deletions

View File

@@ -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;