feat: add soc2 audit trail and response docs#67
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c13d95217e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const state = workflowService.getWorkflowState(parsed.data.workflowId); | ||
| if (!state) { | ||
| return reply.code(404).send({ error: 'workflow_not_found' }); | ||
| } |
There was a problem hiding this comment.
Allow event lookup when workflow state is not in memory
This handler blocks retrieval of persisted audit events by requiring workflowService.getWorkflowState(...) to exist first, but WorkflowService uses an in-memory store, so any server restart (or querying from another API instance) causes valid WorkflowEvent rows to return 404 workflow_not_found even though they were successfully written to Postgres. Because this commit’s main feature is durable workflow audit history, this guard makes historical evidence effectively inaccessible in normal multi-instance/restart scenarios.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR adds SOC 2 evidence support by introducing a persisted workflow audit trail (stored via Prisma and exposed via an API endpoint) and by expanding incident response + security governance documentation for evidence collection.
Changes:
- Add
WorkflowEventpersistence (Prisma model + migration + runtime DB bootstrap) and implementPrismaWorkflowEventSink/InMemoryWorkflowEventSink. - Wire the workflow event sink into the API server and expose
GET /api/v1/workflows/:workflowId/events. - Add SOC 2-focused docs updates (incident response plan, branch protection evidence, DB encryption evidence, secret rotation evidence guidance) plus tests for event persistence/querying.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/security-workflows.md | Link incident response plan from security workflow docs. |
| docs/ops/db-security-evidence.md | Add guidance for collecting provider-side encryption-at-rest evidence. |
| docs/github-settings-checklist.md | Document expected master branch protection baseline and evidence capture steps. |
| docs/compliance/evidence/logging-monitoring.md | Document workflow audit event persistence and what evidence to capture. |
| docs/SECURITY.md | Link response documentation from the repo security guide. |
| docs/INCIDENT_RESPONSE_PLAN.md | Add audit-facing incident response runbook tailored to TrustSignal workflows/receipts. |
| apps/api/src/workflow/service.ts | Emit actor on workflow events; thread actor into verify/release decision events. |
| apps/api/src/workflow/events.ts | Add stored event shape + in-memory and Prisma-backed event sinks. |
| apps/api/src/workflow.test.ts | Add integration test for querying workflow events after a flow. |
| apps/api/src/workflow.events.test.ts | Add unit test for Prisma sink normalization + ordering. |
| apps/api/src/server.ts | Load runtime env, wire event sink into WorkflowService, add events API route. |
| apps/api/src/env.ts | Add runtime .env loader and DB URL alias resolution helper. |
| apps/api/src/db.ts | Ensure WorkflowEvent table/index exist in runtime DB bootstrap. |
| apps/api/prisma/schema.prisma | Add WorkflowEvent Prisma model. |
| apps/api/prisma/migrations/20260320050000_add_workflow_event_table/migration.sql | Create WorkflowEvent table + index. |
| SECURITY_CHECKLIST.md | Add DB encryption evidence pointer + secret rotation evidence/cadence section. |
| SECURITY.md | Add root security policy and point to detailed docs/runbooks. |
Comments suppressed due to low confidence (1)
apps/api/src/workflow/service.ts:328
- Defaulting
actorto "system" makes it easy for API-triggered verifications to be recorded without an operator, which weakens the audit trail. Since the verify-artifact HTTP route currently callsverifyArtifact(workflowId, artifactId)without an actor, those events will be attributed to "system". Consider makingactorrequired (no default) and updating call sites to pass an operator identity (e.g., request.createdBy or a value derived from authContext).
verifyArtifact(workflowId: string, artifactId: string, actor = 'system'): VerificationRecord {
const artifact = this.getArtifactForWorkflow(workflowId, artifactId);
const recomputedHash = keccak256Utf8(canonicalizeJson(artifact.content));
const verification: VerificationRecord = {
artifactId: artifact.id,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const state = workflowService.getWorkflowState(parsed.data.workflowId); | ||
| if (!state) { | ||
| return reply.code(404).send({ error: 'workflow_not_found' }); | ||
| } | ||
|
|
There was a problem hiding this comment.
The events endpoint currently returns 404 unless the workflow exists in the in-memory WorkflowService store. That means persisted audit events in Prisma become non-queryable after a process restart (or if the workflow was created by another instance), which undermines the purpose of persisting the audit trail. Consider removing the in-memory state check here, or validating existence via persisted data (e.g., WorkflowEvent rows or a persisted Workflow table) and returning an empty list when no events exist.
| const state = workflowService.getWorkflowState(parsed.data.workflowId); | |
| if (!state) { | |
| return reply.code(404).send({ error: 'workflow_not_found' }); | |
| } |
| await this.pendingWrite; | ||
| const rows = await this.workflowEventDelegate.findMany({ | ||
| where: { workflowId }, | ||
| orderBy: { timestamp: 'asc' } |
There was a problem hiding this comment.
listByWorkflow orders only by timestamp. If multiple events share the same timestamp (possible when emitting several events in quick succession), the returned order can be nondeterministic at the DB level, which is risky for an audit trail and can make tests flaky. Consider adding a stable secondary ordering (e.g., createdAt then id) or storing an explicit monotonic sequence/offset for per-workflow event ordering.
| orderBy: { timestamp: 'asc' } | |
| orderBy: [{ timestamp: 'asc' }, { id: 'asc' }] |
| ? new PrismaWorkflowEventSink( | ||
| (prisma as PrismaClient & { workflowEvent: PrismaWorkflowEventDelegate }).workflowEvent, | ||
| app.log | ||
| ) | ||
| : new NoopWorkflowEventSink()); | ||
| const workflowService = new WorkflowService(undefined, { | ||
| eventSink: workflowEventSink | ||
| }); |
There was a problem hiding this comment.
The Prisma delegate is being force-cast to a custom WorkflowEventPrismaDelegate shape. This bypasses Prisma’s generated types (especially around payload: Json and orderBy options) and can hide schema/type mismatches at compile time. Prefer typing the sink against Prisma’s generated types (e.g., PrismaClient['workflowEvent'], Prisma.WorkflowEventCreateInput, and the real findMany args), which would also allow multi-column orderBy without widening casts.
| await app.close(); | ||
|
|
||
| const workflowEventSink = new InMemoryWorkflowEventSink(); | ||
| app = await buildServer({ | ||
| logger: false, | ||
| workflowEventSink | ||
| }); | ||
|
|
||
| const workflowRes = await app.inject({ | ||
| method: 'POST', | ||
| url: '/api/v1/workflows', | ||
| headers: { 'x-api-key': apiKey }, | ||
| payload: { createdBy: 'operator@trustsignal.test' } | ||
| }); | ||
| const workflow = workflowRes.json(); | ||
|
|
||
| const artifactRes = await app.inject({ | ||
| method: 'POST', | ||
| url: `/api/v1/workflows/${workflow.id}/artifacts`, | ||
| headers: { 'x-api-key': apiKey }, | ||
| payload: { | ||
| createdBy: 'operator@trustsignal.test', | ||
| classification: 'internal', | ||
| parentIds: [], | ||
| content: { | ||
| schemaVersion: 'trustsignal.workflow.input.v1', | ||
| source: 'audit-log-test' | ||
| } | ||
| } | ||
| }); | ||
| const artifact = artifactRes.json(); | ||
|
|
||
| await app.inject({ | ||
| method: 'POST', | ||
| url: `/api/v1/workflows/${workflow.id}/artifacts/${artifact.id}/verify`, | ||
| headers: { 'x-api-key': apiKey } | ||
| }); | ||
|
|
||
| const eventsRes = await app.inject({ | ||
| method: 'GET', | ||
| url: `/api/v1/workflows/${workflow.id}/events`, | ||
| headers: { 'x-api-key': apiKey } | ||
| }); | ||
|
|
||
| expect(eventsRes.statusCode).toBe(200); | ||
| const body = eventsRes.json(); | ||
| expect(body.events.length).toBeGreaterThanOrEqual(3); | ||
| expect(body.events.map((event: { eventType: string }) => event.eventType)).toEqual([ | ||
| 'workflow.created', | ||
| 'workflow.artifact.created', | ||
| 'workflow.artifact.verified' | ||
| ]); | ||
| expect(body.events[0].operator).toBe('operator@trustsignal.test'); | ||
| expect(body.events[2].decision).toBe('verified'); |
There was a problem hiding this comment.
This test closes and reassigns the shared app instance used by earlier tests. That works as long as this remains the last test, but it makes the suite more order-dependent and can complicate future edits (or parallelization). Consider using a separate local Fastify instance inside this test (and closing it in a finally) instead of mutating the shared app variable.
| await app.close(); | |
| const workflowEventSink = new InMemoryWorkflowEventSink(); | |
| app = await buildServer({ | |
| logger: false, | |
| workflowEventSink | |
| }); | |
| const workflowRes = await app.inject({ | |
| method: 'POST', | |
| url: '/api/v1/workflows', | |
| headers: { 'x-api-key': apiKey }, | |
| payload: { createdBy: 'operator@trustsignal.test' } | |
| }); | |
| const workflow = workflowRes.json(); | |
| const artifactRes = await app.inject({ | |
| method: 'POST', | |
| url: `/api/v1/workflows/${workflow.id}/artifacts`, | |
| headers: { 'x-api-key': apiKey }, | |
| payload: { | |
| createdBy: 'operator@trustsignal.test', | |
| classification: 'internal', | |
| parentIds: [], | |
| content: { | |
| schemaVersion: 'trustsignal.workflow.input.v1', | |
| source: 'audit-log-test' | |
| } | |
| } | |
| }); | |
| const artifact = artifactRes.json(); | |
| await app.inject({ | |
| method: 'POST', | |
| url: `/api/v1/workflows/${workflow.id}/artifacts/${artifact.id}/verify`, | |
| headers: { 'x-api-key': apiKey } | |
| }); | |
| const eventsRes = await app.inject({ | |
| method: 'GET', | |
| url: `/api/v1/workflows/${workflow.id}/events`, | |
| headers: { 'x-api-key': apiKey } | |
| }); | |
| expect(eventsRes.statusCode).toBe(200); | |
| const body = eventsRes.json(); | |
| expect(body.events.length).toBeGreaterThanOrEqual(3); | |
| expect(body.events.map((event: { eventType: string }) => event.eventType)).toEqual([ | |
| 'workflow.created', | |
| 'workflow.artifact.created', | |
| 'workflow.artifact.verified' | |
| ]); | |
| expect(body.events[0].operator).toBe('operator@trustsignal.test'); | |
| expect(body.events[2].decision).toBe('verified'); | |
| const workflowEventSink = new InMemoryWorkflowEventSink(); | |
| const eventsApp = await buildServer({ | |
| logger: false, | |
| workflowEventSink | |
| }); | |
| try { | |
| const workflowRes = await eventsApp.inject({ | |
| method: 'POST', | |
| url: '/api/v1/workflows', | |
| headers: { 'x-api-key': apiKey }, | |
| payload: { createdBy: 'operator@trustsignal.test' } | |
| }); | |
| const workflow = workflowRes.json(); | |
| const artifactRes = await eventsApp.inject({ | |
| method: 'POST', | |
| url: `/api/v1/workflows/${workflow.id}/artifacts`, | |
| headers: { 'x-api-key': apiKey }, | |
| payload: { | |
| createdBy: 'operator@trustsignal.test', | |
| classification: 'internal', | |
| parentIds: [], | |
| content: { | |
| schemaVersion: 'trustsignal.workflow.input.v1', | |
| source: 'audit-log-test' | |
| } | |
| } | |
| }); | |
| const artifact = artifactRes.json(); | |
| await eventsApp.inject({ | |
| method: 'POST', | |
| url: `/api/v1/workflows/${workflow.id}/artifacts/${artifact.id}/verify`, | |
| headers: { 'x-api-key': apiKey } | |
| }); | |
| const eventsRes = await eventsApp.inject({ | |
| method: 'GET', | |
| url: `/api/v1/workflows/${workflow.id}/events`, | |
| headers: { 'x-api-key': apiKey } | |
| }); | |
| expect(eventsRes.statusCode).toBe(200); | |
| const body = eventsRes.json(); | |
| expect(body.events.length).toBeGreaterThanOrEqual(3); | |
| expect(body.events.map((event: { eventType: string }) => event.eventType)).toEqual([ | |
| 'workflow.created', | |
| 'workflow.artifact.created', | |
| 'workflow.artifact.verified' | |
| ]); | |
| expect(body.events[0].operator).toBe('operator@trustsignal.test'); | |
| expect(body.events[2].decision).toBe('verified'); | |
| } finally { | |
| await eventsApp.close(); | |
| } |
Summary
Verification
Issue tracking