Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { EvidenceExportModule } from './tasks/evidence-export/evidence-export.mo
import { VendorsModule } from './vendors/vendors.module';
import { ContextModule } from './context/context.module';
import { TrustPortalModule } from './trust-portal/trust-portal.module';
import { ControlTemplateModule } from './framework-editor/control-template/control-template.module';
import { FrameworkEditorFrameworkModule } from './framework-editor/framework/framework.module';
import { PolicyTemplateModule } from './framework-editor/policy-template/policy-template.module';
import { RequirementModule } from './framework-editor/requirement/requirement.module';
import { TaskTemplateModule } from './framework-editor/task-template/task-template.module';
import { FindingTemplateModule } from './finding-template/finding-template.module';
import { FindingsModule } from './findings/findings.module';
Expand Down Expand Up @@ -78,6 +82,10 @@ import { AdminOrganizationsModule } from './admin-organizations/admin-organizati
CommentsModule,
HealthModule,
TrustPortalModule,
ControlTemplateModule,
FrameworkEditorFrameworkModule,
PolicyTemplateModule,
RequirementModule,
TaskTemplateModule,
FindingTemplateModule,
FindingsModule,
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { ac, allRoles } from '@trycompai/auth';
import { createAuthMiddleware } from 'better-auth/api';
import { Redis } from '@upstash/redis';
import type { AccessControl } from 'better-auth/plugins/access';

const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour

Expand Down Expand Up @@ -47,13 +48,15 @@ export function getTrustedOrigins(): string[] {
'http://localhost:3000',
'http://localhost:3002',
'http://localhost:3333',
'http://localhost:3004',
'https://app.trycomp.ai',
'https://portal.trycomp.ai',
'https://api.trycomp.ai',
'https://app.staging.trycomp.ai',
'https://portal.staging.trycomp.ai',
'https://api.staging.trycomp.ai',
'https://dev.trycomp.ai',
'https://framework-editor.trycomp.ai',
];
}

Expand Down Expand Up @@ -414,7 +417,7 @@ export const auth = betterAuth({
}),
});
},
ac,
ac: ac as AccessControl,
roles: allRoles,
// Enable dynamic access control for custom roles
// This allows organizations to create custom roles at runtime
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { PlatformAdminGuard } from '../../auth/platform-admin.guard';
import { CreateControlTemplateDto } from './dto/create-control-template.dto';
import { UpdateControlTemplateDto } from './dto/update-control-template.dto';
import { ControlTemplateService } from './control-template.service';

@ApiTags('Framework Editor Control Templates')
@Controller({ path: 'framework-editor/control-template', version: '1' })
@UseGuards(PlatformAdminGuard)
export class ControlTemplateController {
constructor(private readonly service: ControlTemplateService) {}

@Get()
async findAll(
@Query('take') take?: string,
@Query('skip') skip?: string,
@Query('frameworkId') frameworkId?: string,
) {
const limit = Math.min(Number(take) || 500, 500);
const offset = Number(skip) || 0;
return this.service.findAll(limit, offset, frameworkId);
}

@Get(':id')
async findOne(@Param('id') id: string) {
return this.service.findById(id);
}

@Post()
@UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
async create(
@Body() dto: CreateControlTemplateDto,
@Query('frameworkId') frameworkId?: string,
) {
return this.service.create(dto, frameworkId);
}

@Patch(':id')
@UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
async update(
@Param('id') id: string,
@Body() dto: UpdateControlTemplateDto,
) {
return this.service.update(id, dto);
}

@Delete(':id')
async delete(@Param('id') id: string) {
return this.service.delete(id);
}

@Post(':id/requirements/:reqId')
async linkRequirement(
@Param('id') id: string,
@Param('reqId') reqId: string,
) {
return this.service.linkRequirement(id, reqId);
}

@Delete(':id/requirements/:reqId')
async unlinkRequirement(
@Param('id') id: string,
@Param('reqId') reqId: string,
) {
return this.service.unlinkRequirement(id, reqId);
}

@Post(':id/policy-templates/:ptId')
async linkPolicyTemplate(
@Param('id') id: string,
@Param('ptId') ptId: string,
) {
return this.service.linkPolicyTemplate(id, ptId);
}

@Delete(':id/policy-templates/:ptId')
async unlinkPolicyTemplate(
@Param('id') id: string,
@Param('ptId') ptId: string,
) {
return this.service.unlinkPolicyTemplate(id, ptId);
}

@Post(':id/task-templates/:ttId')
async linkTaskTemplate(
@Param('id') id: string,
@Param('ttId') ttId: string,
) {
return this.service.linkTaskTemplate(id, ttId);
}

@Delete(':id/task-templates/:ttId')
async unlinkTaskTemplate(
@Param('id') id: string,
@Param('ttId') ttId: string,
) {
return this.service.unlinkTaskTemplate(id, ttId);
}
}
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 { ControlTemplateController } from './control-template.controller';
import { ControlTemplateService } from './control-template.service';

