Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2c4521f
feat: add FrameworkInstanceRequirement schema for custom org requirem…
Marfuen Mar 24, 2026
6aba767
feat: add customer-facing framework instance requirements CRUD API
Marfuen Mar 24, 2026
5195747
fix: remove redundant per-method ValidationPipe, rely on global pipe
Marfuen Mar 24, 2026
6329294
feat: include instance requirements in framework detail response
Marfuen Mar 24, 2026
d839159
feat: make framework rows clickable in overview when advanced mode is…
Marfuen Mar 24, 2026
b05b17b
feat: add framework instance requirement types, SWR hook, and update …
Marfuen Mar 24, 2026
f34aa4d
feat: add custom requirements list and create sheet components
Marfuen Mar 24, 2026
dbd4b84
test: add framework instance requirements service tests
Marfuen Mar 24, 2026
29f9aff
refactor: merge custom requirements into unified requirements table
Marfuen Mar 24, 2026
310aef3
refactor: restyle CreateRequirementSheet to match create risk pattern
Marfuen Mar 24, 2026
2bffd2d
fix: support clicking on custom requirements to view detail page
Marfuen Mar 24, 2026
282e5cf
feat: add create control button on requirement detail page and contro…
Marfuen Mar 24, 2026
7de8bfa
fix: render controls multi-select only after data loads
Marfuen Mar 24, 2026
fff750c
fix: cap controls dropdown height at 200px with scroll
Marfuen Mar 24, 2026
75fe587
fix: remove unnecessary className, MultipleSelector already caps at 2…
Marfuen Mar 24, 2026
a6a43cb
refactor: use DS Combobox with chips for controls multi-select
Marfuen Mar 24, 2026
e026914
fix: remove ComboboxEmpty that shows incorrectly with manual items
Marfuen Mar 24, 2026
f23f182
fix: show only framework controls in add requirement dropdown
Marfuen Mar 24, 2026
f12ee56
fix: sort controls alphabetically in add requirement dropdown
Marfuen Mar 24, 2026
c5f394c
feat: add tasks and policies selectors to create control sheet
Marfuen Mar 24, 2026
e9f52b1
feat: support instance requirements in controls create flow
Marfuen Mar 24, 2026
9f8503b
fix: add Requirements breadcrumb level on requirement detail page
Marfuen Mar 24, 2026
6b801bc
fix: set maxItems=4 on breadcrumbs to prevent collapsing
Marfuen Mar 24, 2026
a00f832
fix: handle nullable requirement in RequirementsTable for instance re…
Marfuen Mar 24, 2026
0884244
chore: update migration
Marfuen Mar 25, 2026
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
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { SecretsModule } from './secrets/secrets.module';
import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module';
import { StripeModule } from './stripe/stripe.module';
import { AdminOrganizationsModule } from './admin-organizations/admin-organizations.module';
import { FrameworkInstanceRequirementsModule } from './framework-instance-requirements/framework-instance-requirements.module';

@Module({
imports: [
Expand Down Expand Up @@ -110,6 +111,7 @@ import { AdminOrganizationsModule } from './admin-organizations/admin-organizati
SecurityPenetrationTestsModule,
StripeModule,
AdminOrganizationsModule,
FrameworkInstanceRequirementsModule,
],
controllers: [AppController],
providers: [
Expand Down
86 changes: 63 additions & 23 deletions apps/api/src/controls/controls.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const controlInclude = {
requirement: {
select: { name: true, identifier: true },
},
frameworkInstanceRequirement: {
select: { name: true, identifier: true },
},
},
},
} satisfies Prisma.ControlInclude;
Expand Down Expand Up @@ -76,6 +79,7 @@ export class ControlsService {
include: { framework: true },
},
requirement: true,
frameworkInstanceRequirement: true,
},
},
},
Expand Down Expand Up @@ -117,41 +121,71 @@ export class ControlsService {
}

