Audit Trail

Audit event structure and audited operations for compliance and forensics.

Status: Partially implemented (as of 2026-07-14, SPRINT_20260703_002 / DTC-12 and SPRINT_20260703_006 / E2E-8). The tamper-evident store shipped long ago — release_orchestrator.audit_entries + audit_sequences with the next_audit_sequence / update_audit_sequence_hash / verify_audit_chain SQL chain (migration 001) and PostgresAuditRepository — but no code path wrote it (0 rows in every deployment). DTC-12 added the first writers: ReleaseOrchestratorDecisionAuditTrail records approval decisions (grant/reject, incl. batch) and deployment lifecycle decisions (create/pause/resume/cancel/rollback/target-retry). The Console submits approve/reject actions through POST /api/v1/approvals/{id}/decision; that v2 route uses the same canonical local-chain writer as the dedicated approve/reject routes and separately emits the platform-wide audit event. This v2 writer correction is source-verified but requires a Release Orchestrator rebuild/redeploy before older live runtimes adopt it. Other categories listed below (authentication, environments, integrations, plugins…) are still design-time; verify against src/ before relying on them. Source: Architecture Advisory Section 8.5 Related Modules: Evidence Module, Security Overview Sprints: 109_001 Evidence Collector

Overview

The Release Orchestrator maintains a tamper-evident audit trail of all security-relevant operations. Audit events are cryptographically chained to detect tampering.


Audit Event Structure

TypeScript Interface

interface AuditEvent {
  id: UUID;
  timestamp: DateTime;
  tenantId: UUID;

  // Actor
  actorType: "user" | "agent" | "system" | "plugin";
  actorId: UUID;
  actorName: string;
  actorIp?: string;

  // Action
  action: string;              // "promotion.approved", "deployment.started"
  resource: string;            // "promotion"
  resourceId: UUID;

  // Context
  environmentId?: UUID;
  releaseId?: UUID;
  promotionId?: UUID;

  // Details
  before?: object;             // State before (for updates)
  after?: object;              // State after
  metadata?: object;           // Additional context

  // Integrity
  previousEventHash: string;   // Hash chain for tamper detection
  eventHash: string;
}

Audited Operations

CategoryOperations
AuthenticationLogin, logout, token refresh, failed attempts
AuthorizationPermission denied events
EnvironmentsCreate, update, delete, freeze window changes
ReleasesCreate, deprecate, archive
PromotionsRequest, approve, reject, cancel
DeploymentsStart, complete, fail, rollback
TargetsRegister, update, delete, health changes
AgentsRegister, heartbeat gaps, capability changes
IntegrationsCreate, update, delete, test
PluginsEnable, disable, config changes
EvidenceCreate (never update/delete)

Hash Chain

Chain Verification

The audit trail uses SHA-256 hash chaining for tamper detection:

interface HashChainEntry {
  eventId: UUID;
  eventHash: string;
  previousEventHash: string;
}

function computeEventHash(event: AuditEvent): string {
  const payload = JSON.stringify({
    id: event.id,
    timestamp: event.timestamp,
    tenantId: event.tenantId,
    actorType: event.actorType,
    actorId: event.actorId,
    action: event.action,
    resource: event.resource,
    resourceId: event.resourceId,
    previousEventHash: event.previousEventHash,
  });

  return sha256(payload);
}

function verifyChain(events: AuditEvent[]): VerificationResult {
  for (let i = 1; i < events.length; i++) {
    const current = events[i];
    const previous = events[i - 1];

    if (current.previousEventHash !== previous.eventHash) {
      return {
        valid: false,
        brokenAt: i,
        reason: "Hash chain broken"
      };
    }

    const computed = computeEventHash(current);
    if (computed !== current.eventHash) {
      return {
        valid: false,
        brokenAt: i,
        reason: "Event hash mismatch"
      };
    }
  }

  return { valid: true };
}

Verify endpoint (live contract)

GET /api/v1/release-orchestrator/audit/verify (scope: ReleaseOrchestratorPolicies.Read, tenant-scoped) runs two layers:

