Skip to content

Commit 20496b0

Browse files
committed
feat: add audit log controller and integrate with existing modules
1 parent 7c26342 commit 20496b0

File tree

88 files changed

+5478
-3177
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+5478
-3177
lines changed

apps/api/src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ import { OrgChartModule } from './org-chart/org-chart.module';
3737
import { TrainingModule } from './training/training.module';
3838
import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module';
3939
import { FrameworksModule } from './frameworks/frameworks.module';
40+
import { AuditModule } from './audit/audit.module';
41+
import { ControlsModule } from './controls/controls.module';
4042
import { RolesModule } from './roles/roles.module';
43+
import { SecretsModule } from './secrets/secrets.module';
4144

4245
@Module({
4346
imports: [
@@ -88,6 +91,9 @@ import { RolesModule } from './roles/roles.module';
8891
EvidenceFormsModule,
8992
FrameworksModule,
9093
RolesModule,
94+
AuditModule,
95+
ControlsModule,
96+
SecretsModule,
9197
],
9298
controllers: [AppController],
9399
providers: [
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
2+
import { ApiOperation, ApiQuery, ApiSecurity, ApiTags } from '@nestjs/swagger';
3+
import { db } from '@trycompai/db';
4+
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
5+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
6+
import { PermissionGuard } from '../auth/permission.guard';
7+
import { RequirePermission } from '../auth/require-permission.decorator';
8+
import type { AuthContext as AuthContextType } from '../auth/types';
9+
10+
@ApiTags('Audit Logs')
11+
@Controller({ path: 'audit-logs', version: '1' })
12+
@UseGuards(HybridAuthGuard, PermissionGuard)
13+
@ApiSecurity('apikey')
14+
export class AuditLogController {
15+
@Get()
16+
@RequirePermission('app', 'read')
17+
@ApiOperation({ summary: 'Get audit logs filtered by entity type and ID' })
18+
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (e.g. policy, task, control)' })
19+
@ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' })
20+
@ApiQuery({ name: 'take', required: false, description: 'Number of logs to return (max 100, default 50)' })
21+
async getAuditLogs(
22+
@OrganizationId() organizationId: string,
23+
@AuthContext() authContext: AuthContextType,
24+
@Query('entityType') entityType?: string,
25+
@Query('entityId') entityId?: string,
26+
@Query('take') take?: string,
27+
) {
28+
// organizationId comes from auth context (not user input) — ensures tenant isolation
29+
const where: Record<string, unknown> = { organizationId };
30+
if (entityType) where.entityType = entityType;
31+
if (entityId) where.entityId = entityId;
32+
33+
const parsedTake = take
34+
? Math.min(100, Math.max(1, parseInt(take, 10) || 50))
35+
: 50;
36+
37+
const logs = await db.auditLog.findMany({
38+
where,
39+
include: {
40+
user: {
41+
select: { id: true, name: true, email: true, image: true },
42+
},
43+
member: true,
44+
organization: true,
45+
},
46+
orderBy: { timestamp: 'desc' },
47+
take: parsedTake,
48+
});
49+
50+
return {
51+
data: logs,
52+
authType: authContext.authType,
53+
...(authContext.userId && {
54+
authenticatedUser: {
55+
id: authContext.userId,
56+
email: authContext.userEmail,
57+
},
58+
}),
59+
};
60+
}
61+
}

apps/api/src/audit/audit-log.interceptor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export class AuditLogInterceptor implements NestInterceptor {
113113
request.url,
114114
method,
115115
responseBody,
116+
requestBody,
116117
);
117118
const downloadDesc = extractDownloadDescription(
118119
request.url,
@@ -128,7 +129,11 @@ export class AuditLogInterceptor implements NestInterceptor {
128129

129130
if (commentCtx || versionDesc || policyActionDesc) {
130131
// Comments and version operations don't produce meaningful diffs
131-
changes = null;
132+
// But preserve the comment/reason if provided in the request body
133+
const comment = requestBody?.comment;
134+
changes = comment && typeof comment === 'string'
135+
? { reason: { previous: null, current: comment } }
136+
: null;
132137
} else if (relationMappingResult) {
133138
changes = relationMappingResult.changes;
134139
descriptionOverride ??= relationMappingResult.description;

apps/api/src/audit/audit-log.utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export function extractVersionDescription(
8989
path: string,
9090
method: string,
9191
responseBody: unknown,
92+
requestBody?: Record<string, unknown>,
9293
): string | null {
9394
const isVersionPath = /\/versions(?:\/|$)/.test(path);
9495
const isApprovalPath = /\/(accept|deny)-changes\/?$/.test(path);
@@ -177,6 +178,18 @@ export function extractPolicyActionDescription(
177178
return 'Regenerated policy';
178179
}
179180

181+
const pathWithoutQuery = path.split('?')[0];
182+
183+
// POST /v1/policies/:id/pdf (upload)
184+
if (/\/pdf\/?$/.test(pathWithoutQuery) && method === 'POST') {
185+
return 'Uploaded policy PDF';
186+
}
187+
188+
// DELETE /v1/policies/:id/pdf (delete)
189+
if (/\/pdf\/?$/.test(pathWithoutQuery) && method === 'DELETE') {
190+
return 'Deleted policy PDF';
191+
}
192+
180193
// PATCH /v1/policies/:id with isArchived field
181194
if (method === 'PATCH' && requestBody && 'isArchived' in requestBody) {
182195
return requestBody.isArchived ? 'Archived policy' : 'Restored policy';

apps/api/src/audit/audit.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { Module } from '@nestjs/common';
22
import { APP_INTERCEPTOR } from '@nestjs/core';
3+
import { AuthModule } from '../auth/auth.module';
4+
import { AuditLogController } from './audit-log.controller';
35
import { AuditLogInterceptor } from './audit-log.interceptor';
46

57
@Module({
8+
imports: [AuthModule],
9+
controllers: [AuditLogController],
610
providers: [
711
{
812
provide: APP_INTERCEPTOR,

apps/api/src/frameworks/frameworks-scores.helper.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from '@trycompai/db';
2+
import { filterComplianceMembers } from '../utils/compliance-filters';
23

34
const TRAINING_VIDEO_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
45

@@ -34,11 +35,8 @@ export async function getOverviewScores(organizationId: string) {
3435
(t) => t.status === 'todo' || t.status === 'in_progress',
3536
);
3637

37-
// People score
38-
const activeEmployees = employees.filter((m) => {
39-
const roles = m.role.includes(',') ? m.role.split(',') : [m.role];
40-
return roles.includes('employee') || roles.includes('contractor');
41-
});
38+
// People score — filter to members with compliance:required permission
39+
const activeEmployees = await filterComplianceMembers(employees, organizationId);
4240

4341
let completedMembers = 0;
4442

0 commit comments

Comments
 (0)