Security Operations & Runbooks
On this page
- Incident Classification
- Incident Response Procedure
- Breach Notification Chain
- Key Rotation Runbook
- chain-v2 Genesis & Migration-Job Runbook (#1246)
- Reporting Credential-Cutover Runbook (#1456, ADR-004 A8b)
- chain-v2 Staging Ops & the Unpark Runbook (#1207)
- chain-v2 Incident-Resolution Runbook (#1205, ADR-014 Amendment 9)
- Audit Program-Scope Posture (#1519, epic &78 Part D)
- Startup-Guard Inventory (fail-closed defaults + accountable overrides)
- Deployment Rollback
- Performance Troubleshooting
- Archive Management
- Data Flow Diagrams
- Security Configuration Reference
Incident Classification
| Severity | Definition | Response Target | Examples |
|---|---|---|---|
P0 — Critical |
Active data breach, system compromise, service outage affecting all users |
15 minutes (acknowledge), 4 hours (contain) |
FTI data exposure, authentication bypass, complete service failure, ransomware |
P1 — High |
Service degradation affecting multiple users, security vulnerability actively exploited |
1 hour (acknowledge), 24 hours (resolve) |
Database connection exhaustion, partial service failure, privilege escalation attempt |
P2 — Medium |
Non-critical security finding, single-user impact |
24 hours (acknowledge), 7 business days (resolve) |
Dependency CVE (high severity), individual auth failure, non-critical data exposure |
P3 — Low |
Informational finding, best practice deviation |
Next sprint |
Dependency CVE (medium/low), configuration improvement, documentation gap |
Incident Response Procedure
1. DETECT
-
Automated: canopy-security breach detection rules fire alerts (checked every 60 seconds)
-
Monitoring: Health check failures, error rate spikes, unusual traffic patterns
-
User reports: Caseworker reports unexpected behavior
-
Audit: Review canopy-security audit events API (
GET /v1/security/events)
2. CLASSIFY
Assign severity (P0–P3) based on the classification table above. Consider: * Is restricted federal data (FTI, IEVS, HIPAA PHI) at risk? * Is the system available to caseworkers? * How many users/households are affected?
3. CONTAIN
-
Isolate affected service: Scale to 0 replicas or block inbound traffic
-
Rotate compromised credentials: See Key Rotation Runbook below
-
Preserve evidence: Do NOT delete logs. Archive audit events via
POST /v1/security/archive -
Notify on-call team: Per agency escalation procedures
4. ERADICATE
-
Identify root cause (vulnerability, misconfiguration, insider threat)
-
Patch the vulnerability or fix the configuration
-
Deploy fix via standard deployment pipeline (build → test → staging → production)
5. RECOVER
-
Restore service (scale replicas back up, unblock traffic)
-
Verify data integrity via hash chain —
POST /v1/security/chain/verify(family-full) + poll the job, then confirmGET /v1/security/chain/status(#1205;GET /v1/security/verify-chainis deleted). The FTI family tasks exist too (#1206 MR-3):family=fti&service=canopy-{tanf,medicaid}runs one verifier task per configured program DB, and degradation is PER FAMILY — one program DB outage degrades that family’s status only, never the audit family or the process. DORMANCY CAVEAT: until the #1279 cutover the verifiers are off — status reportsunknown→ 503 by design (a latched legacy v1 FTI breach still surfaces asbreached/legacy_breach_latched) and verify returns 503verifier_unavailable, so pre-cutover integrity verification during recovery remains a manual DBA procedure (bounded ad-hoc re-hash of the affected window) -
Verify determination signatures are intact (canopy-eligibility JWS verification)
-
Monitor for recurrence (24-hour watch period)
Breach Notification Chain
| Data Type | Federal Authority | Notification Window | Contact |
|---|---|---|---|
FTI (Federal Tax Information) |
IRS Office of Safeguards |
24 hours |
safeguards@irs.gov; (202) 803-9449 |
IEVS / SNAP Data |
USDA FNS Regional Office (Southeast) |
48 hours |
FNS Southeast Regional Office, Atlanta |
HIPAA PHI (Medicaid/CHIP) |
HHS Office for Civil Rights |
60 days (individuals); 60 days (HHS if >500 affected) |
ocrportal.hhs.gov/ocr/breach |
General PII |
State Attorney General |
Per state breach notification law (Georgia: without unreasonable delay) |
Georgia AG Consumer Protection Division |
Key Rotation Runbook
ECDSA Signing Keys (per program service)
Program services sign determinations with ECDSA P-256 keys.
CANOPY_VERIFY_KEY_{PROGRAM}_PREV
slot and no 30-day grace window. Each program service derives its signing
kid from its public key and self-registers that public key into
canopy-security’s signing_key_history on boot; the orchestrator’s verifier
lazy-loads any since-retired key from there (via the per-program JWKS) on a
cache-miss. So a determination signed with a rotated-out key stays verifiable
indefinitely, with no operator-managed previous-key window — you rotate by
deploying a new key, and the old key remains in the retention store forever.
Routine rotation (quarterly recommended):
-
Generate a new key pair:
cargo xtask gen-signing-keys --program snap
Produces
.keys/snap-private.pem(PKCS#8) and.keys/snap-public.pem(SPKI). -
Set the new keys (the public key is the new current verifier key; the private key is the new signer):
CANOPY_VERIFY_KEY_SNAP = <new public key PEM> CANOPY_SNAP__SIGNING_KEY = <new private key PEM>
-
Rolling restart canopy-snap (and canopy-eligibility to pick up the new current verifier key). On boot canopy-snap registers the new public key into
signing_key_historyunder its key-derived kid. New determinations are signed with the new key. -
No PREV step, no grace-period cleanup. Determinations signed with the old key continue to verify: the verifier misses the new in-memory key, then lazy-loads the old key from
signing_key_history(registered while it was active) and verifies. The old key is retained permanently — appeals/QC can run for years.
Emergency rotation (compromised key):
-
Immediately set
CANOPY_SNAP__SIGNING_KEY+CANOPY_VERIFY_KEY_SNAPto a new key pair and restart all services. -
Audit all determinations signed during the compromise window.
-
To distrust the compromised key (so its signatures stop verifying), tombstone its
signing_key_historyrow in the blessed maintenance window AND ensure it is not the currentCANOPY_VERIFY_KEY_SNAP— a dedicated revocation surface is a tracked follow-up (the store currently retains, never revokes).
Registrant attribution + the standing audit (#1261, post-#1259): every
registration records registrant_service_id — the authenticated service
identity the #1259 authorization gate ran against (NULL = pre-attribution
row, never backfilled: attribution is evidence). The battery-run standing
audit (signing_key_history_every_row_is_key_derived_and_attributable,
canopy-security integration tests) re-applies the production gate to every
attributed row and content-verifies pre-attribution rows (known program,
valid SPKI key, key-derived kid) in every environment it touches; a
violation fails the battery and the row must be purged through the guarded
canopy.signing_key_maintenance window — never normalized.
Portal Applicant-Claim Key (#1442 / #1569)
The portal signs per-request applicant ownership claims with a dedicated
P-256 key; every claim-verifying origin (canopy-applications,
canopy-eligibility, canopy-enrollment, canopy-persons, …) loads the public
key at boot via applicant_verifier_from_env. Since #1569 the verifier
accepts multiple concatenated PEM blocks in the env value, so rotation
is a zero-downtime overlap, never a hard cut of live citizen sessions:
-
Generate the incoming pair (
cargo xtask keygenconventions); do NOT deploy the private key yet. -
Append the incoming PUBLIC pem to every origin’s
CANOPY_<SERVICE>__APPLICANT_VERIFYING_KEYvalue (concatenate the two exports byte-for-byte — each block’s kid is derived over the exact export bytes) and roll the origins. Dev boxes stage the incoming key as.keys/portal-applicant-public-next.peminstead. -
Once every origin verifies both keys, swap the portal’s PRIVATE key to the incoming one and roll the portal. In-flight claims signed by the old key stay verifiable.
-
After the longest claim TTL has elapsed, remove the retired public block from the origins' env values and roll again.
Emergency (portal key compromise): skip the overlap — replace both halves immediately and accept the hard cut; every live citizen session re-auths through lookup.
Keycloak JWKS Keys
Keycloak manages its own key rotation internally. The JwksProvider in canopy-auth auto-refreshes the JWKS cache hourly and force-refreshes on unknown kid (with 30-second debounce).
Emergency (Keycloak key compromise):
-
Log into Keycloak admin console
-
Navigate to Realm Settings → Keys → Providers
-
Add a new RSA key provider (higher priority than current)
-
Keycloak starts signing with new key immediately
-
canopy-auth auto-detects the new kid within 1 hour (or immediately on next unknown-kid JWT)
-
Disable the compromised key provider after grace period
AES-256-GCM Encryption Key
Used for SSN field-level encryption in canopy-persons.
-
Generate new key:
openssl rand -base64 32 -
Write a database migration that:
-
Reads each encrypted field with the old key
-
Re-encrypts with the new key
-
Updates the row
-
-
Set
CANOPY_ENCRYPTION_KEYto the new key -
Run the migration (service must be restarted after)
chain-v2 Genesis & Migration-Job Runbook (#1246)
The chain-v2 substrate (ADR-014 Amendment 6; the MR-2 migrations) lands DORMANT. Two operator procedures exist ahead of the #1279 cutover:
Empty-genesis install (cargo xtask chain-genesis)
# Devstack (the TLS gate fails closed unless development is declared):
CANOPY_ENV=development cargo xtask chain-genesis --service canopy-tanf
# Production: URLs via env, never argv.
CANOPY_CHAIN_GENESIS__TARGET_URL=… CANOPY_CHAIN_GENESIS__ANCHOR_URL=… \
cargo xtask chain-genesis --service canopy-tanf [--shard-count N]
-
Two-phase and crash-resumable: Phase A installs the registry on the target database in one transaction (instance, epoch 0 in
installing, topology, source binding, pre-created heads); Phase B records the genesis anchor viachain_anchor_appendon canopy_security (idempotent CAS); Phase C re-fetches everything, independently REBUILDS the manifest and byte-compares it, then validates the full genesis shape. Reruns: crash-after-A resumes at B (after proving the heads untouched); a completed install re-validates and reports "already installed"; any supplied parameter differing from installed state is a hard error. -
The whole run holds a per-(database, family) advisory lock — concurrent installers refuse.
-
Credentials: the TARGET URL needs a migration/owner-capable principal (genesis writes owner-role-owned tables); the ANCHOR URL needs EXECUTE on
chain_anchor_append. The command hard-compares the anchor database name to canopy_security independent ofCANOPY_ENV(the ADR-001 guard alone is warn-only in development). -
Wiped-target caveat: if a target database is recreated while canopy_security retains the family’s prior anchors, a fresh install mints a NEW instance — the prior instance’s anchors remain recorded (instance history is immutable by design; anchor sequences are per-instance). The orphaned prior chain is evidence, not garbage: never delete it.
-
The chain is NOT appendable after install: epoch 0 stays
installinguntil the cutover operator runschain_epoch_activate(under the migration/owner credential — EXECUTE is granted to no runtime or verify role).chain-genesisis never legitimate against an activated chain.
Deploy-time migration job (cargo xtask migrate apply)
After the #1279 cutover the three chain services run with
CANOPY_<SVC>SKIP_MIGRATIONS=true: bootstrap applies nothing and the
runtime environment holds no migration-capable credential (a closed pool
does not un-know a credential — the job model removes it entirely).
Migrations then run as a deploy step: cargo xtask migrate apply --service
canopy-tanf (URL via CANOPY_MIGRATEDATABASE_URL), or the devstack
chain-migration-split compose one-shots. Until cutover, NOTHING enables
this — bootstrap migrates as today, now over a dedicated short-lived
connection that closes before the app pool exists.
Migration-credential requirements + the rotation wrinkle: the production migration credential needs CREATEROLE and schema CREATE (create-then- transfer ownership). A ROTATED credential lacks ADMIN OPTION on roles the old one created — the substrate migrations' reconcile step surfaces this loudly (refusing, not half-applying); re-grant the chain owner roles to the new migrator before re-running.
Reporting Credential-Cutover Runbook (#1456, ADR-004 A8b)
canopy-reporting’s restricted holdings are owned by canopy_reporting_owner
(NOLOGIN) and the runtime connects as the restricted canopy_reporting_app
login; migrations 20261111000000/20261111000001 create the roles
(DO-guarded), transfer ownership (catalog-driven ALTER … OWNER loop —
REASSIGN OWNED cannot be used: the historical devstack owner is the pinned
bootstrap superuser, which Postgres refuses to reassign from), apply the
per-object grant matrix, and move the generation janitor’s deletes behind
the SECURITY DEFINER reporting_janitor_reap fn. Canonical design:
the A8b child plan.
Cutover (per environment)
-
Provision the login out of source —
canopy_reporting_appmust beLOGINwith a secret-managed password BEFORE the runtime URL flips. Devstack:devstack/postgres/init.sqldoes this on a FRESH volume only — an existing devstack adopts via the documented destructive reset (cargo xtask dev clean --confirm && cargo xtask dev start). Production: create the role manually (CREATE ROLE canopy_reporting_app LOGIN PASSWORD '<vault>') or pre-create NOLOGIN and flip. -
Split the credentials: set
CANOPY_REPORTINGMIGRATION_DATABASE_URLto the owner-capable migrator URL and pointCANOPY_REPORTINGDATABASE_URLat thecanopy_reporting_applogin. Bootstrap migrates on the former over a dedicated pool (closed before the app pool exists) and serves on the latter. -
One-time transfer identity: the cutover migration’s ownership loop runs
ALTER … OWNER, which requires the executor to OWN the objects (or be a superuser) and hold SET-membership incanopy_reporting_owner(the migration grants itself that membership). Run the first post-cutover migration as the historical migrator identity or a superuser; every later migration works under an ordinaryCREATEROLEnon-superuser member of the owner role. -
Verify: boot logs show no
ALLOW_BROAD_DB_ROLEWARN; the #1456 boot guard passes (the session is the app identity, unprivileged);services/canopy-reporting/tests/least_privilege_role_test.rsproves the matrix on a real app-login connection.
Rollback
Point CANOPY_REPORTINGDATABASE_URL back at the broad credential and set
CANOPY_REPORTINGALLOW_BROAD_DB_ROLE=true — the boot guard then WARNs
loudly instead of refusing (the accountable override; ADR-041 doctrine:
fail-closed default + explicit per-control override). Ownership/grants need
no rollback: the broad credential is a superuser or owner-member and is not
bound by the matrix. Remove the override as soon as the restricted login is
restored — every boot under it is an auditable WARN.
Standing convention — every future reporting migration
Objects created by a later migration are owned by the MIGRATOR until
transferred: end every new reporting migration that creates tables or
functions with ALTER … OWNER TO canopy_reporting_owner + the app grants
its runtime SQL needs. Skipping it silently regresses to migrator-owned
objects the app cannot reach (fails loud in the devstack battery — the
runtime IS the restricted login). The catalog-loop in 20261111000000 is
the one-time backstop for pre-cutover objects only, not a recurring sweep.
chain-v2 Staging Ops & the Unpark Runbook (#1207)
The append-transport staging queue (chain_append_staging, canopy_security)
parks a row when the drainer classifies a DETERMINISTIC per-row fault:
prevalidation failure (digest mismatch, uncastable field), a substrate
Row-class refusal, or a divergent replay (same event id, different
content — already staged or already chained). Parked rows are an
auditor-visible quarantine: the staging health snapshot degrades on ANY
parked row, and they are excluded from every claim. Parking is single-shot
by design — retrying a deterministic refusal is theater.
Unpark is an OPERATOR action under the maintenance/migration credential — never a runtime API (the app role cannot clear a quarantine it created):
-
Inspect the quarantine:
SELECT event_id, park_reason, attempts, staged_at, parked_at FROM chain_append_staging WHERE parked_at IS NOT NULL ORDER BY parked_at; -
Adjudicate the cause. A
divergent replaypark is potentially a Pub 1075 §9 signal (same identity, different content) — treat as an incident input, never a silent unpark. A prevalidation/refusal park usually means a builder/substrate version skew — fix the deployment first. -
Record a ticket reference for the decision (the park row itself is the evidence; do not delete it).
-
Restore the row to draining:
UPDATE chain_append_staging SET parked_at = NULL, park_reason = NULL, attempts = 0 WHERE event_id = '<id>';The next drainer pass reclaims it. A row that reparks with the same reason confirms the fault is still live — escalate, don’t loop.
Residual staging while the flag is OFF (a trace of an aborted trial) stays visible: the stats sampler runs even when the drainer is dormant, and any staged or parked rows degrade the snapshot until drained or adjudicated.
chain-v2 Incident-Resolution Runbook (#1205, ADR-014 Amendment 9)
When a chain-v2 verifier detects an integrity finding it LATCHES a
chain_incidents row: the family’s status goes breached (503), the
family’s verification loops halt, and the state stays latched until the
procedure below clears it — a clean scheduled pass NEVER clears a breach
(scheduled runs are structurally barred from resolution; only a manual,
job-linked revalidation run can). Design: plan
chain-v2 verifiers D7.
Resolution is a guarded manual flow — there is no UI and no unguarded write path (the resolve fn enforces every precondition in SQL):
-
Inspect the evidence under the incident-admin credential. Evidence and resolution text are readable ONLY by
canopy_chain_incident_admin(the verify role and_appsee evidence-free views):SELECT id, chain_family, chain_epoch, shard_id, kind, detected_loop_kind, detected_at, evidence FROM chain_incidents WHERE state = 'latched' ORDER BY detected_at;Evidence is positions + expected/got hex — enough to adjudicate whether this is data corruption, an operational fault, or tampering (a genuine break is Pub 1075 §9 reportable — see the Breach Notification Chain). -
Open a ticket. The resolution records an
evidence_ref; the ticket is that reference. Never resolve without one — the fn refuses an empty ref. -
Trigger the manual revalidation job:
POST /v1/security/chain/verifywith{"family": …, "incident_id": "<id>"}(plusservicefor an FTI incident). Anincident_idjob bypasses the family halt gate and must run the incident’s DETECTED loop — stored at latch, never inferred — orfamily-full(the enqueue fn refuses a mismatched loop at the door). CLI:canopy security chain-verify --family … [--service …] [--wait]. -
Confirm the outcome is
ok. PollGET /v1/security/chain/verify-jobs/{id}untilstate = doneand the linked run’soutcome = ok. Arejectedoutcome means the finding is still live — the incident stays latched; back to step 1. -
Resolve under the incident-admin credential:
SELECT chain_incident_resolve('<incident-id>', '<reason>', '<ticket-ref>', '<run-id>');The fn recordsactor := session_user(enforced in SQL, never caller-supplied) and refuses unless the revalidation run is manual-mode
ok, job-linked, of the STORED detected loop, scope-covering (whole-family or the incident’s shard), same instance/family, and finished AFTER the detection. On success the family’s halt gate reopens on the next pass.
Credential provisioning: the three chain-v2 roles —
canopy_chain_verify (the background verifier + job claim protocol),
canopy_chain_incident_admin (evidence read + resolve), and
canopy_chain_anchor_emitter (anchor append + emitter transitions, #1278) —
are NOLOGIN until the #1279 cutover provisions login carriers. Until
then this runbook is not executable in production by construction (dormant
verifier ⇒ no latches to resolve); rehearse it on a devstack with the test
login carriers.
Audit Program-Scope Posture (#1519, epic &78 Part D)
Audit rows carry the authoritative programs set (audit_events.programs
TEXT[], both twins): NULL = no assertion (the row is INVISIBLE to every
scoped worker’s audit surfaces — role-agnostic, ADR-044), '{}' = asserted
program-neutral (visible to all), {…} = the named storage slugs.
Backfill posture — forward-only, deliberately. Rows written before the
20261128000000 migration have programs = NULL and DROP from the worker
portal’s audit page, CSV export, panels and citations. There is NO
synthetic backfill: inventing a program for a historical row would be an
unverifiable authorization statement (ADR-016 forward-only; the
household_id precedent). Operational access to pre-#1519 history:
-
Devstack: wipe/reseed (
cargo xtask dev clean --confirm+ seed) — the seed stamps every generated row. -
Service-level access: canopy-security’s
GET /v1/security/eventswith an EMPTYprogramsfilter (service callers and direct admin API use) remains unscoped — the pre-#1519 contract — so history is reachable for incident response without a worker-portal disclosure surface. -
Production: none exists (pre-UAT). If that changes before this paragraph is deleted, a deliberate backfill decision (derive from the hashed
event_type/metadata, verified row-by-row) must be ruled on first — do not enable a worker-facing unscoped view instead.
Chain posture: the column is OUTSIDE the frozen v1 hash (dedup_key
precedent; the 20261128000000 migration header records the
tamper-evidence reasoning); the dormant chain-v2 tables deliberately do
NOT carry it yet — the #1279 cutover rules on its v2 treatment.
Startup-Guard Inventory (fail-closed defaults + accountable overrides)
Doctrine (#1265, mirroring ADR-041): canopy never hard-denies a deployment’s
choice. Each guard below refuses to boot outside CANOPY_ENV=development by
default; the per-control override env var lets the operator proceed anyway,
producing a loud, auditable per-boot WARN naming the accepted risk — the
deployment owns it. Absence of a flag ALWAYS leaves its guard fail-closed;
accepted syntax varies by loading path: the three #1412 flags and
CANOPY_WEB__DEADLINE_OVERRIDE are read directly from the environment and
accept exactly the literal true (lowercase — 1, TRUE, on are ignored),
while the three serde-loaded flags (ALLOW_FABRICATED_VERIFICATION,
ALLOW_INSECURE_SCANNER, ALLOW_BROAD_DB_ROLE) parse as standard config
booleans (true/1/on/yes, case-insensitive). An override warns only
when the guard would actually have refused (setting a flag alongside a
compliant configuration logs nothing). Unset CANOPY_ENV resolves to
production — omission is never permissive.
| Guard (what refuses) | Override | Origin |
|---|---|---|
Unencrypted DB connection: |
|
#1260, override #1412 |
DB-name ↔ service mismatch (ADR-001 program isolation, via |
|
#1260, override #1412 |
Local ( |
|
#1260, override #1412 |
Fabricated IEVS/SAVE/SSA data: a |
|
#1265 (audit W2) |
Uninspected uploads: |
|
#1006 |
Broad reporting DB role (pre-cutover |
|
#1456 |
Out-of-range SSR deadline budgets on canopy-web (#1306 bounds) |
|
#1306 |
Not everything fail-closed carries an override — deliberately. A guard gets
one only where the guarded condition is a deployment choice. A missing
secret (CANOPY_ENCRYPTION_KEY — the mechanism cannot run without it) and
identity admission (the ADR-044 worker program-scope claim, whose override is
the IdP claim mapper, not a canopy flag) have none.
Deployment Rollback
Service Rollback
-
Identify failing service(s) from health checks (
GET /healthzreturning error) -
Check recent deployment logs for what changed
-
Revert container image tag — ADR-040 production refs are immutable per commit, so rollback is a redeploy of the previous known-good
:<short-sha>(never "the previous image if not rebuilt", which only ever held for local from-source builds):# Docker Compose (prebuilt override — see the Scaling & Deployment # runbook; the override interpolates BOTH image vars, so both must be # set even for a service-only rollback) export COMPOSE_FILE=docker-compose.yml:docker-compose.prebuilt.yml export CANOPY_PREBUILT_IMAGES=true export CANOPY_PREBUILT_SERVICE_IMAGE="$CI_REGISTRY_IMAGE:<previous-short-sha>" export CANOPY_PREBUILT_PORTAL_IMAGE="$CI_REGISTRY_IMAGE/portal:<previous-short-sha>" docker compose --profile snap-only up -d --pull always # Kubernetes kubectl rollout undo deployment/canopy-snap
-
Verify health:
GET /healthzreturns{"status": "ok"} -
Investigate root cause before redeploying
Database Rollback
Migrations are forward-only by project convention. If a migration causes problems:
Option A (preferred): Corrective migration
Write a new migration that fixes the issue (e.g., ALTER TABLE … DROP COLUMN if a column was added incorrectly). This preserves the migration history and is auditable.
Option B (destructive): Restore from backup
-
Stop the affected service
-
Restore the PostgreSQL database from PITR backup to a point before the bad migration
-
Restart the service with the corrected migration
-
WARNING: This loses all data written after the backup point
Performance Troubleshooting
Slow Database Queries
-
Check PostgreSQL slow query log (
log_min_duration_statement = 500in postgresql.conf) -
Verify indexes exist on frequently queried columns (check migration files for
CREATE INDEX) -
Check connection pool: if
active = max_connections, increasedb_max_connections
Event Bus Backpressure
-
Check RabbitMQ queue depth via management UI (
http://rabbitmq:15672) -
If queue depth > 10,000: check consumer services (canopy-security, canopy-notices, canopy-enrollment) for errors
-
Increase consumer count by scaling service replicas
Archive Management
Audit events accumulate over time. The archive system (#1208, plan
audit-archive-async) moves rows older than
the operator-set age threshold from audit_events to audit_events_archive in
bounded, per-chunk-committed passes — the single-transaction move of the
original design is gone (#1245 deleted it: unbounded transaction, genesis-
breaking boundary, silent loss). Each chunk is one atomic transaction
(SET LOCAL statement_timeout → SET LOCAL canopy.audit_maintenance='on' →
move ≤ archive_chunk_size rows with an inserted == deleted assertion →
commit), the chain-head row is always retained, and progress is durable on the
audit_archive_runs row after every chunk. API contract:
canopy-security API › Archive Management.
Manual Archive
POST /v1/security/archive
Content-Type: application/json
Authorization: Bearer <admin-jwt>
{"archive_after_days": 2555}
Admin-only (service tokens 403). Returns 202 with a durable
{run_id, poll_url} handle — the run executes asynchronously; poll
GET /v1/security/archive-runs/{run_id} to completion. A 409 means a run is
already active and carries that run’s handle. archive_after_days is an age
threshold (domain 1..=36500), not a retention value — the archive
retains rows indefinitely (retention policy = #1303).
Enabling scheduled archival (one-time procedure)
Scheduled archival ships dormant. Enablement is a three-step operator procedure; do the steps in order.
Step 1 — duplicate-wedge preflight. Verify the live table and the archive share NO row ids (a legacy partial move can leave overlap, which wedges every run):
SELECT count(*) FROM audit_events a JOIN audit_events_archive b USING (id);
The count MUST be 0. If it is not, resolve the overlap first — see Duplicate-wedge recovery below. Run this full overlap query at enablement time only (it is O(min(live, archive)); the runner’s own per-run preflight is a bounded first-chunk probe, not this).
Step 2 — pre-create the indexes on large live tables. The #1208 migration
creates four indexes transactionally (CREATE INDEX IF NOT EXISTS, NOT
CONCURRENTLY — the sqlx migrator’s advisory lock deadlocks with
CONCURRENTLY’s snapshot wait, the 20260811000000 precedent). Fresh installs
start empty and need nothing. On an already-huge live table, pre-create the
four indexes `CONCURRENTLY out of band BEFORE deploying the migration, so the
migration’s `CREATE`s no-op:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_received_at_id
ON audit_events (received_at, id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_archive_received_at_id
ON audit_events_archive (received_at, id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_archive_event_timestamp_id
ON audit_events_archive (event_timestamp, id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_archive_persons_metadata
ON audit_events_archive USING GIN (metadata)
WHERE source_service = 'canopy-persons';
Then verify every build completed valid (a failed CONCURRENTLY build leaves
an INVALID index behind):
SELECT c.relname, i.indisvalid, i.indisready
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname IN ('idx_audit_events_received_at_id',
'idx_audit_events_archive_received_at_id',
'idx_audit_events_archive_event_timestamp_id',
'idx_audit_events_archive_persons_metadata');
All rows must show indisvalid = t and indisready = t before deploying —
DROP INDEX and rebuild any that do not. The migration’s DO block re-verifies
name, table, validity, and definition at boot and fails the boot loudly on any
mismatch.
Step 3 — configure and flip the flag. Set the CANOPY_SECURITYARCHIVE_*
variables (Configuration Reference) —
ARCHIVE_AFTER_DAYS is REQUIRED once the scheduler is enabled (no default: the
age threshold is an operator decision, no policy is embedded) — then flip
CANOPY_SECURITYARCHIVE_SCHEDULER_ENABLED=true. This flag is the
accountable operator override: the deployment owns the threshold choice,
and every scheduled run records requested_by = 'scheduler' with its frozen
config snapshot on the run row. The manual endpoint works regardless of the
flag — the runner is always spawned.
Duplicate-wedge recovery
A run finalized with error_code = duplicate_overlap (visible on
GET /v1/security/archive-runs/{id} and in the
canopy_security_audit_archive_run_failures_total{error_code="duplicate_overlap"}
counter) means a row id exists in BOTH tables — the runner refuses to move
anything past it (a chunk-level 23505 rolls the chunk back whole; committed
progress from earlier chunks stands). There is deliberately NO in-code
auto-reconcile (#1208 P4): automation deleting audit rows on equality
heuristics is worse than a loud wedge.
Manual reconcile is permitted only when every column of the twin rows
matches. Compare all 17 columns with IS NOT DISTINCT FROM (NULL-safe); the
duplicate live row may then be deleted under the maintenance GUC in the same
transaction:
BEGIN;
SET LOCAL canopy.audit_maintenance = 'on';
-- 1) Inspect the offending id (from the run row's error_detail):
SELECT a.id
FROM audit_events a
JOIN audit_events_archive b USING (id)
WHERE a.id = '<offending-id>'
AND a.event_id IS NOT DISTINCT FROM b.event_id
AND a.event_type IS NOT DISTINCT FROM b.event_type
AND a.source_service IS NOT DISTINCT FROM b.source_service
AND a.action IS NOT DISTINCT FROM b.action
AND a.resource_type IS NOT DISTINCT FROM b.resource_type
AND a.resource_id IS NOT DISTINCT FROM b.resource_id
AND a.user_id IS NOT DISTINCT FROM b.user_id
AND a.user_role IS NOT DISTINCT FROM b.user_role
AND a.ip_address IS NOT DISTINCT FROM b.ip_address
AND a.metadata IS NOT DISTINCT FROM b.metadata
AND a.event_timestamp IS NOT DISTINCT FROM b.event_timestamp
AND a.received_at IS NOT DISTINCT FROM b.received_at
AND a.created_at IS NOT DISTINCT FROM b.created_at
AND a.previous_hash IS NOT DISTINCT FROM b.previous_hash
AND a.event_hash IS NOT DISTINCT FROM b.event_hash
AND a.household_id IS NOT DISTINCT FROM b.household_id;
-- 2) ONLY if step 1 returned the id (every column identical), delete the
-- live duplicate (the archive copy is retained):
DELETE FROM audit_events WHERE id = '<offending-id>';
COMMIT;
Upgrade-state repair (archive non-empty, live empty)
Every run preflights this state and refuses with
error_code = upgrade_state_unrepaired: with audit_events empty, the next
append would re-genesis against a chain the archive already holds. Repair is a
gated re-seed — copy the newest archive row back into audit_events under
SET LOCAL canopy.audit_maintenance = 'on' so the next append chains from it.
Purpose-built repair tooling is deferred to #1303; until it lands, treat the
re-seed as an incident-class manual procedure (verify the chain afterwards via
POST /v1/security/chain/verify once the verifiers are active).
Interpreting more and the backlog
more: true on a done run means the pass ended on a full chunk — more
movable rows may remain (this coexists with the retained chain head). Either
re-POST a continuation run (fresh Idempotency-Key) or let the scheduler’s
catch-up cadence drain it: more pulls the next due time forward to
archive_catchup_interval_secs (default 30s), so an enabled scheduler drains
backlogs without operator action. Watch the
canopy_security_audit_archive_backlog_capped gauge — movable rows remaining
at the last probe, capped at 100001 (100001 = "more than 100k remain"; the
probe never pays an O(table) count).
Querying Archived Events
GET /v1/security/archive?limit=50 Authorization: Bearer <admin-jwt>
Keyset-paginated (received_at DESC, id DESC): pass the last row’s
(received_at, id) as before_received_at + before_id for the next page.
The old offset + filter parameters are gone (#1208 decision 14).
Recommended Retention Policy
| Data Type | Minimum Retention | Authority |
|---|---|---|
FTI audit logs |
7 years |
IRS Publication 1075 AU-11 (ADR-004 Amendment 2) |
HIPAA audit logs |
6 years |
45 CFR §164.530(j) |
IEVS audit logs |
Per state CMA |
Computer Matching Agreement |
General audit events |
3 years |
State records retention schedule |
Determination records |
7 years |
7 CFR 272.1(f) |
The archive age threshold is mechanical and independent of this table:
moving a row live→archive never shortens its retention (retention =
archive ∪ live, and the archive retains indefinitely). Retention policy —
floors, legal hold, purge — is #1303.
Data Flow Diagrams
These diagrams show how sensitive data moves through Canopy’s service architecture. Each flow enforces the isolation requirements of ADR-001 (program service isolation) and ADR-004 (legally-scoped data tenancy).
PII Flow — SSN Encryption and Storage
SSN is the only PII field with application-layer encryption. All other PII fields rely on PostgreSQL TDE in production.
Key files:
-
services/canopy-persons/src/store/persons.rs—encrypt_ssn()wrapscanopy_common::crypto::encrypt() -
crates/canopy-common/src/crypto.rs— AES-256-GCM implementation (12-byte random nonce, 16-byte auth tag) -
services/canopy-persons/migrations/20260326000000_create_persons_tables.sql—ssn_encrypted BYTEAcolumn -
Key source:
CANOPY_ENCRYPTION_KEYenvironment variable (base64-encoded 32-byte key)
On read, SSN is decrypted only for callers with Claims::require_caseworker_or_above(). The masked form (*--6789) is used in all other contexts.
FTI Flow — Federal Tax Information Isolation
FTI is used by TANF and Medicaid for income verification. Per IRS Publication 1075, FTI must never leave authorized program databases.
Isolation enforcement:
-
crates/canopy-mq/src/publisher.rs—RESTRICTED_FIELDSarray (27 field names: SSN, FTI, IEVS, HIPAA, PII, immigration data) validated before every publish -
crates/canopy-db/src/lib.rs—validate_database_name()warns if a service connects to the wrong database -
services/canopy-tanf/migrations/20260325000001_create_fti_audit_log.sql— Independent FTI audit trail (accessed_by, accessed_at, purpose_code, data_elements_accessed, originating_system) -
canopy-medicaid has identical isolation requirements (see
services/canopy-medicaid/migrations/COMPLIANCE.md) — FTI audit logging is implemented (Complete) viaservices/canopy-medicaid/migrations/20260326000001_create_fti_audit_log.sql(independent FTI audit trail) andservices/canopy-medicaid/migrations/20260425000000_add_fti_audit_hash_chain.sql(ADR-014 SHA-256previous_hash/event_hashchain). canopy-tanf carries the equivalent migrations.
IEVS Flow — Income Verification Data
IEVS match results are stored exclusively in the canopy-snap database per 7 USC §2025(e). The verification service is transient — it queries external sources but does not persist IEVS data.
Key files:
-
services/canopy-snap/src/verification.rs—run_verification()orchestrates per-member IEVS queries -
services/canopy-snap/src/verification_client.rs— HTTP client forPOST /internal/v1/ievs/match -
services/canopy-verification/src/api/ievs.rs—handle_ievs_match()dispatches to 4 data sources -
services/canopy-verification/src/noop.rs—NoopIevsAdapterreturns deterministic test data for UAT -
services/canopy-snap/migrations/20260330000000_create_ievs_tables.sql—ievs_match_resultsandievs_discrepanciestables (canopy-snap DB only) -
Discrepancy threshold loaded from
jurisdiction.toml [snap.ievs_discrepancy_threshold](default: $100/month) -
services/canopy-snap/src/events.rs— Events carry IDs and status only, never PII, income, or IEVS data
Determination Signing Flow (ADR-002)
Every eligibility determination is signed with ECDSA P-256 (detached JWS per RFC 7515). The orchestrator independently verifies each signature. Unverified determinations are quarantined.
Key files:
-
services/canopy-snap/src/determine.rs:295— Signs determination payload -
crates/canopy-signing/src/signer.rs—SigningKey::sign_detached()(ECDSA P-256) -
crates/canopy-signing/src/verifier.rs—VerifyingKeyRegistrywith dual-key rotation support -
services/canopy-eligibility/src/orchestrator.rs:345— Signature verification, quarantine logic -
services/canopy-eligibility/migrations/20260326000000_create_eligibility_tables.sql—program_determinationstable withsignature TEXT NOT NULLandsignature_verified BOOLEAN
Encryption Inventory
| Data | Algorithm | Where | Status |
|---|---|---|---|
SSN |
AES-256-GCM (application-layer, per-field) |
canopy-persons, canopy-snap |
✓ Implemented |
Determinations |
ECDSA P-256 JWS (signing, not encryption) |
Per program service → canopy-eligibility verifies |
✓ Implemented |
Audit chain |
SHA-256 hash chain (integrity, not encryption) |
canopy-security |
✓ Implemented |
HTTP traffic |
TLS 1.2+ via rustls (OpenSSL banned) |
All services |
✓ Implemented |
Database connections |
TLS via |
All PostgreSQL connections |
✓ Configured |
Message bus |
TLS via |
All RabbitMQ connections |
✓ Configured |
All PII at rest (beyond SSN) |
PostgreSQL Transparent Data Encryption |
Production database servers |
Recommended — no code change required |
Security Configuration Reference
The runbooks above describe how to respond; this section is the canonical reference for how Canopy’s security posture is configured. The universal posture (Kerckhoffs’s principle, public-visibility enforcement) is governed by the synced baseline at docs/modules/standards/pages/security-baseline.adoc and is non-negotiable across all projects — Canopy adds the project-specific configuration below.
Authentication
-
Keycloak OIDC with RS256 JWT (any RFC 6749 + OIDC-discovery-compliant provider works via config — see IdP Integration).
-
JWKS auto-refreshes hourly; force-refresh on unknown
kid(30-second debounce) — see the Keycloak JWKS Keys runbook above. -
Split issuer / fetch URLs are supported for Docker deployments (the in-cluster JWKS fetch URL can differ from the public token issuer).
Authorization
Role-based access control. Role guards live in the canopy-auth crate. Roles:
-
applicant -
caseworker -
eligibility_specialist -
supervisor -
quality_control -
admin
Handlers enforce least privilege (e.g. Medicaid handlers require eligibility_specialist_or_above; SSN decryption requires Claims::require_caseworker_or_above()).
Federal Data Isolation (ADR-004)
Legally-scoped data tenancy isolates restricted federal data to the program services authorized to hold it (the per-data-type flows are diagrammed under Data Flow Diagrams below):
-
FTI isolated to canopy-tanf and canopy-medicaid, each with an independent audit log.
-
IEVS data isolated to canopy-snap (7 USC §2025(e)).
-
SSA SOLQ/BINDEX scoped per Computer Matching Agreement.
-
FDSH data isolated to canopy-medicaid.
-
No restricted data in event-bus payloads.
CI enforcement: cargo xtask compliance audit-data-tenancy (job compliance-data-tenancy) scans every service’s migrations and source for protected field-name patterns and fails the build if FTI, IEVS, or SSA SOLQ/BINDEX fields surface in an unauthorised service. The authorisation matrix lives at compliance/data-tenancy-authorisation.toml — one section per data class (fti / ievs / ssa_solq_bindex), each listing authorised services and the field-name patterns the scanner flags. Exceptions go through the block with a written justification.
Audit Posture
-
canopy-security subscribes to all events via the wildcard (
#) routing key for system-wide audit (see Archive Management above for retention and the hash-chain integrity model). -
FTI audit logs are maintained separately per IRS Pub 1075 (the
fti_audit_logtable in canopy-tanf and canopy-medicaid) and must be available for IRS on-site inspection independently of the general audit store.
Session Management
BFF services use PostgreSQL-backed sessions via tower-sessions-sqlx-store (with a Redis LRU cache). MemoryStore is banned (ADR-009).
Secret Management
-
Secrets are read from environment variables (
CANOPY_{SERVICE}__*) — never hardcode secrets in source or checked-in config. -
.env.examplecarries dummy/placeholder values and is checked into version control;.envis `.gitignore`d and never committed. -
At-rest secrets are SOPS-encrypted YAML (
secrets/dev.yaml, fake values only) per ADR-017; CI/CD secrets are stored as GitLab CI/CD variables (masked, protected).
Dependency Policy
-
OpenSSL is banned in
deny.toml— rustls only. Containers are Alpine with musl. -
cargo auditruns in CI as a blocking job;cargo denychecks license compatibility (AGPL-3.0-or-later allowlist) and known advisories. -
CVE response timeline:
Severity Patch SLA Critical
Within 24 hours
High
Within 1 week
Medium
Within 1 month
Low
Next scheduled dependency update
Determination Signing (ADR-002)
ECDSA P-256 detached JWS for all eligibility determinations (signing config; rotation runbook is above, the end-to-end flow is diagrammed under Data Flow Diagrams):
-
Crate:
crates/canopy-signing/—SigningKey,VerifyingKey,VerifyingKeyRegistry. -
Trait:
DeterminationSignerincrates/canopy-signing/src/traits.rs— unified trait for all program services. -
Key generation:
cargo xtask gen-signing-keys --program snapgenerates PEM key pairs under.keys/. -
JWS format: detached (RFC 7515 Appendix F) —
header..signaturewith the payload omitted from the token. -
Key format: PKCS#8 PEM (private), SPKI PEM (public).
-
Tamper detection: determinations with invalid or missing signatures are rejected (quarantined) by the canopy-eligibility orchestrator before assembling combined results.
Disqualification Screenings (7 CFR 273.11)
SNAP-specific disqualification screenings are stored in the snap_disqualification_screenings table (canopy-snap):
-
Drug felony (7 CFR 273.11(m)): jurisdiction policy in
jurisdiction.toml— Georgia usestrafficking_only(partial opt-out per Georgia Code §49-4-186). Federal cutoff date: 8/22/1996. -
Fleeing felon / probation violator (7 CFR 273.11(n)): categorical disqualification, no state variation; self-attested during application intake.
-
Striker (7 CFR 273.11(e)): pre-strike income comparison test — the household is eligible only if it would have been eligible using pre-strike income.
-
Exemption tracking:
exemption_reasonfield with a worker audit trail (screened_byUUID).
Content Security Policy (BFF)
canopy-web and canopy-portal serve HTML and must apply a strict CSP:
default-src 'self'; script-src 'self'; style-src 'self';
img-src 'self' data:; frame-ancestors 'none'; form-action 'self'
Non-negotiable prohibitions:
-
No
'unsafe-inline'(onscript-srcorstyle-src). -
No
'unsafe-eval'. -
No inline
<script>or<style>blocks in templates. -
No inline
style=""attributes — use utility classes incanopy-web.css. -
No inline event handlers (
onclick="",onsubmit="",@click=""with inline expressions,hx-on::*).
Implementation rules:
-
All JavaScript in external files under
services/canopy-web/static/js/. -
All CSS in external files under
services/canopy-web/static/css/. -
Dynamic theme variables served from the
/theme.cssendpoint. -
Alpine.js: use the CSP build (
@alpinejs/csp) withAlpine.data(name, …)components — never inline expressions. -
htmx: set
includeIndicatorStyles: falsevia<meta name="htmx-config">so it will not inject<style>at runtime. -
Event handlers wired via
addEventListenerin the external JS bundle, delegated where possible.
Verification: the axe-core WCAG 2.1 AA audit in Playwright E2E must pass with zero critical violations.