Skip to content
Open
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
28 changes: 28 additions & 0 deletions app/backend/src/ai-verification.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
IsString,
IsNotEmpty,
IsUUID,
IsEnum,
IsObject,
} from 'class-validator';

export enum VerificationStatus {
VERIFIED = 'verified',
REJECTED = 'rejected',
NEEDS_REVIEW = 'needs_review',
}

export class AiVerificationPayloadDto {
@IsUUID()
eventId: string;

@IsString()
@IsNotEmpty()
sessionId: string;

@IsEnum(VerificationStatus)
status: VerificationStatus;

@IsObject()
details: Record<string, any>;
}
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { SandboxModule } from './sandbox/sandbox.module';
import { CacheModule } from './common/cache/cache.module';
import { CacheResponseInterceptor } from './common/interceptors/cache-response.interceptor';

import { WebhooksModule } from './webhooks/webhooks.module';
@Module({
imports: [
ConfigModule.forRoot({
Expand Down Expand Up @@ -116,6 +117,7 @@ import { CacheResponseInterceptor } from './common/interceptors/cache-response.i
EntityLinkingModule,
DeploymentMetadataModule,
SandboxModule,
WebhooksModule,
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand Down
31 changes: 31 additions & 0 deletions app/backend/src/audit/ai-verification.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
IsString,
IsNotEmpty,
IsEnum,
IsObject,
IsUUID,
} from 'class-validator';

export enum VerificationStatus {
COMPLETED = 'completed',
FAILED = 'failed',
}

export class AiVerificationPayloadDto {
@IsUUID('4')
idempotencyKey: string;

@IsString()
@IsNotEmpty()
sessionId: string;

@IsString()
@IsNotEmpty()
stepId: string;

@IsEnum(VerificationStatus)
status: VerificationStatus;

@IsObject()
output: Record<string, any>;
}
26 changes: 26 additions & 0 deletions app/backend/src/audit/audit.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
import { MetricsService } from '../metrics/metrics.service';

describe('AuditController', () => {
let controller: AuditController;
let service: AuditService;
let metricsService: MetricsService;

const mockExportResult = {
data: [
Expand All @@ -31,10 +33,18 @@ describe('AuditController', () => {
buildCsv: jest.fn().mockReturnValue('id,actorHash,...\nlog-1,...'),
};

const mockMetricsService = {
getMetrics: jest.fn().mockResolvedValue('# HELP ...'),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuditController],
providers: [
{
provide: MetricsService,
useValue: mockMetricsService,
},
{
provide: AuditService,
useValue: mockAuditService,
Expand All @@ -44,6 +54,7 @@ describe('AuditController', () => {

controller = module.get<AuditController>(AuditController);
service = module.get<AuditService>(AuditService);
metricsService = module.get<MetricsService>(MetricsService);
});

it('should be defined', () => {
Expand Down Expand Up @@ -117,4 +128,19 @@ describe('AuditController', () => {
});
});
});

describe('getMetrics', () => {
it('should call metricsService.getMetrics and set headers', async () => {
const res = {
set: jest.fn(),
send: jest.fn(),
} as any;

await controller.getMetrics(res);

expect(metricsService.getMetrics).toHaveBeenCalled();
expect(res.set).toHaveBeenCalledWith('Content-Type', 'text/plain');
expect(res.send).toHaveBeenCalledWith('# HELP ...');
});
});
});
15 changes: 14 additions & 1 deletion app/backend/src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
ApiUnauthorizedResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { MetricsService } from '../metrics/metrics.service';

@ApiTags('Audit')
@ApiBearerAuth('JWT-auth')
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
constructor(
private readonly auditService: AuditService,
private metricsService: MetricsService,
) {}

@Get()
@Version('1')
Expand Down Expand Up @@ -130,4 +134,13 @@

return result;
}

@Get('metrics')
@ApiOperation({ summary: 'Get service metrics' })
@ApiOkResponse({ description: 'Prometheus metrics exported successfully.' })
async getMetrics(@Res() res: Response) {
res.set('Content-Type', 'text/plain');
const metrics = await this.metricsService.getMetrics();

Check warning on line 143 in app/backend/src/audit/audit.controller.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .getMetrics on a type that cannot be resolved

Check warning on line 143 in app/backend/src/audit/audit.controller.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check warning on line 143 in app/backend/src/audit/audit.controller.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an error typed value
res.send(metrics);
}
}
2 changes: 2 additions & 0 deletions app/backend/src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';
import { MetricsModule } from '../metrics/metrics.module';

@Global()
@Module({
imports: [MetricsModule],
providers: [AuditService],
controllers: [AuditController],
exports: [AuditService],
Expand Down
128 changes: 87 additions & 41 deletions app/backend/src/audit/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { PrismaService } from '../prisma/prisma.service';

import { MetricsService } from '../metrics/metrics.service';
import { Prisma } from '@prisma/client';

export interface AuditLogParams {
Expand Down Expand Up @@ -65,22 +66,40 @@

@Injectable()
export class AuditService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private metrics: MetricsService,
) {}

anonymize(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 16);
}

async record(params: AuditLogParams) {
return this.prisma.auditLog.create({
data: {
actorId: params.actorId,
entity: params.entity,
entityId: params.entityId,
action: params.action,
metadata: (params.metadata as Prisma.InputJsonValue) ?? {},
},
const end = this.metrics.dbQueryDuration.startTimer({

Check warning on line 79 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .dbQueryDuration on a type that cannot be resolved

Check warning on line 79 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved

Check warning on line 79 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an error typed value
operation: 'create',
entity: 'AuditLog',
});
try {
const result = await this.prisma.auditLog.create({
data: {
actorId: params.actorId,
entity: params.entity,
entityId: params.entityId,
action: params.action,
metadata: (params.metadata as Prisma.InputJsonValue) ?? {},
},
});
end();

Check warning on line 93 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved
return result;
} catch (error) {
this.metrics.dbErrorsTotal.inc({

Check warning on line 96 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .dbErrorsTotal on a type that cannot be resolved

Check warning on line 96 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved
operation: 'create',
entity: 'AuditLog',
});
end();

Check warning on line 100 in app/backend/src/audit/audit.service.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of a type that could not be resolved
throw error;
}
}

async findLogs(query: AuditQuery) {
Expand All @@ -101,17 +120,30 @@
if (query.endTime) where.timestamp.lte = new Date(query.endTime);
}

const [rows, total] = await this.prisma.$transaction([
this.prisma.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
skip,
take: limit,
}),
this.prisma.auditLog.count({ where }),
]);

return { data: rows, total, page, limit };
const end = this.metrics.dbQueryDuration.startTimer({
operation: 'findMany',
entity: 'AuditLog',
});
try {
const [rows, total] = await this.prisma.$transaction([
this.prisma.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
skip,
take: limit,
}),
this.prisma.auditLog.count({ where }),
]);
end();
return { data: rows, total, page, limit };
} catch (error) {
this.metrics.dbErrorsTotal.inc({
operation: 'findMany',
entity: 'AuditLog',
});
end();
throw error;
}
}

async exportLogs(query: ExportAuditQuery): Promise<ExportAuditResult> {
Expand All @@ -137,27 +169,41 @@
if (query.to) where.timestamp.lte = new Date(query.to);
}

const [rows, total] = await this.prisma.$transaction([
this.prisma.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
skip,
take: limit,
}),
this.prisma.auditLog.count({ where }),
]);

const data: AnonymizedAuditLog[] = rows.map(row => ({
id: row.id,
actorHash: this.anonymize(row.actorId),
entity: row.entity,
entityHash: this.anonymize(row.entityId),
action: row.action,
timestamp: row.timestamp,
metadata: row.metadata,
}));

return { data, total, page, limit };
const end = this.metrics.dbQueryDuration.startTimer({
operation: 'export',
entity: 'AuditLog',
});
try {
const [rows, total] = await this.prisma.$transaction([
this.prisma.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
skip,
take: limit,
}),
this.prisma.auditLog.count({ where }),
]);
end();

const data: AnonymizedAuditLog[] = rows.map(row => ({
id: row.id,
actorHash: this.anonymize(row.actorId),
entity: row.entity,
entityHash: this.anonymize(row.entityId),
action: row.action,
timestamp: row.timestamp,
metadata: row.metadata,
}));

return { data, total, page, limit };
} catch (error) {
this.metrics.dbErrorsTotal.inc({
operation: 'export',
entity: 'AuditLog',
});
end();
throw error;
}
}

buildCsv(rows: AnonymizedAuditLog[]): string {
Expand Down
5 changes: 5 additions & 0 deletions app/backend/src/audit/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';

export default registerAs('app', () => ({
aiWebhookSecret: process.env.AI_WEBHOOK_SECRET,
}));
Loading
Loading