@Module({
imports: [AuthModule],
controllers: [ControlTemplateController],
providers: [ControlTemplateService],
exports: [ControlTemplateService],
})
export class ControlTemplateModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
import { db, Prisma } from '@trycompai/db';
import type { EvidenceFormType } from '@trycompai/db';
import { CreateControlTemplateDto } from './dto/create-control-template.dto';
import { UpdateControlTemplateDto } from './dto/update-control-template.dto';

@Injectable()
export class ControlTemplateService {
private readonly logger = new Logger(ControlTemplateService.name);

async findAll(take = 500, skip = 0, frameworkId?: string) {
return db.frameworkEditorControlTemplate.findMany({
take,
skip,
orderBy: { createdAt: 'asc' },
where: frameworkId
? { requirements: { some: { frameworkId } } }
: undefined,
include: {
policyTemplates: { select: { id: true, name: true } },
requirements: {
select: {
id: true,
name: true,
framework: { select: { name: true } },
},
},
taskTemplates: { select: { id: true, name: true } },
},
});
}

async findById(id: string) {
const ct = await db.frameworkEditorControlTemplate.findUnique({
where: { id },
include: {
policyTemplates: { select: { id: true, name: true } },
requirements: {
select: {
id: true,
name: true,
framework: { select: { name: true } },
},
},
taskTemplates: { select: { id: true, name: true } },
},
});
if (!ct) throw new NotFoundException(`Control template ${id} not found`);
return ct;
}

async create(dto: CreateControlTemplateDto, frameworkId?: string) {
const requirementIds = frameworkId
? await db.frameworkEditorRequirement
.findMany({
where: { frameworkId },
select: { id: true },
})
.then((reqs) => reqs.map((r) => ({ id: r.id })))
: [];

const ct = await db.frameworkEditorControlTemplate.create({
data: {
name: dto.name,
description: dto.description ?? '',
...(dto.documentTypes && {
documentTypes: dto.documentTypes as EvidenceFormType[],
}),
...(requirementIds.length > 0 && {
requirements: { connect: requirementIds },
}),
},
});
this.logger.log(`Created control template: ${ct.name} (${ct.id})`);
return ct;
}

async update(id: string, dto: UpdateControlTemplateDto) {
await this.findById(id);
const updated = await db.frameworkEditorControlTemplate.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.documentTypes !== undefined && {
documentTypes: dto.documentTypes as EvidenceFormType[],
}),
},
});
this.logger.log(`Updated control template: ${updated.name} (${id})`);
return updated;
}

async delete(id: string) {
await this.findById(id);
try {
await db.frameworkEditorControlTemplate.delete({ where: { id } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2003'
) {
throw new ConflictException(
'Cannot delete control template: it is referenced by existing controls',
);
}
throw error;
}
this.logger.log(`Deleted control template ${id}`);
return { message: 'Control template deleted successfully' };
}

async linkRequirement(controlId: string, requirementId: string) {
await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { requirements: { connect: { id: requirementId } } },
});
return { message: 'Requirement linked' };
}

async unlinkRequirement(controlId: string, requirementId: string) {
await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { requirements: { disconnect: { id: requirementId } } },
});
return { message: 'Requirement unlinked' };
}

async linkPolicyTemplate(controlId: string, policyTemplateId: string) {
await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { policyTemplates: { connect: { id: policyTemplateId } } },
});
return { message: 'Policy template linked' };
}

async unlinkPolicyTemplate(controlId: string, policyTemplateId: string) {
await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { policyTemplates: { disconnect: { id: policyTemplateId } } },
});
return { message: 'Policy template unlinked' };
}

async linkTaskTemplate(controlId: string, taskTemplateId: string) {
await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { taskTemplates: { connect: { id: taskTemplateId } } },
});
return { message: 'Task template linked' };
}

async unlinkTaskTemplate(controlId: string, taskTemplateId: string) {
await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { taskTemplates: { disconnect: { id: taskTemplateId } } },
});
return { message: 'Task template unlinked' };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsArray,
IsOptional,
MaxLength,
} from 'class-validator';

export class CreateControlTemplateDto {
@ApiProperty({ example: 'Access Control Policy' })
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;

@ApiProperty({ example: 'Ensures access controls are properly managed' })
@IsString()
@MaxLength(5000)
description: string;

@ApiPropertyOptional({ example: ['penetration-test', 'rbac-matrix'] })
@IsArray()
@IsString({ each: true })
@IsOptional()
documentTypes?: string[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateControlTemplateDto } from './create-control-template.dto';

export class UpdateControlTemplateDto extends PartialType(
CreateControlTemplateDto,
) {}
Loading
Loading