Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/src/roles/dto/update-built-in-obligations.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsObject, IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class BuiltInObligationsBody {
@ApiProperty({
description: 'Whether the role must complete compliance tasks.',
example: false,
required: false,
})
@IsBoolean()
@IsOptional()
compliance?: boolean;
}

export class UpdateBuiltInObligationsDto {
@ApiProperty({
description: 'Obligations override for the built-in role.',
example: { compliance: false },
required: true,
type: BuiltInObligationsBody,
})
@IsObject()
@ValidateNested()
@Type(() => BuiltInObligationsBody)
obligations!: BuiltInObligationsBody;
}
70 changes: 70 additions & 0 deletions apps/api/src/roles/roles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { PermissionGuard } from '../auth/permission.guard';
import { RequirePermission } from '../auth/require-permission.decorator';
import type { AuthContext as AuthContextType } from '../auth/types';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateBuiltInObligationsDto } from './dto/update-built-in-obligations.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { RolesService } from './roles.service';

Expand Down Expand Up @@ -172,6 +173,75 @@ export class RolesController {
return { permissions, obligations };
}

@Get('built-in/:name/obligations')
@RequirePermission('ac', 'read')
@ApiOperation({
summary: 'Get obligations for a built-in role',
description:
'Returns the effective obligations for a built-in role (owner, admin, auditor, employee, contractor) — DB override if present, else the hardcoded default.',
})
@ApiParam({ name: 'name', description: 'Built-in role name', example: 'owner' })
@ApiResponse({
status: 200,
description: 'Effective obligations for the built-in role',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
obligations: {
type: 'object',
properties: { compliance: { type: 'boolean' } },
},
},
},
})
async getBuiltInObligations(
@OrganizationId() organizationId: string,
@Param('name') name: string,
) {
const obligations = await this.rolesService.getBuiltInObligations(
organizationId,
name,
);
return { name, obligations };
}

@Patch('built-in/:name/obligations')
@RequirePermission('ac', 'update')
@ApiOperation({
summary: 'Update obligations for a built-in role',
description:
'Override the obligations for a built-in role (e.g., turn off the compliance obligation for owners). Permissions stay sourced from the hardcoded defaults.',
})
@ApiParam({ name: 'name', description: 'Built-in role name', example: 'owner' })
@ApiBody({ type: UpdateBuiltInObligationsDto })
@ApiResponse({
status: 200,
description: 'Obligations updated',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
obligations: {
type: 'object',
properties: { compliance: { type: 'boolean' } },
},
},
},
})
@ApiResponse({ status: 400, description: 'Not a built-in role' })
async updateBuiltInObligations(
@OrganizationId() organizationId: string,
@Param('name') name: string,
@Body() dto: UpdateBuiltInObligationsDto,
) {
return this.rolesService.updateBuiltInObligations(
organizationId,
name,
dto.obligations,
);
}

@Get(':roleId')
@RequirePermission('ac', 'read')
@ApiOperation({
Expand Down
244 changes: 243 additions & 1 deletion apps/api/src/roles/roles.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jest.mock('@trycompai/auth', () => {

const BUILT_IN_ROLE_OBLIGATIONS: Record<string, Record<string, boolean>> = {
owner: { compliance: true },
admin: { compliance: true },
admin: {},
auditor: {},
employee: { compliance: true },
contractor: { compliance: true },
Expand All @@ -97,6 +97,7 @@ jest.mock('@db', () => ({
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
delete: jest.fn(),
},
member: {
Expand Down Expand Up @@ -229,6 +230,32 @@ describe('RolesService', () => {
).rejects.toThrow('Maximum of 20 custom roles per organization');
});

it('excludes built-in obligation override rows from the 20-role limit', async () => {
// The count query should filter out override rows so they don't steal
// slots from the customer's custom-role budget.
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.organizationRole.count as jest.Mock).mockResolvedValue(0);
(mockDb.organizationRole.create as jest.Mock).mockResolvedValue({
id: 'rol_xyz',
name: validDto.name,
permissions: JSON.stringify(validDto.permissions),
obligations: '{}',
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
});

await service.createRole(organizationId, validDto, ['owner']);
expect(mockDb.organizationRole.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
organizationId,
name: { notIn: expect.arrayContaining(['owner', 'admin']) },
}),
}),
);
});

it('should prevent privilege escalation - cannot grant permissions you do not have', async () => {
// Employee trying to grant admin-level permissions
const dto = {
Expand Down Expand Up @@ -713,4 +740,219 @@ describe('RolesService', () => {
expect(result.map((m) => m.id)).toEqual(['m1']);
});
});

describe('getBuiltInObligations', () => {
const organizationId = 'org_1';

it('returns the hardcoded default when no override row exists', async () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
const result = await service.getBuiltInObligations(organizationId, 'owner');
expect(result).toEqual({ compliance: true });
});

it('returns the DB override when one exists (string JSON)', async () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue({
obligations: JSON.stringify({ compliance: false }),
});
const result = await service.getBuiltInObligations(organizationId, 'owner');
expect(result).toEqual({ compliance: false });
});

it('returns the DB override when one exists (object JSON)', async () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue({
obligations: { compliance: false },
});
const result = await service.getBuiltInObligations(organizationId, 'owner');
expect(result).toEqual({ compliance: false });
});

it('returns empty for admin (no default compliance, no override)', async () => {
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue(null);
const result = await service.getBuiltInObligations(organizationId, 'admin');
expect(result).toEqual({});
});

it('rejects unknown role names', async () => {
await expect(
service.getBuiltInObligations(organizationId, 'not-a-built-in'),
).rejects.toThrow(BadRequestException);
});

it('falls back to built-in default when override row exists but has no compliance key', async () => {
// Override row with `{}` should be treated as "no override on compliance"
// — UI must show the built-in default to match what enforcement does.
(mockDb.organizationRole.findFirst as jest.Mock).mockResolvedValue({
obligations: '{}',
});
const result = await service.getBuiltInObligations(organizationId, 'owner');
expect(result).toEqual({ compliance: true });
});
});

