Skip to content

feat: add soc2 audit trail and response docs#67

Merged
chrismaz11 merged 1 commit intomasterfrom
codex_soc2_fixes
Mar 20, 2026
Merged

feat: add soc2 audit trail and response docs#67
chrismaz11 merged 1 commit intomasterfrom
codex_soc2_fixes

Conversation

@chrismaz11
Copy link
Collaborator

Summary

  • add a persisted workflow event sink with Prisma schema, migration, server wiring, and tests
  • add incident response and security documentation updates for SOC 2 evidence collection
  • document secret rotation evidence handling, branch protection expectations, and DB encryption evidence capture

Verification

  • npm --workspace apps/api run typecheck
  • npm --workspace apps/api exec vitest run src/workflow.events.test.ts src/workflow.service.test.ts src/workflow.test.ts

Issue tracking

Copilot AI review requested due to automatic review settings March 20, 2026 07:05
@vercel
Copy link

vercel bot commented Mar 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
trustsignal Ready Ready Preview, Comment Mar 20, 2026 7:06am

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1079 to +1082
const state = workflowService.getWorkflowState(parsed.data.workflowId);
if (!state) {
return reply.code(404).send({ error: 'workflow_not_found' });
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@chrismaz11 chrismaz11 merged commit 284baed into master Mar 20, 2026
18 checks passed
@chrismaz11 chrismaz11 deleted the codex_soc2_fixes branch March 20, 2026 07:09
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 WorkflowEvent persistence (Prisma model + migration + runtime DB bootstrap) and implement PrismaWorkflowEventSink / 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 actor to "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 calls verifyArtifact(workflowId, artifactId) without an actor, those events will be attributed to "system". Consider making actor required (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.

Comment on lines +1079 to +1083
const state = workflowService.getWorkflowState(parsed.data.workflowId);
if (!state) {
return reply.code(404).send({ error: 'workflow_not_found' });
}

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const state = workflowService.getWorkflowState(parsed.data.workflowId);
if (!state) {
return reply.code(404).send({ error: 'workflow_not_found' });
}

Copilot uses AI. Check for mistakes.
await this.pendingWrite;
const rows = await this.workflowEventDelegate.findMany({
where: { workflowId },
orderBy: { timestamp: 'asc' }
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
orderBy: { timestamp: 'asc' }
orderBy: [{ timestamp: 'asc' }, { id: 'asc' }]

Copilot uses AI. Check for mistakes.
Comment on lines +933 to +940
? new PrismaWorkflowEventSink(
(prisma as PrismaClient & { workflowEvent: PrismaWorkflowEventDelegate }).workflowEvent,
app.log
)
: new NoopWorkflowEventSink());
const workflowService = new WorkflowService(undefined, {
eventSink: workflowEventSink
});
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +262 to +315
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');
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants