diff --git a/src/app.module.ts b/src/app.module.ts index 2d07487..34348cb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,7 @@ import { NotificationsModule } from "./notifications/notifications.module"; import { BillPayModule } from "./bill-pay/bill-pay.module"; import { CreditScoreModule } from "./credit-score/credit-score.module"; import { ViewModule } from "./view/view.module"; +import { ComplianceModule } from "./compliance/compliance.module"; import { LoggerModule } from "nestjs-pino"; import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor"; @@ -88,6 +89,7 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep BillPayModule, CreditScoreModule, ViewModule, + ComplianceModule, ], providers: [ // Apply rate limiting globally diff --git a/src/compliance/compliance.controller.ts b/src/compliance/compliance.controller.ts new file mode 100644 index 0000000..10b933a --- /dev/null +++ b/src/compliance/compliance.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from "@nestjs/common"; + +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { ComplianceService } from "./compliance.service"; + +@Controller("compliance") +export class ComplianceController { + constructor(private readonly complianceService: ComplianceService) {} + + @Get("soc2/readiness") + async soc2Readiness(@CurrentUser() userId: string) { + return this.complianceService.getSoc2Readiness(userId); + } +} diff --git a/src/compliance/compliance.module.ts b/src/compliance/compliance.module.ts new file mode 100644 index 0000000..efeafc3 --- /dev/null +++ b/src/compliance/compliance.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; + +import { ComplianceController } from "./compliance.controller"; +import { ComplianceService } from "./compliance.service"; + +@Module({ + controllers: [ComplianceController], + providers: [ComplianceService], +}) +export class ComplianceModule {} diff --git a/src/compliance/compliance.service.ts b/src/compliance/compliance.service.ts new file mode 100644 index 0000000..06cd3f1 --- /dev/null +++ b/src/compliance/compliance.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from "@nestjs/common"; + +import { PrismaService } from "../prisma/prisma.service"; + +export type ControlStatus = "implemented" | "partial" | "external_required"; + +export interface Soc2Control { + id: string; + trustServiceCriterion: string; + status: ControlStatus; + control: string; + evidence: string[]; +} + +export interface Soc2ReadinessReport { + framework: "SOC 2"; + readinessStatus: "technical_baseline_ready"; + certificationStatus: "not_certified"; + generatedAt: string; + evidenceSummary: { + auditLogCount: number; + exportLogCount: number; + abuseEventCount: number; + activeSessionCount: number; + }; + controls: Soc2Control[]; + pendingExternalControls: string[]; + note: string; +} + +@Injectable() +export class ComplianceService { + constructor(private readonly prisma: PrismaService) {} + + async getSoc2Readiness(userId: string): Promise { + const [auditLogCount, exportLogCount, abuseEventCount, activeSessionCount] = await Promise.all([ + this.prisma.auditLog.count({ where: { userId } }), + this.prisma.exportLog.count({ where: { userId } }), + this.prisma.abuseEvent.count({ where: { userId } }), + this.prisma.session.count({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } } }), + ]); + + const controls: Soc2Control[] = [ + { + id: "CC6.1", + trustServiceCriterion: "Logical access", + status: "implemented", + control: "Authenticated access is bound to a server-side session with IP, user-agent, refresh token, and rotating nonce checks.", + evidence: [ + "JwtAuthGuard validates session ID, IP hash, user-agent hash, and per-request nonce hash.", + `${activeSessionCount} active session(s) currently recorded for this user.`, + ], + }, + { + id: "CC6.7", + trustServiceCriterion: "Data confidentiality", + status: "implemented", + control: "Sensitive tokens and raw transaction payloads are encrypted before storage, and browser APIs return presentation data.", + evidence: [ + "Plaid tokens and raw transaction payloads use server-side encryption services.", + "Browser-facing finance routes are served through sanitized view APIs.", + ], + }, + { + id: "CC7.2", + trustServiceCriterion: "Security monitoring", + status: "implemented", + control: "Abuse and anomaly signals are recorded for risky usage patterns.", + evidence: [`${abuseEventCount} abuse/risk event(s) currently recorded for this user.`], + }, + { + id: "CC7.3", + trustServiceCriterion: "Auditability", + status: "implemented", + control: "Security-relevant account, export, and collaboration actions write audit evidence.", + evidence: [ + `${auditLogCount} audit event(s) currently recorded for this user.`, + `${exportLogCount} export audit record(s) currently recorded for this user.`, + ], + }, + { + id: "CC8.1", + trustServiceCriterion: "Change management", + status: "partial", + control: "Repository-level CI and committed implementation history exist, but formal approval and release controls remain operational processes.", + evidence: ["Technical CI coverage can support SOC 2 evidence, but policy attestation is outside the application runtime."], + }, + { + id: "P1.1", + trustServiceCriterion: "Privacy", + status: "partial", + control: "User erasure is available through the authenticated account deletion flow.", + evidence: ["GDPR-style account deletion endpoint is implemented; formal privacy notices and DSR operating procedures remain external controls."], + }, + ]; + + return { + framework: "SOC 2", + readinessStatus: "technical_baseline_ready", + certificationStatus: "not_certified", + generatedAt: new Date().toISOString(), + evidenceSummary: { + auditLogCount, + exportLogCount, + abuseEventCount, + activeSessionCount, + }, + controls, + pendingExternalControls: [ + "Independent SOC 2 auditor engagement and examination period", + "Board/management-approved security, access control, incident response, vendor, and change management policies", + "Recurring access reviews with retained evidence", + "Vendor risk reviews for infrastructure, email, payment, and data-provider subprocessors", + "Incident response tabletop or equivalent drill evidence", + "Employee security training and onboarding/offboarding records", + ], + note: "This endpoint reports application control evidence only. It does not represent SOC 2 certification or audit opinion.", + }; + } +} diff --git a/test/compliance.service.spec.ts b/test/compliance.service.spec.ts new file mode 100644 index 0000000..b53977c --- /dev/null +++ b/test/compliance.service.spec.ts @@ -0,0 +1,38 @@ +import { ComplianceService } from "../src/compliance/compliance.service"; + +const createService = () => { + const prisma = { + auditLog: { count: jest.fn() }, + exportLog: { count: jest.fn() }, + abuseEvent: { count: jest.fn() }, + session: { count: jest.fn() }, + }; + + return { service: new ComplianceService(prisma as any), prisma }; +}; + +describe("ComplianceService", () => { + it("returns SOC 2 readiness evidence without claiming certification", async () => { + const { service, prisma } = createService(); + prisma.auditLog.count.mockResolvedValue(12); + prisma.exportLog.count.mockResolvedValue(3); + prisma.abuseEvent.count.mockResolvedValue(2); + prisma.session.count.mockResolvedValue(1); + + const result = await service.getSoc2Readiness("user_1"); + + expect(result.readinessStatus).toBe("technical_baseline_ready"); + expect(result.certificationStatus).toBe("not_certified"); + expect(result.evidenceSummary).toEqual({ + auditLogCount: 12, + exportLogCount: 3, + abuseEventCount: 2, + activeSessionCount: 1, + }); + expect(result.controls).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "CC6.1", status: "implemented" }), + expect.objectContaining({ id: "CC8.1", status: "partial" }), + ])); + expect(result.pendingExternalControls.length).toBeGreaterThan(0); + }); +});