Scaffolds the phase-3-enterprise OpenSpec change (proposal only — awaiting CEO approval before implementation). 6 workstreams, 95 implementation tasks: WS1: Multi-Tenancy (21 tasks) — org model, RLS, admin API WS2: W3C DIDs (12 tasks) — DID:WEB, agent DID documents, AGNTCY cards WS3: OIDC (12 tasks) — oidc-provider, ID tokens, JWKS, discovery WS4: Federation (11 tasks) — cross-instance trust, JWT assertions WS5: Webhooks (17 tasks) — subscriptions, Bull queue, HMAC, retry WS6: SOC2 (22 tasks) — encryption at rest, Merkle audit chain, controls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 KiB
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-CBCcredentials.vault_path— encrypted with AES-256-CBCwebhook_subscriptions.vault_secret_path— encrypted with AES-256-CBCagent_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:
- Express middleware:
TLSEnforcementMiddleware— ifX-Forwarded-Protois nothttpsandNODE_ENV=production, respond301 Moved Permanentlyto HTTPS. - 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:
- Identifies credentials whose
expires_atis withinROTATION_WARNING_DAYSdays - Emits a Prometheus metric
agentidp_credentials_expiring_soon_total(labelled byorg_id,days_remaining) - Renews Vault leases for all active credentials
- Sends a webhook event
credential.expiring_soonto 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 precedingaudit_logsrow (bycreated_atorder), 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.
GET /audit/verify
Authorization: Bearer <token with admin:orgs scope>
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.
GET /compliance/controls
Authorization: Bearer <token with admin:orgs scope>
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
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
-- 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
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_logsprevents application-layer modification; aSUPERUSERcan still bypass triggers — document this in the controls matrix as a residual risk requiring compensating controls (e.g., read-only replica verification) GET /audit/verifyis rate-limited to prevent denial-of-service via repeated expensive sequential scansGET /compliance/controlsnever returns raw secrets or key material — only control status
Acceptance Criteria
pgcryptoextension 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
SecretsRotationJobruns on schedule; emits Prometheus metric for expiring credentials- Audit log immutability trigger prevents UPDATE/DELETE on
audit_logstable GET /audit/verifyreturnsvalid: truefor an unmodified chainGET /audit/verifyreturnsvalid: falsewithbrokenAtEventIdafter a row is manually tampered with (test scenario)- All 6 Prometheus alerting rules are present in
monitoring/prometheus/alerts.yml GET /compliance/controlsreturns correct status for all 5 controls- Compliance documentation written and reviewed
- TypeScript strict, zero
any, >80% test coverage on SOC2 control implementations