LayerMechanismCatches
Structuralin-DB verify_audit_chain(tenant, start, end) SQL walk — follows previous_entry_hash → predecessor content_hash. No per-row transfer.Broken pointer links (a re-ordered / spliced / dropped row).
Content (deep)per-row recompute of content_hash under each recognized row’s hash_version (AuditEntry.VerifyIntegrity).Field tampers in reproducible epochs — a mutated column whose row still carries the original content_hash and intact pointers (invisible to the structural walk).

Query params:

Response (ChainVerificationResponse):

{
  "isValid": false,
  "invalidEntryId": "…",
  "invalidSequence": 2,
  "errorMessage": "Content tamper: entry … (sequence 2) failed content-hash recomputation under hash v3.",
  "failureKind": "ContentTamper",   // None | StructuralBreak | ContentTamper | UnsupportedHashVersion
  "invalidHashVersion": 3,
  "deepVerified": true               // false also marks honest partial legacy coverage
}

Round-trip-stable hashing and legacy coverage (SER-5). New entries use hash v3, the first unambiguous epoch computed over each field’s PostgreSQL storage round-trip form: occurred_at is truncated to microseconds (TIMESTAMPTZ resolution) and old_state/new_state are canonical JSON before hashing (jsonb re-formats text on read). A v1/v2 row that still reproduces is content-verified normally. If a historical v1/v2 row does not reproduce because its pre-normalization full-precision timestamp or raw JSON text was discarded, the endpoint returns isValid:true, failureKind:"None", deepVerified:false, and an explanatory partial- coverage message. It does not falsely report ContentTamper, and immutable history is not rewritten. Unknown hash epochs fail closed as UnsupportedHashVersion; a v3 mismatch remains a proven ContentTamper. Migration 018 makes hash_version immutable after insertion so a normal DB writer cannot relabel a v3 row as legacy to evade this boundary.


Example Audit Events

Promotion Approved

{
  "id": "evt-123",
  "timestamp": "2026-01-09T14:32:15Z",
  "tenantId": "tenant-uuid",
  "actorType": "user",
  "actorId": "user-uuid",
  "actorName": "jane@example.com",
  "actorIp": "192.168.1.100",
  "action": "promotion.approved",
  "resource": "promotion",
  "resourceId": "promo-uuid",
  "environmentId": "env-uuid",
  "releaseId": "rel-uuid",
  "promotionId": "promo-uuid",
  "before": {
    "status": "pending"
  },
  "after": {
    "status": "approved",
    "approvals": 2
  },
  "metadata": {
    "comment": "LGTM"
  },
  "previousEventHash": "sha256:abc...",
  "eventHash": "sha256:def..."
}

Deployment Started

{
  "id": "evt-124",
  "timestamp": "2026-01-09T14:32:20Z",
  "tenantId": "tenant-uuid",
  "actorType": "system",
  "actorId": "system",
  "actorName": "deployment-orchestrator",
  "action": "deployment.started",
  "resource": "deployment",
  "resourceId": "deploy-uuid",
  "environmentId": "env-uuid",
  "releaseId": "rel-uuid",
  "promotionId": "promo-uuid",
  "after": {
    "status": "deploying",
    "strategy": "rolling",
    "targetCount": 5
  },
  "previousEventHash": "sha256:def...",
  "eventHash": "sha256:ghi..."
}

Retention Policy

EnvironmentRetention Period
All tenants7 years (compliance)
After tenant deletion7 years (legal hold)
Archive formatNDJSON, signed

Export Format

Audit events can be exported for compliance reporting:

# Export audit trail for a date range
GET /api/v1/audit/export?
  start=2026-01-01T00:00:00Z&
  end=2026-01-31T23:59:59Z&
  format=ndjson

Response includes signed digest for verification:

{
  "export": {
    "startDate": "2026-01-01T00:00:00Z",
    "endDate": "2026-01-31T23:59:59Z",
    "eventCount": 15234,
    "firstEventHash": "sha256:abc...",
    "lastEventHash": "sha256:xyz...",
    "downloadUrl": "https://..."
  },
  "signature": "base64-signature",
  "signedAt": "2026-02-01T00:00:00Z"
}

See Also