describe('updateBuiltInObligations', () => {
const organizationId = 'org_1';

it('upserts an organization_role row with the built-in name', async () => {
(mockDb.organizationRole.upsert as jest.Mock).mockResolvedValue({
name: 'owner',
obligations: JSON.stringify({ compliance: false }),
});

const result = await service.updateBuiltInObligations(
organizationId,
'owner',
{ compliance: false },
);

expect(result).toEqual({ name: 'owner', obligations: { compliance: false } });
expect(mockDb.organizationRole.upsert).toHaveBeenCalledWith({
where: { organizationId_name: { organizationId, name: 'owner' } },
create: expect.objectContaining({
organizationId,
name: 'owner',
obligations: JSON.stringify({ compliance: false }),
}),
update: { obligations: JSON.stringify({ compliance: false }) },
});
});

it('rejects unknown role names', async () => {
await expect(
service.updateBuiltInObligations(organizationId, 'not-a-built-in', {
compliance: true,
}),
).rejects.toThrow(BadRequestException);
expect(mockDb.organizationRole.upsert).not.toHaveBeenCalled();
});

it('rejects built-in roles whose obligations are not user-editable', async () => {
for (const locked of ['auditor', 'employee', 'contractor']) {
await expect(
service.updateBuiltInObligations(organizationId, locked, {
compliance: false,
}),
).rejects.toThrow(BadRequestException);
}
expect(mockDb.organizationRole.upsert).not.toHaveBeenCalled();
});

it('accepts owner and admin', async () => {
(mockDb.organizationRole.upsert as jest.Mock).mockResolvedValue({
name: 'admin',
obligations: JSON.stringify({ compliance: true }),
});
await expect(
service.updateBuiltInObligations(organizationId, 'admin', {
compliance: true,
}),
).resolves.toEqual({ name: 'admin', obligations: { compliance: true } });
});
});

describe('getObligationsForRoles with built-in overrides', () => {
const organizationId = 'org_1';

it('returns hardcoded defaults when no DB rows match', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([]);
const result = await service.getObligationsForRoles(organizationId, [
'owner',
'admin',
]);
// owner has compliance:true by default, admin has none — union is true
expect(result).toEqual({ compliance: true });
});

it('DB override beats the hardcoded default (owner override → no compliance)', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{ name: 'owner', obligations: JSON.stringify({ compliance: false }) },
]);
const result = await service.getObligationsForRoles(organizationId, [
'owner',
]);
expect(result).toEqual({});
});

it('admin override can opt INTO compliance even though default is none', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{ name: 'admin', obligations: JSON.stringify({ compliance: true }) },
]);
const result = await service.getObligationsForRoles(organizationId, [
'admin',
]);
expect(result).toEqual({ compliance: true });
});

it('falls back to built-in default when override JSON has no compliance key', async () => {
// A row with `{}` should not silently disable the owner default.
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{ name: 'owner', obligations: '{}' },
]);
const result = await service.getObligationsForRoles(organizationId, [
'owner',
]);
expect(result).toEqual({ compliance: true });
});
});

describe('listRoles built-in overrides', () => {
const organizationId = 'org_1';

it('hides override rows from customRoles and reflects them on builtInRoles', async () => {
const ownerOverride = {
id: 'rol_owner_override',
name: 'owner',
permissions: '{}',
obligations: JSON.stringify({ compliance: false }),
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
};
const customRole = {
id: 'rol_custom_1',
name: 'compliance-lead',
permissions: JSON.stringify({ control: ['read'] }),
obligations: '{}',
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
};
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
ownerOverride,
customRole,
]);
(mockDb.member.count as jest.Mock).mockResolvedValue(0);

const result = await service.listRoles(organizationId);
// Override row must not appear as a custom role
expect(result.customRoles.map((r) => r.name)).toEqual(['compliance-lead']);
// Built-in entries carry effective obligations — owner reflects the override
const ownerEntry = result.builtInRoles.find((r) => r.name === 'owner');
expect(ownerEntry?.obligations).toEqual({ compliance: false });
// Other built-ins still show their hardcoded defaults
const adminEntry = result.builtInRoles.find((r) => r.name === 'admin');
expect(adminEntry?.obligations).toEqual({});
const employeeEntry = result.builtInRoles.find((r) => r.name === 'employee');
expect(employeeEntry?.obligations).toEqual({ compliance: true });
});

it('shows the built-in default when override row exists with no compliance key', async () => {
// Override row with `{}` should not silently flip the built-in entry off.
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{
id: 'rol_owner_override',
name: 'owner',
permissions: '{}',
obligations: '{}',
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
(mockDb.member.count as jest.Mock).mockResolvedValue(0);

const result = await service.listRoles(organizationId);
const ownerEntry = result.builtInRoles.find((r) => r.name === 'owner');
expect(ownerEntry?.obligations).toEqual({ compliance: true });
});
});
});
Loading
Loading