# SOC 2 Type II Preparation — Specification **Workstream**: 6 of 6 **Phase**: 3 — Enterprise **Author**: Virtual Architect **Date**: 2026-03-29 --- ## Overview Implement the technical controls required for SOC 2 Type II audit readiness. SOC 2 Type II certifies that security controls operate continuously over a defined period — not just that they exist. Controls are implemented in code, not just documented. This workstream cuts across all other Phase 3 workstreams. It delivers: encryption at rest for sensitive columns, TLS enforcement middleware, automated secrets rotation, security event alerting, and audit log immutability via a Merkle hash chain. A compliance documentation package (controls matrix and runbook) is produced for auditors. --- ## Technical Controls ### Control C1: Encryption at Rest (Column-Level Encryption) Sensitive columns in PostgreSQL are encrypted using `pgcrypto` symmetric encryption. The encryption key is stored in Vault and fetched at application startup, never written to disk. **Columns encrypted**: - `credentials.secret_hash` — encrypted with AES-256-CBC - `credentials.vault_path` — encrypted with AES-256-CBC - `webhook_subscriptions.vault_secret_path` — encrypted with AES-256-CBC - `agent_did_keys.vault_key_path` — encrypted with AES-256-CBC **Implementation**: A `EncryptionService` wraps `pgcrypto` `pgp_sym_encrypt` / `pgp_sym_decrypt`. The key is a 256-bit symmetric key stored at `secret/agentidp/encryption/column-key` in Vault. All INSERT/SELECT operations for encrypted columns go through `EncryptionService`. --- ### Control C2: TLS Enforcement All inbound HTTP connections are rejected in production if TLS is not present. This is enforced at two levels: 1. Express middleware: `TLSEnforcementMiddleware` — if `X-Forwarded-Proto` is not `https` and `NODE_ENV=production`, respond `301 Moved Permanently` to HTTPS. 2. Terraform: Load balancers (Phase 2 Terraform modules) already enforce TLS; TLS enforcement middleware provides defense-in-depth. --- ### Control C3: Automated Secrets Rotation A scheduled job (`SecretsRotationJob`) runs on a configurable cron schedule. It: 1. Identifies credentials whose `expires_at` is within `ROTATION_WARNING_DAYS` days 2. Emits a Prometheus metric `agentidp_credentials_expiring_soon_total` (labelled by `org_id`, `days_remaining`) 3. Renews Vault leases for all active credentials 4. Sends a webhook event `credential.expiring_soon` to subscribers who have opted in This does not automatically rotate credentials without operator action — it alerts and prepares. Forced rotation requires an operator call to the existing `POST /agents/:id/credentials/:credId/rotate` endpoint. --- ### Control C4: Audit Log Immutability (Merkle Hash Chain) Every `audit_logs` row carries two new columns: - `hash`: SHA-256 of `(eventId || timestamp.toISOString() || action || outcome || agentId || organizationId || previousHash)` - `previous_hash`: hash of the immediately preceding `audit_logs` row (by `created_at` order), or the genesis string `"GENESIS"` for the first row A PostgreSQL trigger prevents `UPDATE` and `DELETE` on `audit_logs`. A new admin endpoint `GET /audit/verify` runs a sequential chain verification pass and returns the integrity status. --- ### Control C5: Security Event Alerting Prometheus alerting rules are written for the following security events: | Alert | Condition | Severity | |-------|-----------|---------| | `AuthFailureSpike` | >50 `auth.failed` events in 5 minutes | Warning | | `RateLimitExhaustion` | >80% of org rate limit consumed in 1 minute | Warning | | `AnomalousTokenIssuance` | Token issuance rate 3x 7-day average | Warning | | `WebhookDeadLetterAccumulating` | `agentidp_webhook_dead_letters_total` increases by >10 in 1 hour | Warning | | `AuditChainIntegrityFailed` | `agentidp_audit_chain_integrity` metric is 0 | Critical | | `CredentialExpiryApproaching` | `agentidp_credentials_expiring_soon_total{days_remaining="7"}` > 0 | Info | --- ## API Endpoints ### GET /audit/verify Verify the Merkle hash chain integrity of the audit log. Requires `admin:orgs` scope. This is a potentially expensive operation on large audit logs — it is rate-limited to once per 5 minutes per organization. ```yaml GET /audit/verify Authorization: Bearer Query Parameters: fromDate: type: string format: date-time description: Start of verification range. If omitted, verifies from genesis. toDate: type: string format: date-time description: End of verification range. If omitted, verifies to the latest row. Responses: 200 OK: schema: type: object properties: valid: type: boolean description: True if the chain is intact across the entire range rowsVerified: type: integer description: Number of audit rows verified firstEventId: type: string lastEventId: type: string firstTimestamp: type: string format: date-time lastTimestamp: type: string format: date-time verifiedAt: type: string format: date-time brokenAtEventId: type: string nullable: true description: Present only if valid=false — the first eventId where the chain breaks example: valid: true rowsVerified: 15420 firstEventId: "evt_genesis_00001" lastEventId: "evt_01HXK7Z9P3FKWABCDEFZZZZZ" firstTimestamp: "2026-01-01T00:00:00Z" lastTimestamp: "2026-03-29T12:00:00Z" verifiedAt: "2026-03-29T14:00:00Z" brokenAtEventId: null 401 Unauthorized: schema: $ref: '#/components/schemas/ErrorResponse' 403 Forbidden: schema: $ref: '#/components/schemas/ErrorResponse' 429 Too Many Requests: schema: $ref: '#/components/schemas/ErrorResponse' example: code: "RATE_LIMITED" message: "Audit verification can be run at most once per 5 minutes" ``` --- ### GET /compliance/controls Returns the current status of all SOC 2 technical controls. Requires `admin:orgs` scope. Used by auditors and compliance dashboards. ```yaml GET /compliance/controls Authorization: Bearer Responses: 200 OK: schema: type: object properties: generatedAt: type: string format: date-time controls: type: array items: type: object properties: controlId: type: string name: type: string status: type: string enum: [pass, fail, warning, not_applicable] description: type: string lastChecked: type: string format: date-time example: generatedAt: "2026-03-29T14:00:00Z" controls: - controlId: "C1" name: "Encryption at Rest" status: "pass" description: "Column-level encryption active for all sensitive columns" lastChecked: "2026-03-29T14:00:00Z" - controlId: "C2" name: "TLS Enforcement" status: "pass" description: "All non-TLS requests redirected to HTTPS in production" lastChecked: "2026-03-29T14:00:00Z" - controlId: "C3" name: "Secrets Rotation" status: "warning" description: "3 credentials expiring within 7 days" lastChecked: "2026-03-29T14:00:00Z" - controlId: "C4" name: "Audit Log Immutability" status: "pass" description: "Merkle chain intact — last verified 2026-03-29T13:55:00Z" lastChecked: "2026-03-29T14:00:00Z" - controlId: "C5" name: "Security Event Alerting" status: "pass" description: "All 6 alerting rules active in Prometheus" lastChecked: "2026-03-29T14:00:00Z" 401 Unauthorized: schema: $ref: '#/components/schemas/ErrorResponse' 403 Forbidden: schema: $ref: '#/components/schemas/ErrorResponse' ``` --- ## Database Schema Changes ### Modified: audit_logs table ```sql ALTER TABLE audit_logs ADD COLUMN hash VARCHAR(64), -- SHA-256 hex string of chain node ADD COLUMN previous_hash VARCHAR(64); -- Hash of preceding row, or "GENESIS" -- Back-fill genesis hash for existing rows (one-time migration) -- Migration script computes chain in order of created_at -- Prevent updates and deletes (immutability trigger) CREATE OR REPLACE FUNCTION prevent_audit_modification() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'audit_logs rows are immutable — modification is not permitted'; END; $$ LANGUAGE plpgsql; CREATE TRIGGER audit_logs_immutability BEFORE UPDATE OR DELETE ON audit_logs FOR EACH ROW EXECUTE FUNCTION prevent_audit_modification(); ``` ### Modified: credentials table ```sql -- Columns remain same type; application now stores encrypted values -- No DDL change — encryption is transparent at application layer -- Add comment for documentation COMMENT ON COLUMN credentials.secret_hash IS 'AES-256-CBC encrypted via EncryptionService (pgcrypto). Not a plain bcrypt hash.'; COMMENT ON COLUMN credentials.vault_path IS 'AES-256-CBC encrypted via EncryptionService.'; ``` ### New Table: compliance_check_log ```sql CREATE TABLE compliance_check_log ( check_id VARCHAR(40) PRIMARY KEY, organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id), control_id VARCHAR(10) NOT NULL, status VARCHAR(20) NOT NULL, details JSONB NOT NULL DEFAULT '{}', checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_compliance_check_org ON compliance_check_log(organization_id, checked_at DESC); ``` --- ## Configuration | Environment Variable | Description | Default | |---------------------|-------------|---------| | `SOC2_CONTROLS_ENABLED` | Enable SOC 2 controls enforcement | `true` | | `TLS_ENFORCEMENT_ENABLED` | Enforce HTTPS in production | `true` in production, `false` in development | | `COLUMN_ENCRYPTION_KEY_PATH` | Vault path for AES-256 column encryption key | `secret/agentidp/encryption/column-key` | | `ROTATION_WARNING_DAYS` | Days before expiry to emit rotation warning | `30` | | `SECRETS_ROTATION_CRON` | Cron schedule for rotation check job | `0 3 * * *` (daily at 3 AM UTC) | | `AUDIT_CHAIN_VERIFY_CRON` | Cron schedule for automated chain verification | `0 2 * * *` (daily at 2 AM UTC) | --- ## Dependencies | Package | Version | Purpose | |---------|---------|---------| | `node-forge` | `^1.3.1` | AES-256-CBC column-level encryption primitives | Note: `pgcrypto` PostgreSQL extension must be enabled: `CREATE EXTENSION IF NOT EXISTS pgcrypto;` --- ## Compliance Documentation The following documents are produced as part of this workstream: | Document | Path | Description | |----------|------|-------------| | Controls Matrix | `docs/compliance/soc2-controls-matrix.md` | Maps SOC 2 Trust Services Criteria to implemented controls | | Encryption Runbook | `docs/compliance/encryption-runbook.md` | Key rotation procedure, Vault key path map | | Audit Log Runbook | `docs/compliance/audit-log-runbook.md` | How to run chain verification, interpret results | | Incident Response | `docs/compliance/incident-response.md` | Security event response procedures | | Secrets Rotation Guide | `docs/compliance/secrets-rotation.md` | Operator guide for credential and key rotation | --- ## Security Considerations - Column encryption key is fetched from Vault at startup and held in process memory — never written to disk or logged - Key rotation: new encryption key generates re-encrypted copies of all sensitive columns in a migration; the old key is retained in Vault history - The immutability trigger on `audit_logs` prevents application-layer modification; a `SUPERUSER` can still bypass triggers — document this in the controls matrix as a residual risk requiring compensating controls (e.g., read-only replica verification) - `GET /audit/verify` is rate-limited to prevent denial-of-service via repeated expensive sequential scans - `GET /compliance/controls` never returns raw secrets or key material — only control status --- ## Acceptance Criteria - [ ] `pgcrypto` extension enabled; sensitive columns are encrypted at rest (verified: plaintext not visible in direct DB query) - [ ] TLS enforcement middleware redirects HTTP to HTTPS in production; passthrough in development - [ ] `SecretsRotationJob` runs on schedule; emits Prometheus metric for expiring credentials - [ ] Audit log immutability trigger prevents UPDATE/DELETE on `audit_logs` table - [ ] `GET /audit/verify` returns `valid: true` for an unmodified chain - [ ] `GET /audit/verify` returns `valid: false` with `brokenAtEventId` after a row is manually tampered with (test scenario) - [ ] All 6 Prometheus alerting rules are present in `monitoring/prometheus/alerts.yml` - [ ] `GET /compliance/controls` returns correct status for all 5 controls - [ ] Compliance documentation written and reviewed - [ ] TypeScript strict, zero `any`, >80% test coverage on SOC2 control implementations