canopy-security API Reference

On this page

Overview

Cross-link: canopy-security Data Model (#419)

Central audit and security service. Subscribes to ALL events via RabbitMQ wildcard (#) routing key, persists them with SHA-256 hash chain integrity, runs background breach detection, and provides APIs for audit review and compliance reporting.

Base URL

http://localhost:8012/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

admin

Swagger UI

http://localhost:8012/swagger-ui

Database

canopy_security

Receiver contract (OIDC S-security, #1427 / ADR-043 §C)

canopy-security is the third service on the ADR-043 receiver contract (canopy_auth::ReceiverContract) — user-only enforcement only. It is not a program service, so there is no hop-2 exchanged route: audit ingest and the signing-key registry stay genuine service-to-service require_service_caller, and the 14 dual surfaces (is_service() || admin) are unchanged behind the exchanged_gate middleware. See the tanf API page for the full bearer-shape and guard-family description.

  • require_user_only(["admin"]) on POST /v1/security/archive — the decision-10 "service tokens are rejected" posture, now mechanical.

  • require_user_only(["admin", "quality_control"]) on GET /v1/export/audit-events — a bulk PII egress with no service arm.

  • Under CANOPY_SECURITY__ENFORCE_USER_ONLY_ROUTES=true only an exchanged per-target token (aud=canopy-security exactly, allowlisted azp) carrying the named role passes these two routes; a legacy broad-audience worker bearer is 403 aud_not_exact.

  • Attribution via EffectiveUser on the archive requested_by and the export self-audit actor (exchanged bearers preserve sub; the X-Canopy-Actor header is retired fleet-wide — #1443).

Receiver knobs are documented in the configuration reference.

Audit Events

GET /v1/security/events

List audit events with filtering and pagination.

Query parameters:

Parameter Type Description

limit

integer

Page size (default: 50)

offset

integer

Offset for pagination

source_service

string

Filter by originating service (e.g., canopy-snap)

event_type

string

Filter by event type (e.g., determination.completed.snap)

action

string

Filter by action

household_id

uuid

Filter audit events to a single household. Required by the worker portal Audit section (Plan worker-intake-program-independence MR5a/MR5b), backed by the audit_events_household_idx partial index.

programs

string[] (repeated)

Program-scope visibility filter (#1519, ADR-044). Repeated programs=snap&programs=tanf; storage slugs (snap tanf medicaid chip caps wic); an unknown slug is a 422. Empty/absent = no scoping (the pre-#1519 contract for service callers). Non-empty = rows whose programs is asserted-neutral ('{}') or overlaps the filter; rows with NO assertion (NULL, pre-#1519 history) are invisible to any scoped caller — fail-closed.

Response (200): Array of AuditEvent objects.

NOTE
Hot-only, designated (#1208 read-contract seam, P2). This list serves the live audit_events table only — rows older than the operator-set archive age threshold have moved to audit_events_archive and do not appear here. History contracts ride the by-id lookup (archive ∪ live), the FOIA export union (GET /v1/export/audit-events), the fact-history endpoint, and the keyset GET /v1/security/archive list.

GET /v1/security/events/{id}

Get a single audit event by ID. Reads archive ∪ live (#1208): the live PK probe first, else the archive twin — an archived row keeps resolving.

Response (200): AuditEvent object.

Response (404): Event not found.

GET /v1/security/persons/{person_id}/fact-history/{resource}

The scoped, transaction-time change-history of one person’s eligibility facts (epic &56 / T1-6, ADR-027 §4). Projects the append-only audit_events ledger to the attributed *.claimed / income.closed events canopy-persons emits (T1-5), filtered to one person and one resource kind, ordered by the hash-chain insert time (created_at, strictly increasing) — oldest first. One resource returns a fact type’s full history (claims, corrections, and the close); the entry action distinguishes them.

Path parameters:

Parameter Type Description

person_id

uuid

The person whose fact history is queried (matched against the person_id the event payload carries; the audit_events.household_id column is NULL for these person-scoped fact events).

resource

enum

One of income, asset, expense. Any other value returns 400.

Response (200): Array of FactChangeEntry objects, each carrying person_id, fact_id, version_id (claim only), action (claim / close), actor_sub / actor_role (the authoring worker/applicant; null for a close — the ADR-019 on-behalf-of limitation), claim_source, claim_status, the opaque per-kind before (superseded windows) and after (new value; null for a close), recorded_at (the audit row’s transaction-time), and the opaque event_hash. Since T2-6 MR9 (#687, ADR-036) the PII money leaves inside before/after are sealed (a SealedValue envelope — canopy-security never holds the DEK), so this endpoint surfaces ciphertext for them and the worker UI renders a (sealed) marker; the structural discriminators (*_type, frequency) stay plaintext. The actual figure is read from the system-of-record (canopy-persons) — see #920.

Response (400): Unknown resource. 403: caller is neither service-class nor admin.

Minimum role: service-class token or admin (the same house auth as the rest of the service). Worker (caseworker) scoping is enforced at the canopy-web BFF — which gates on the worker’s case access + program scope, resolves the household’s members, and fans out here per member to compose the household view — because canopy-security cannot see the worker identity until the ADR-019 plumbing lands. canopy-security stays a self-contained leaf (ADR-001): it calls no other service.

NOTE
Since T2-5 (#686, ADR-014 Amendment 1) the change-history is cryptographically tamper-evident: the v2 audit_events hash covers the actor (user_id/user_role + metadata.author), action, resource, source_service, household_id, and a content-hash of metadata (the before/after). Verification is server-side (verify_chain recomputes each row with the single chain formula; T2-6 #687 dropped the legacy v1 formula + the per-row hash_version); the event_hash returned here stays an opaque integrity token — it is not independently recomputable from this DTO (which omits previous_hash/event_id/ timestamp), so a client treats it as a correlation handle, not a recompute input.

POST /v1/security/audit/ingest

HTTP audit ingress for broker-less services (Plan 3 MR5a, ADR-026). canopy-security normally ingests audit events via the RabbitMQ wildcard subscriber, but the applicant portal (canopy-portal) is Postgres- and RabbitMQ-free, so its session security events (mint / revoke / kill-switch) post here instead. The server mints the event_id and chains the row through the same insert_audit_event path the subscriber uses (ADR-014) — an HTTP-ingested event is indistinguishable from a broker-ingested one in the chain.

Minimum role: service-class, or the scoped portal credential (portal:audit:write, #1441 — the portal’s session security events arrive citizen-class; the exchange-audit sinks keep their service arm). Any other principal returns 403.

Callers post this best-effort / fire-and-forget — a canopy-security outage must never break the applicant flow, so the portal logs and drops failures rather than retrying them into the request path.

Request: AuditEventIngestRequest

{
  "event_type": "applicant.session.minted",
  "source_service": "canopy-portal",
  "action": "created",
  "resource_type": "applicant_session",
  "resource_id": null,
  "user_id": null,
  "user_role": null,
  "ip_address": null,
  "household_id": null,
  "metadata": { "flow_kind": "resume" },
  "event_timestamp": null,
  "programs": []
}

event_timestamp defaults to the ingest time and metadata to {} when omitted. household_id is parsed to a UUID for the typed audit_events.household_id column; a non-UUID drops to NULL (it never breaks the hash chain, which hashes content + timestamp).

programs (#1519) is the publisher’s program-scope assertion for the row’s audit_events.programs column: [] = asserted program-neutral (visible to every scoped worker view), ["snap", …] = the named storage slugs, omitted/null = no assertion — the store then derives from the event-type routing key or the curated neutral families, else stores NULL (invisible to scoped worker views; see the worker-portal audit model in authorization-inventory.adoc). An assertion containing an unknown slug is rejected as a whole — the row stores NULL, with no fall-through to derivation (a malformed assertion must not be partially honored or silently re-derived). Like household_id, the column is OUTSIDE the frozen v1 chain hash (dedup_key precedent — the 20261128000000 migration header records the posture).

The endpoint is mode-split (#1207, ADR-014 Amendment 7; dormant until the #1279 cutover flips CANOPY_SECURITY__CHAIN_V2_APPEND_ENABLED):

  • v1 (flag off, today’s production): Response (202): accepted and synchronously chained (no body). 403: caller is not service-class.

  • chain-v2 (flag on): 202 is REDEFINED as accepted + durably staged — the row is idempotently staged (chain_append_staging) and the per-shard drainer chains it asynchronously. The body carries the server-minted receipt {"event_id": "<uuid>"} — the event’s permanent replay identity. A client retry WITHOUT its own idempotency key mints a NEW event (intentional: replay identity belongs to the server-minted envelope id, not the request body). 422: metadata carries an integer outside the RFC 8785-safe range (±(2^53 − 1)), or — since #1205 MR-2 — the raw body fails the JSON number fence (#1285, plan D14: a number token whose decimal value differs from its f64 round-trip, the collision class hash recomputation cannot see) — the client-traceable I-JSON classes; any other build failure is a redacted 500. 503 (fixed detail audit staging at capacity): the staging admission cap is reached — retry later.

The auth.token_exchange stream (#1424, OIDC A1)

The RFC 8693 exchange audit stream (ADR-023 Decision 6; purposes frozen by ADR-043 A1) rides this endpoint with three contract additions, all scoped to event_type = "auth.token_exchange" and inert for every other type:

  • Frozen purpose vocabulary (422). metadata.purpose must deserialize to worker_request | orchestrator_fanout (canopy_contracts_security::events::TokenExchangePurpose). Anything else — including the retired ADR-023 citizen_upload / background_job codes (neither path exchanges, ADR-043 A1) — is refused with a 422 naming the frozen vocabulary. The MQ wildcard arm enforces the same gate (a violating delivery nacks to the DLQ). Both v1 and chain-v2 ingest arms gate BEFORE the mode split.

  • Idempotency by exchange jti (server-derived). canopy-security derives dedup_key = "auth.token_exchange:" + metadata.jti itself (event_parsing::derive_dedup_key — the emitter cannot forget or drift the key) and the v1 chain insert is ON CONFLICT DO NOTHING against the partial unique index audit_events_dedup_key_uq. A replayed grant — the fail-closed broker sink retrying a committed-but-unacknowledged POST — answers 202 and chains exactly one row. Denials carry no jti (no token was minted) and are deliberately non-idempotent. The dedup_key column is deliberately OUTSIDE the frozen chain-hash input set (changing AuditChainInputs would break verification of every existing row); the jti’s tamper evidence rides the hashed metadata. The uniqueness horizon is the live table — the archive mover does not carry the column, which is weeks of horizon against a sink retry window of seconds.

  • chain-v2 degradation (tracked). The dormant staging arm dedups only on the server-minted event_id; until #1498 lands (blocks the #1279 cutover) a sink retry under chain-v2 can double-stage — logged loudly at ingest, and benign in the double-audit direction only.

The typed metadata shape is canopy_contracts_security::events::TokenExchangeAuditMetadata — identifiers and enum codes only ({outcome, reason?, exchanger_client_id, audience, granted_scope?, purpose, exp?, jti?, subject_jti?, correlation_id?}); the emitting sink (canopy_auth::ChainAuditSink) sets the structured row fields to action = granted|denied, resource_type = auth, resource_id = the target audience, user_id = the subject (the broker’s (unvalidated) sentinel for pre-validation denials, recorded honestly).

Breach Alerts

GET /v1/security/alerts

List breach detection alerts.

Query parameters: limit, offset, status

Response (200): Array of BreachAlert objects.

GET /v1/security/alerts/{id}

Get a single alert.

Response (200): BreachAlert object.

Response (404): Alert not found.

PATCH /v1/security/alerts/{id}

Update alert status (acknowledge, investigate, resolve).

Request: UpdateAlertStatus

{
  "status": "investigating",
  "resolved_by": "auditor@example.gov"
}

status is required; resolved_by is optional.

Response (200): updated BreachAlert object.

Response (404): Alert not found.

NIST Controls

GET /v1/security/nist-controls

List all NIST SP 800-53 control mappings tracked by the system.

Response (200): Array of NistControlMapping objects with control ID, description, and implementation status.

Integrity and Summary

GET /v1/security/summary

Get aggregate audit statistics (per-service action counts) over a trailing time window.

Query parameters: days (optional) — trailing window over created_at; defaults to 30, clamped to a 366-day cap. The window is mandatory server-side (#1229): there is no unbounded full-table aggregate path, and the query rides the idx_audit_events_created_at_id range with a statement timeout so an abandoned client cannot leave the aggregate running on the audit-ingest DB.

Response (200): Array of AuditSummaryRow objects (source_service, action, count) for events in the window.

NOTE
Hot-only, designated (#1208 read-contract seam). The aggregate counts live audit_events rows only — a window wider than the operator-set archive age threshold undercounts by design (archived rows are excluded).

Chain Verification (/v1/security/chain/*)

The unified chain-verification namespace (#1205, ADR-014 Amendment 9; plan chain-v2 verifiers D8). ONE namespace replaces the historical scatter, pre-1.0 with zero compat: GET /v1/security/verify-chain and POST /v1/security/fti/chain-verify were deleted in #1205 MR-2 (the typed ChainVerificationResponse contract died with them; every consumer — worker portal, CLI, test-lib, citation template — migrated in the same MR), and GET /v1/security/fti/chain-status — the last survivor of the old FTI scatter — is deleted in #1206 MR-3 (its FtiChainVerification wire DTO and ChainStatusInterim.last_verification evidence field die with it; the OpenAPI path count settled at 16 until the #1208 archive-run poll endpoint made it 17). The FTI (Pub 1075) chains of canopy-tanf and canopy-medicaid are served by the SAME four endpoints below via family=fti&service=….

Auth (all four endpoints): the existing service-or-admin arm — a service-class token passes; any other principal must carry admin (403 otherwise). Job polling is additionally requester-scoped (below).

Dormancy (#1279): the background verifiers run only when CANOPY_SECURITY__CHAIN_V2_VERIFY_ENABLED=true (default false, dormant until the #1279 cutover provisions the LOGIN carriers + verify-pool URLs
the flag — Configuration Reference). Each family is dormant or live independently: the audit family follows the flag + its verify-pool URL, and each FTI family (#1206 MR-3) follows the flag + its own CHAIN_VERIFY_TANF_DATABASE_URL / MEDICAID — one program DB outage degrades that family only. While a family is dormant: GET …/chain/status reports unknown with reason verifier_disabled → 503 (the #1245 fail-closed posture, preserved by status code), and POST …/chain/verify + GET …/chain/attest return 503 verifier_unavailable (no job is ever queued for an unconfigured target). One exception outranks dormancy on the fti arm: a latched legacy v1 breach row forces state: "breached" with reason legacy_breach_latched (X8 — the #1245 "a breach is never silently swallowed" posture, Pub 1075 §9 reportable; retires with the fti_chain_verifications table drop at #1279).

Wire DTOs: crates/canopy-contracts-security/src/chain.rs — every closed vocabulary is a real enum with its wire strings test-pinned. CLI parity (ADR-007): canopy security chain-status / chain-verify / chain-attest.

GET /v1/security/chain/status

C6 verification status for one family target (ADR-014 Amendment 8; the derivation precedence is plan D6).

Query parameters:

Parameter Type Description

family

enum

audit or fti. Required.

service

enum

FTI service (canopy-tanf or canopy-medicaid). Required exactly when family=fti; rejected for family=audit.

Response (200 or 503): ChainStatusResponsethe SAME typed body on both codes. HTTP mapping: healthy/verifying → 200; unknown/stale/error/breached → 503, so a consumer reading only the status code fails closed while a typed consumer still gets the full picture. The body carries state (the six-state machine), reasons[] (every firing derivation input — the closed StatusReason vocabulary), epoch, per-shard coverage (shards[]: tail verified-through / head / lag / stamps + scrub cursor / target / cycle stamps), backlog (audit family only — staged / parked / inbox-parked / DLQ depth; null for fti, typed applicability), trusted_manifest (the verifier-checked anchor: id / seq / age), and — on breached — the oldest unresolved incident_id plus its typed breached_position.

POST /v1/security/chain/verify

Enqueue a durable manual verification job — never a synchronous walk (the #1245 OOM class stays dead). Jobs are token-claimed, target-scoped, and crash-safe: the work definition (instance / epoch / per-shard targets) is captured once at first claim, a reclaim resumes the SAME vector, and coverage is all-or-nothing. ONE active job per target (family, or family + service); manual runs never feed the status machine in either direction.

Request: ChainVerifyRequest

{"family": "audit", "loop": "family-full"}

loop{tail, scrub, family-full}, default family-full (tail + scrub
census + manifest). service is required exactly for family=fti. incident_id (optional) marks a revalidation job for the incident-resolution runbook (Security Operations): it bypasses the family halt gate and must run the incident’s DETECTED loop (or family-full).

Response (202): ChainVerifyJobAccepted{job_id, poll_url}.

Response (409): verification_in_progress — an active job already exists for the target; the body carries its job_id (idempotent — the caller learns the in-flight id, no duplicate work).

Response (503): verifier_unavailable — queue at CHAIN_JOB_MAX_QUEUED, verifier disabled, or family unconfigured.

GET /v1/security/chain/verify-jobs/{id}

Poll one verify job. Requester-scoped: service callers see only their own jobs; admin sees all; an unknown or foreign id returns 404 (existence is not disclosed across requesters).

Response (200): ChainVerifyJobStatusstate{queued, running, done, error}, requested_loop, attempts (reclaims increment), the finalizing run summary once done, and error_code{coverage_incomplete, verifier_error, integrity_rejected, crashed} once error.

GET /v1/security/chain/attest

Per-event verification attestation — the citation-PDF input ("Cite for hearing" refuses to issue without it).

Query parameters: event_id (uuid, required), family, service (same rules as status).

Response (200): ChainAttestation. attested: true iff the position is found (view-mediated, indexed, across archive ∪ live), the row’s (instance, epoch) match the ACTIVE topology, seq sits within BOTH the shard’s tail verified_through AND the trusted (verifier-checked) manifest tip, and the family state is attestable (healthy/verifying). Otherwise attested: false with reason{unknown_event, newer_than_checkpoint, beyond_trusted_manifest, state_not_attestable, verifier_unavailable, foreign_topology}.

Request-error matrix

The closed request-error vocabulary (plan D8 — each row is a named test; this table is the doc source of truth):

Condition Status error code

family missing or not in {audit, fti}

400

invalid_family

family=fti without service

400

missing_service

family=audit WITH service

400

unexpected_service

service not in {canopy-tanf, canopy-medicaid}

400

invalid_service

event_id missing/malformed (attest)

400

invalid_event_id

loop not in {tail, scrub, family-full} (verify)

400

invalid_loop

incident_id unknown (verify)

404

unknown_incident

active job exists for the target (verify)

409

verification_in_progress

queue at CHAIN_JOB_MAX_QUEUED / verifier disabled / family unconfigured

503

verifier_unavailable

Archive Management

The async, durable archival protocol (#1208, plan audit-archive-async) — the chain-verify-jobs pattern applied to archival. It replaced the #1245 interim 503 (the unbounded one-transaction mover was deleted for its OOM hazard, genesis-breaking boundary, and ON CONFLICT DO NOTHING silent loss; this protocol is its structural fix: per-chunk committed transactions, an inserted == deleted loss-proof assertion, and a chain-head exclusion guard). A durable audit_archive_runs row is the unit of work AND the accountability record; the always-spawned in-service runner services it asynchronously. Operator procedures: Security Operations › Archive Management.

POST /v1/security/archive

Enqueue a durable archive run — never executes inline. The run moves rows whose received_at is older than the requested age threshold (cutoff computed by the database clock) from audit_events to audit_events_archive in bounded, per-chunk-committed transactions, always retaining the chain-head row.

Minimum role: admin only, USER-ONLY (#1427): service-class tokens are always 403 (service_class_on_user_only), and under enforcement the bearer must be an exchanged per-target token carrying admin. The run row records requested_by = admin:{sub} via EffectiveUser (scheduled runs record scheduler), so the requester must be a person.

Request: ArchiveRequest

{"archive_after_days": 2555}

archive_after_days is an age threshold, not a retention value — the archive retains rows indefinitely, so archiving early shortens nothing (retention policy — floors, legal hold, purge — is #1303). Sanity domain 1..=36500; outside it → 400.

Response (202): ArchiveRunAccepted{run_id, poll_url}; the Location header carries the poll URL.

Response (400): archive_after_days outside 1..=36500.

Response (403): caller lacks admin role (service tokens included).

Response (409): an archive run is already queued or running — ONE active run total. The body carries that run’s ArchiveRunAccepted handle (no Location header): the caller learns the in-flight id, no duplicate work.

Response (503): database pool exhausted — retry later.

Idempotency: the run row is durable, so a replay under the same Idempotency-Key returns the stored handle (no transient-409 class exists). Use a fresh key per continuation run — e.g. when re-POSTing after a more: true outcome to drain a backlog.

GET /v1/security/archive-runs/{id}

Poll one archive run. Minimum role: service-class token or admin.

Response (200): ArchiveRunStatus — the durable run row projected onto the wire: run_id, state{queued, running, done, error}, requested_by (admin:{sub} or scheduler), requested_at, the frozen config snapshot (archive_after_days, chunk_size, max_chunks_per_pass), attempts (lease-expiry reclaims increment; the ladder finalizes error/crashed with progress intact), the per-chunk committed progress (chunks_committed, rows_archived — durable and pollable mid-run and after a crash), more, error_code/error_detail once error, and started_at/finished_at.

error_code is the closed set {duplicate_overlap, upgrade_state_unrepaired, statement_timeout, db_error, crashed} — each maps to a runbook procedure (Security Operations).

more semantics (set at done): the pass ended on a full chunk, so more movable rows may remain (this coexists with the retained chain head — more is about the chunk budget, not the head). more: true also pulls the scheduler’s next due time forward to the catch-up cadence, so backlogs drain without operator action.

Response (404): unknown archive run.

GET /v1/security/archive

One keyset page of archived audit events, newest first.

Query parameters:

Parameter Type Description

limit

integer

Page size, domain 1..=500 (default 50); outside → 400.

before_received_at

date-time

Keyset cursor half — pair with before_id, both-or-neither (a lone half → 400). The page is rows strictly older than the cursor.

before_id

uuid

The other keyset cursor half.

Response (200): Array of AuditEvent objects, ORDER BY received_at DESC, id DESC. Page 2 = pass the last row’s (received_at, id) as the cursor.

Response (400): limit out of domain, or a lone cursor half (validated before the query — negative paging can never reach PostgreSQL).

NOTE
The previously advertised filter parameters (offset, source_service, event_type, action, household_id) are removed (#1208 decision 14/P1) — they were advertised but ignored, and filters over an unboundedly-growing archive without per-filter indexes recreate the O(n) trap. Windowed historical needs ride the FOIA export union (GET /v1/export/audit-events).

Export

GET /v1/export/audit-events

User-only (admin or quality_control) bulk export of the audit log (#1427: service bearers 403; enforced routes require an exchanged per-target token).

Query parameters:

Parameter Type Description

from

date-time

Inclusive start of the export window (RFC 3339 / ISO 8601). Defaults to 24 hours before to if absent.

to

date-time

Exclusive end of the export window. Defaults to "now" if absent.

format

string

Output format: json (default) or csv. Equivalent to setting Accept: application/json or Accept: text/csv.

limit

integer

Row cap, default 10 000, hard cap 50 000.

programs

string[] (repeated)

Same program-scope visibility predicate as GET /v1/security/events (#1519), applied on BOTH arms of the archive ∪ live union. Unknown slug = 422; empty/absent = unscoped.

Response (200): Audit events within the requested window (JSON array of AuditEvent, or CSV per format). The export reads archive ∪ live (#1208): a UNION ALL over the twin tables with explicit columns, ordered event_timestamp ASC, id ASC — the window spans the archive seam without truncation.

Response (400): Invalid time window or limit.

Response (403): Not a user-only-admissible bearer — service-class token, or the caller lacks the admin/quality_control role.

Error Codes

Code Meaning

400

Invalid time window or limit (GET /v1/export/audit-events); the 400 rows of the chain Request-error matrix (invalid_family / missing_service / unexpected_service / invalid_service / invalid_event_id / invalid_loop); archive_after_days outside 1..=36500 (POST /v1/security/archive); archive-list limit out of 1..=500 or a lone keyset-cursor half (GET /v1/security/archive)

401

Missing or invalid JWT

403

Requires admin role (or a service-class token where the endpoint takes the service-or-admin arm; POST /v1/security/archive is admin-ONLY — service tokens are rejected)

404

Event or alert not found; unknown/foreign verify-job id (GET /v1/security/chain/verify-jobs/{id} — requester-scoped); unknown incident_id (POST /v1/security/chain/verify); unknown archive run (GET /v1/security/archive-runs/{id})

409

verification_in_progress — an active verify job already exists for the target (POST /v1/security/chain/verify); an archive run already queued/running (POST /v1/security/archive — the body carries the active run’s ArchiveRunAccepted handle)

503

ChainStatusResponse from GET /v1/security/chain/status on unknown/stale/error/breached (the SAME typed body as 200 — fail closed by status code; on the fti arm a latched legacy v1 breach forces breached with reason legacy_breach_latched, Pub 1075 §9 reportable, until #1279); verifier_unavailable from verify/attest while dormant, family unconfigured, or at the queue cap; database pool exhausted on POST /v1/security/archive (retry later)

Background Processing

canopy-security runs these background tasks:

  1. Event subscriber — wildcard RabbitMQ consumer that persists every event to audit_events with SHA-256 hash chain linking

  2. Breach detection — periodic scan (60-second cycle) checking for privilege escalation patterns, abnormal access volumes, and FTI access anomalies. Generates breach_alert records when thresholds are exceeded.

  3. chain-v2 verifiers (#1205 audit; #1206 MR-3 the FTI families — all dormant until #1279; each runs only when CANOPY_SECURITY__CHAIN_V2_VERIFY_ENABLED=true AND its family’s verify-pool URL is set) — one task per configured family (audit, fti/canopy-tanf, fti/canopy-medicaid), each ordered by its FAMILY lease: halt gate → manual verify jobs first → manifest check + structural census → per-shard tail + scrub loops under a global pass budget. Verify pools parse at boot and connect lazily; one program DB outage degrades that family only. Integrity findings latch incidents (the family halts until the incident-resolution runbook clears them); coverage feeds GET /v1/security/chain/status. Design: plan chain-v2 verifiers D3–D6.

  4. audit-archive runner (#1208) — always spawned (a dormant scheduler still services manual admin runs, else the 202 handle would lie), delayed first tick, non-gating audit-archive check in /readyz. Per tick: claim a queued (or expired-lease) audit_archive_runs row → preflights (upgrade-state + first-chunk duplicate overlap, each a typed refusal on the run row) → the chunk loop (≤ archive_max_chunks_per_pass atomic moves, each followed by a token-fenced progress heartbeat) → finalize. The scheduler arm is dormant unless CANOPY_SECURITY__ARCHIVE_SCHEDULER_ENABLED=true: when enabled, a transactional due-state row (audit_archive_schedule, Skip semantics — a week of downtime is ONE claim, no burst catch-up) enqueues scheduler runs at the archive_interval_secs cadence, and a more: true outcome pulls the next due time forward to archive_catchup_interval_secs so backlogs drain boundedly. GET /v1/security/events and GET /v1/security/summary are designated hot-only: rows older than the operator threshold live in the archive surfaces (by-id, export union, fact-history, archive GET).

Edit this page · default