async getOptions(organizationId: string) {
const [policies, tasks, frameworkInstances] = await Promise.all([
db.policy.findMany({
where: { organizationId },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
db.task.findMany({
where: { organizationId },
select: { id: true, title: true },
orderBy: { title: 'asc' },
}),
db.frameworkInstance.findMany({
where: { organizationId },
include: {
framework: {
include: {
requirements: {
select: { id: true, name: true, identifier: true },
const [policies, tasks, frameworkInstances, instanceRequirements] =
await Promise.all([
db.policy.findMany({
where: { organizationId },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
db.task.findMany({
where: { organizationId },
select: { id: true, title: true },
orderBy: { title: 'asc' },
}),
db.frameworkInstance.findMany({
where: { organizationId },
include: {
framework: {
include: {
requirements: {
select: { id: true, name: true, identifier: true },
},
},
},
},
},
}),
]);
}),
db.frameworkInstanceRequirement.findMany({
where: {
frameworkInstance: { organizationId },
},
select: {
id: true,
name: true,
identifier: true,
frameworkInstanceId: true,
frameworkInstance: {
select: {
framework: { select: { name: true } },
},
},
},
orderBy: { name: 'asc' },
}),
]);

const requirements = frameworkInstances.flatMap((fi) =>
const templateRequirements = frameworkInstances.flatMap((fi) =>
fi.framework.requirements.map((req) => ({
id: req.id,
name: req.name,
identifier: req.identifier,
frameworkInstanceId: fi.id,
frameworkName: fi.framework.name,
isInstanceRequirement: false,
})),
);

const customRequirements = instanceRequirements.map((req) => ({
id: req.id,
name: req.name,
identifier: req.identifier,
frameworkInstanceId: req.frameworkInstanceId,
frameworkName: req.frameworkInstance.framework.name,
isInstanceRequirement: true,
}));

const requirements = [...templateRequirements, ...customRequirements];

return { policies, tasks, requirements };
}

Expand Down Expand Up @@ -184,8 +218,14 @@ export class ControlsService {
db.requirementMap.create({
data: {
controlId: control.id,
requirementId: mapping.requirementId,
frameworkInstanceId: mapping.frameworkInstanceId,
...(mapping.requirementId && {
requirementId: mapping.requirementId,
}),
...(mapping.frameworkInstanceRequirementId && {
frameworkInstanceRequirementId:
mapping.frameworkInstanceRequirementId,
}),
},
}),
),
Expand Down
13 changes: 11 additions & 2 deletions apps/api/src/controls/dto/create-control.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,18 @@ import {
import { Type } from 'class-transformer';

class RequirementMappingDto {
@ApiProperty({ description: 'Requirement ID' })
@ApiProperty({ description: 'Template requirement ID', required: false })
@IsOptional()
@IsString()
requirementId?: string;

@ApiProperty({
description: 'Instance requirement ID',
required: false,
})
@IsOptional()
@IsString()
requirementId: string;
frameworkInstanceRequirementId?: string;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequirementMapping allows creating orphaned maps without any requirement

High Severity

RequirementMappingDto makes both requirementId and frameworkInstanceRequirementId optional with no cross-field validation ensuring at least one is provided. The create method uses conditional spreads that silently skip both when neither is present, producing a RequirementMap row that references no requirement at all — an orphaned record that bypasses both @@unique constraints (since NULL ≠ NULL in PostgreSQL unique indexes).

Additional Locations (1)
Fix in Cursor Fix in Web


@ApiProperty({ description: 'Framework instance ID' })
@IsString()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsString,
IsNotEmpty,
IsOptional,
MaxLength,
} from 'class-validator';

export class CreateFrameworkInstanceRequirementDto {
@ApiProperty({ example: 'frm_abc123' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
frameworkInstanceId: string;

@ApiProperty({ example: 'Custom Access Control' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;

@ApiPropertyOptional({ example: 'CUSTOM-1' })
@IsString()
@IsOptional()
@MaxLength(255)
identifier?: string;

@ApiProperty({ example: 'Custom requirement for access control policies' })
@IsString()
@IsNotEmpty()
@MaxLength(5000)
description: string;

@ApiPropertyOptional({
description: 'Control IDs to link to this requirement',
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
controlIds?: string[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, MaxLength } from 'class-validator';

export class UpdateFrameworkInstanceRequirementDto {
@ApiPropertyOptional()
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;

@ApiPropertyOptional()
@IsString()
@IsOptional()
@MaxLength(255)
identifier?: string;

@ApiPropertyOptional()
@IsString()
@IsOptional()
@MaxLength(5000)
description?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
import { RequirePermission } from '../auth/require-permission.decorator';
import { OrganizationId } from '../auth/auth-context.decorator';
import { FrameworkInstanceRequirementsService } from './framework-instance-requirements.service';
import { CreateFrameworkInstanceRequirementDto } from './dto/create-framework-instance-requirement.dto';
import { UpdateFrameworkInstanceRequirementDto } from './dto/update-framework-instance-requirement.dto';

@ApiTags('Framework Instance Requirements')
@ApiBearerAuth()
@UseGuards(HybridAuthGuard, PermissionGuard)
@Controller({ path: 'framework-instance-requirements', version: '1' })
export class FrameworkInstanceRequirementsController {
constructor(
private readonly service: FrameworkInstanceRequirementsService,
) {}

@Get()
@RequirePermission('framework', 'read')
@ApiOperation({
summary: 'List custom requirements for a framework instance',
})
@ApiQuery({ name: 'frameworkInstanceId', required: true, type: String })
async findAll(
@OrganizationId() organizationId: string,
@Query('frameworkInstanceId') frameworkInstanceId: string,
) {
const data = await this.service.findAll(
frameworkInstanceId,
organizationId,
);
return { data, count: data.length };
}

@Get(':id')
@RequirePermission('framework', 'read')
@ApiOperation({ summary: 'Get a single framework instance requirement' })
async findOne(
@OrganizationId() organizationId: string,
@Param('id') id: string,
) {
return this.service.findOne(id, organizationId);
}

@Post()
@RequirePermission('framework', 'create')
@ApiOperation({ summary: 'Create a framework instance requirement' })
async create(
@OrganizationId() organizationId: string,
@Body() dto: CreateFrameworkInstanceRequirementDto,
) {
return this.service.create(dto, organizationId);
}

@Patch(':id')
@RequirePermission('framework', 'update')
@ApiOperation({ summary: 'Update a framework instance requirement' })
async update(
@OrganizationId() organizationId: string,
@Param('id') id: string,
@Body() dto: UpdateFrameworkInstanceRequirementDto,
) {
return this.service.update(id, dto, organizationId);
}

@Delete(':id')
@RequirePermission('framework', 'delete')
@ApiOperation({ summary: 'Delete a framework instance requirement' })
async delete(
@OrganizationId() organizationId: string,
@Param('id') id: string,
) {
return this.service.delete(id, organizationId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { FrameworkInstanceRequirementsController } from './framework-instance-requirements.controller';
import { FrameworkInstanceRequirementsService } from './framework-instance-requirements.service';

@Module({
imports: [AuthModule],
controllers: [FrameworkInstanceRequirementsController],
providers: [FrameworkInstanceRequirementsService],
exports: [FrameworkInstanceRequirementsService],
})
export class FrameworkInstanceRequirementsModule {}
Loading
Loading