Plan: Security Audit Subscriber

On this page

Status

Step Description Status

1

Database migration: audit_events, breach_alerts, nist_control_mappings tables

Done (2026-03-27)

2

Wildcard event subscriber (routing key #) wired into canopy-security

Done (2026-03-27)

3

Event parsing module: extract action, resource_type, user_id, resource_id from event payloads

Done (2026-03-27)

4

Persistence layer: store parsed audit events

Done (2026-03-27)

5

Breach detection rules engine

Done (2026-03-27)

6

NIST control mapping table

Done (2026-03-27)

7

Archive management for aging audit records

Done (2026-03-27)

8

API endpoints for audit log queries and breach alerts

Done (2026-03-27)

9

Tests

Done (2026-03-27)

Epic: Security Audit Subscriber
MR: !9
Branch: feature/security-audit-subscriber

Context

ADR-004 defines two audit logging streams in Canopy:

  1. FTI audit logs — maintained directly in canopy-tanf and canopy-medicaid, NOT via the event bus (see fti-audit-logging plan)

  2. System-wide audit log — maintained by canopy-security, which subscribes to ALL events on canopy.events via a wildcard routing key

This plan implements the second stream. canopy-security is the system-wide audit trail for all non-restricted operations. Every event published by any service to the canopy.events topic exchange is captured, parsed, stored, and available for audit queries.

The pattern follows CRAIG’s craig-security service closely. CRAIG’s security service subscribes to all events on craig.events using the # wildcard routing key, parses event payloads to extract structured audit fields, and stores them in a queryable audit log. Canopy’s implementation adds breach detection rules and NIST SP 800-53 control mapping.

The canopy-mq subscriber infrastructure already supports this pattern — the Subscriber::subscribe method accepts arbitrary routing keys, including #.

Scope

In scope:

  • Wildcard subscription to canopy.events with routing key #

  • Event parsing: extract action, resource_type, user_id, resource_id from heterogeneous event payloads

  • Audit event persistence with full-text search capability

  • Breach detection rules: failed_auth, bulk_access, privilege_escalation, after_hours_access

  • NIST SP 800-53 control mapping table

  • Archive management for records older than configurable retention period

  • Query API for security operations and auditors

Out of scope:

  • FTI audit logs — handled by the fti-audit-logging plan; canopy-security does NOT receive FTI

  • Real-time alerting (email, SMS, PagerDuty) — future plan; this plan detects and stores breach alerts

  • SIEM integration — future plan; this plan provides the data source

  • Identity and access management — handled by Keycloak and canopy-auth

Design

Data Model

-- services/canopy-security/migrations/20260326000000_create_security_tables.sql

-- Every event captured from canopy.events, parsed into structured audit fields.
CREATE TABLE audit_events (
    id UUID PRIMARY KEY,
    event_id UUID NOT NULL,             -- original EventEnvelope.id
    event_type TEXT NOT NULL,           -- e.g., "determination.completed"
    source_service TEXT NOT NULL,       -- e.g., "canopy-eligibility"
    action TEXT NOT NULL,               -- e.g., "create", "read", "update", "delete", "determine"
    resource_type TEXT NOT NULL,        -- e.g., "person", "household", "determination"
    resource_id TEXT,                   -- ID of the affected resource (may be null for bulk ops)
    user_id TEXT,                       -- user who triggered the action (from JWT claims in payload)
    user_role TEXT,                     -- role of the user at time of action
    ip_address TEXT,                    -- source IP if available in payload
    metadata JSONB NOT NULL DEFAULT '{}',  -- additional parsed fields
    event_timestamp TIMESTAMPTZ NOT NULL,  -- original EventEnvelope.timestamp
    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Breach detection alerts generated by detection rules.
CREATE TABLE breach_alerts (
    id UUID PRIMARY KEY,
    rule_name TEXT NOT NULL,            -- e.g., "failed_auth", "bulk_access"
    severity TEXT NOT NULL,             -- critical, high, medium, low
    description TEXT NOT NULL,
    user_id TEXT,                       -- user involved, if applicable
    source_service TEXT,
    evidence JSONB NOT NULL DEFAULT '{}',  -- event IDs and details that triggered the alert
    status TEXT NOT NULL DEFAULT 'open',   -- open, investigating, resolved, false_positive
    resolved_by TEXT,
    resolved_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- NIST SP 800-53 control mapping.
-- Maps Canopy audit events to NIST controls for compliance reporting.
CREATE TABLE nist_control_mappings (
    id UUID PRIMARY KEY,
    control_id TEXT NOT NULL,           -- e.g., "AU-2", "AC-6", "SI-4"
    control_name TEXT NOT NULL,         -- e.g., "Audit Events"
    control_family TEXT NOT NULL,       -- e.g., "Audit and Accountability"
    event_types TEXT[] NOT NULL,        -- event types that satisfy this control
    description TEXT NOT NULL,
    implementation_status TEXT NOT NULL DEFAULT 'planned',  -- planned, partial, implemented
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Detection rules table (configurable, not hardcoded).
CREATE TABLE detection_rules (
    id UUID PRIMARY KEY,
    rule_name TEXT NOT NULL UNIQUE,
    rule_type TEXT NOT NULL,            -- failed_auth, bulk_access, privilege_escalation, after_hours_access
    threshold INTEGER NOT NULL,
    window_minutes INTEGER NOT NULL,
    severity TEXT NOT NULL,             -- low, medium, high, critical
    enabled BOOLEAN NOT NULL DEFAULT true,
    active BOOLEAN NOT NULL DEFAULT true,
    notify_webhook TEXT,                -- optional webhook URL for alert notifications
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Archive table for aged audit events.
CREATE TABLE audit_events_archive (
    LIKE audit_events INCLUDING ALL
);

-- Indexes
CREATE INDEX idx_audit_events_event_type ON audit_events(event_type);
CREATE INDEX idx_audit_events_source_service ON audit_events(source_service);
CREATE INDEX idx_audit_events_action ON audit_events(action);
CREATE INDEX idx_audit_events_user_id ON audit_events(user_id);
CREATE INDEX idx_audit_events_resource_type ON audit_events(resource_type);
CREATE INDEX idx_audit_events_event_timestamp ON audit_events(event_timestamp);
CREATE INDEX idx_audit_events_metadata ON audit_events USING GIN(metadata);
CREATE INDEX idx_breach_alerts_rule_name ON breach_alerts(rule_name);
CREATE INDEX idx_breach_alerts_status ON breach_alerts(status);
CREATE INDEX idx_breach_alerts_severity ON breach_alerts(severity);
CREATE INDEX idx_nist_control_mappings_control_id ON nist_control_mappings(control_id);
CREATE INDEX idx_detection_rules_rule_type ON detection_rules(rule_type);

Seed Data: NIST Control Mappings

-- services/canopy-security/migrations/20260326000001_seed_nist_controls.sql

INSERT INTO nist_control_mappings (id, control_id, control_name, control_family, event_types, description, implementation_status) VALUES
(gen_random_uuid(), 'AU-2',  'Audit Events',                    'Audit and Accountability', ARRAY['*'], 'All events captured via wildcard subscriber', 'implemented'),
(gen_random_uuid(), 'AU-3',  'Content of Audit Records',        'Audit and Accountability', ARRAY['*'], 'Event parsing extracts action, resource, user, timestamp', 'implemented'),
(gen_random_uuid(), 'AU-6',  'Audit Review, Analysis, Reporting','Audit and Accountability', ARRAY['*'], 'Breach detection rules analyze audit stream', 'implemented'),
(gen_random_uuid(), 'AU-9',  'Protection of Audit Information', 'Audit and Accountability', ARRAY['*'], 'Audit events stored in dedicated security database', 'implemented'),
(gen_random_uuid(), 'AU-11', 'Audit Record Retention',          'Audit and Accountability', ARRAY['*'], 'Archive management with configurable retention', 'implemented'),
(gen_random_uuid(), 'AC-2',  'Account Management',              'Access Control',           ARRAY['auth.login', 'auth.logout', 'auth.failed'], 'Authentication events tracked', 'planned'),
(gen_random_uuid(), 'AC-6',  'Least Privilege',                 'Access Control',           ARRAY['auth.role_changed', 'auth.permission_granted'], 'Privilege changes tracked', 'planned'),
(gen_random_uuid(), 'AC-7',  'Unsuccessful Login Attempts',     'Access Control',           ARRAY['auth.failed'], 'Failed auth triggers breach detection', 'implemented'),
(gen_random_uuid(), 'SI-4',  'Information System Monitoring',   'System and Info Integrity', ARRAY['*'], 'Continuous monitoring via event stream', 'implemented'),
(gen_random_uuid(), 'IR-4',  'Incident Handling',               'Incident Response',        ARRAY['breach.*'], 'Breach alerts created and tracked', 'implemented'),
(gen_random_uuid(), 'IR-5',  'Incident Monitoring',             'Incident Response',        ARRAY['breach.*'], 'Breach alert status tracking', 'implemented');

Seed detection rules:

-- services/canopy-security/migrations/20260326000002_seed_detection_rules.sql

INSERT INTO detection_rules (id, rule_name, rule_type, threshold, window_minutes, severity) VALUES
(gen_random_uuid(), 'Failed Authentication',    'failed_auth',           5,   10, 'high'),
(gen_random_uuid(), 'Bulk Data Access',         'bulk_access',         100,    5, 'medium'),
(gen_random_uuid(), 'Privilege Escalation',     'privilege_escalation',  1,   60, 'critical'),
(gen_random_uuid(), 'After Hours Access',       'after_hours_access',    1, 1440, 'low');

Event Parsing

Each event arriving on canopy.events has the EventEnvelope structure (defined in canopy-mq). The event parsing module extracts structured audit fields from heterogeneous payloads.

Reference: "Port from d:/code/craig/services/craig-security/src/event_parsing.rs (312 lines)."

/// Parsed audit fields extracted from an EventEnvelope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedAuditEvent {
    pub event_id: Uuid,
    pub event_type: String,
    pub source_service: String,
    pub action: String,
    pub resource_type: String,
    pub resource_id: Option<String>,
    pub user_id: Option<String>,
    pub user_role: Option<String>,
    pub ip_address: Option<String>,
    pub metadata: serde_json::Value,
    pub event_timestamp: DateTime<Utc>,
}

/// Parse an EventEnvelope into structured audit fields.
pub fn parse_event(envelope: &EventEnvelope) -> ParsedAuditEvent {
    let (action, resource_type) = parse_event_type(&envelope.event_type);
    let user_id = extract_string_field(
        &envelope.payload,
        &["user_id", "created_by", "updated_by", "requested_by",
          "accessed_by", "approved_by", "archived_by"],
    );
    let resource_id = extract_string_field(
        &envelope.payload,
        &["id", "resource_id", "person_id", "household_id",
          "application_id", "determination_id"],
    );
    let user_role = extract_string_field(
        &envelope.payload,
        &["role", "user_role"],
    );
    let ip_address = extract_string_field(
        &envelope.payload,
        &["ip_address", "source_ip"],
    );

    ParsedAuditEvent {
        event_id: envelope.id,
        event_type: envelope.event_type.clone(),
        source_service: envelope.source_service.clone(),
        action,
        resource_type,
        resource_id,
        user_id,
        user_role,
        ip_address,
        metadata: envelope.payload.clone(),
        event_timestamp: envelope.timestamp,
    }
}

/// Parse event_type string into (action, resource_type).
/// Explicit mapping for known event types; fallback splits on first dot.
///
/// Ported from d:/code/craig/services/craig-security/src/event_parsing.rs
/// which maps CRAIG-specific event types. Canopy uses these mappings:
pub fn parse_event_type(event_type: &str) -> (String, String) {
    match event_type {
        // Person lifecycle
        "person.created" => ("create".into(), "person".into()),
        "person.updated" => ("update".into(), "person".into()),
        "person.deleted" => ("delete".into(), "person".into()),
        // Household lifecycle
        "household.created" => ("create".into(), "household".into()),
        "household.updated" => ("update".into(), "household".into()),
        "household.member_added" => ("add_member".into(), "household".into()),
        "household.member_removed" => ("remove_member".into(), "household".into()),
        // Application lifecycle
        "application.submitted" => ("submit".into(), "application".into()),
        "application.updated" => ("update".into(), "application".into()),
        "application.withdrawn" => ("withdraw".into(), "application".into()),
        // Determination lifecycle
        "determination.completed" => ("determine".into(), "determination".into()),
        "determination.signed" => ("sign".into(), "determination".into()),
        "determination.verified" => ("verify".into(), "determination".into()),
        // Program-specific determinations
        "snap.determined" => ("determine".into(), "snap_determination".into()),
        "tanf.determined" => ("determine".into(), "tanf_determination".into()),
        "medicaid.determined" => ("determine".into(), "medicaid_determination".into()),
        "chip.determined" => ("determine".into(), "chip_determination".into()),
        // Rules engine
        "rules.evaluated" => ("evaluate".into(), "rule_set".into()),
        "rules.published" => ("publish".into(), "rule_set".into()),
        // Work requirements (TANF)
        "tanf.work_requirement_updated" => ("update".into(), "work_requirement".into()),
        "tanf.time_limit_warning" => ("warn".into(), "time_limit".into()),
        // Auth events
        "auth.login" => ("login".into(), "auth".into()),
        "auth.logout" => ("logout".into(), "auth".into()),
        "auth.failed" => ("fail".into(), "auth".into()),
        "auth.role_changed" => ("change_role".into(), "auth".into()),
        "auth.permission_granted" => ("grant_permission".into(), "auth".into()),
        // Verification
        "verification.requested" => ("request".into(), "verification".into()),
        "verification.completed" => ("complete".into(), "verification".into()),
        // Notices
        "notice.generated" => ("generate".into(), "notice".into()),
        "notice.sent" => ("send".into(), "notice".into()),
        // Security (own events -- avoid infinite loop by not re-inserting)
        ev if ev.starts_with("security.") => ("system".into(), "security".into()),
        // Fallback: split on first dot
        other => {
            match other.split_once('.') {
                Some((resource, action)) => (action.to_string(), resource.to_string()),
                None => ("unknown".to_string(), other.to_string()),
            }
        }
    }
}

/// Extract the first matching string field from a JSON payload.
/// Checks field names in order and returns the first match found.
fn extract_string_field(payload: &serde_json::Value, field_names: &[&str]) -> Option<String> {
    for name in field_names {
        if let Some(serde_json::Value::String(v)) = payload.get(name) {
            return Some(v.clone());
        }
        // Also check UUID-typed fields (some payloads store IDs as UUIDs, not strings)
        if let Some(v) = payload.get(name) {
            if let Some(s) = v.as_str() {
                return Some(s.to_string());
            }
        }
    }
    None
}

Breach Detection Rules

Reference: "Port from d:/code/craig/services/craig-security/src/detection.rs (187 lines)."

/// Detection rule loaded from the detection_rules database table.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DetectionRule {
    pub id: Uuid,
    pub rule_name: String,
    pub rule_type: String,      // failed_auth, bulk_access, privilege_escalation, after_hours_access
    pub threshold: i32,
    pub window_minutes: i32,
    pub severity: String,       // low, medium, high, critical
    pub enabled: bool,
    pub active: bool,
    pub notify_webhook: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Alert generated when a detection rule threshold is exceeded.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecurityAlert {
    pub id: Uuid,
    pub rule_name: String,
    pub severity: String,
    pub description: String,
    pub user_id: Option<String>,
    pub source_service: Option<String>,
    pub evidence: serde_json::Value,
    pub status: String,
    pub resolved_by: Option<String>,
    pub resolved_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Run all enabled detection rules against the audit log.
/// Returns newly created alerts.
///
/// Ported from d:/code/craig/services/craig-security/src/detection.rs
pub async fn run_detection_scan(
    pool: &PgPool,
    publisher: &Publisher,
) -> Result<Vec<SecurityAlert>, sqlx::Error> {
    let rules = list_enabled_detection_rules(pool).await?;
    let mut alerts = Vec::new();

    for rule in rules.iter().filter(|r| r.enabled && r.active) {
        let count = match rule.rule_type.as_str() {
            "failed_auth" => count_failed_auth(pool, rule.window_minutes).await?,
            "bulk_access" => count_bulk_access(pool, rule.window_minutes).await?,
            "privilege_escalation" => count_privilege_escalation(pool, rule.window_minutes).await?,
            "after_hours_access" => count_after_hours_access(pool, rule.window_minutes).await?,
            _ => 0,
        };

        if count >= rule.threshold as i64 {
            let evidence = serde_json::json!({
                "count": count,
                "window_minutes": rule.window_minutes,
                "threshold": rule.threshold,
                "scanned_at": Utc::now(),
            });
            let alert = create_alert(
                pool,
                &rule.rule_name,
                &rule.severity,
                &format!(
                    "{}: {} occurrences in {} minutes (threshold: {})",
                    rule.rule_name, count, rule.window_minutes, rule.threshold
                ),
                None, // user_id extracted per-rule below
                None, // source_service
                &evidence,
            )
            .await?;

            // Publish security alert event
            let _ = publish_alert_created(publisher, alert.id, &rule.rule_name, &rule.severity).await;

            alerts.push(alert);
        }
    }

    Ok(alerts)
}

/// List all enabled detection rules from the database.
async fn list_enabled_detection_rules(pool: &PgPool) -> Result<Vec<DetectionRule>, sqlx::Error> {
    sqlx::query_as::<_, DetectionRule>(
        "SELECT * FROM detection_rules WHERE enabled = true AND active = true"
    )
    .fetch_all(pool)
    .await
}

/// Count failed auth events in the detection window.
async fn count_failed_auth(pool: &PgPool, window_minutes: i32) -> Result<i64, sqlx::Error> {
    let cutoff = Utc::now() - chrono::Duration::minutes(window_minutes as i64);
    let (count,): (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM audit_events
         WHERE event_type = 'auth.failed' AND event_timestamp >= $1"
    )
    .bind(cutoff)
    .fetch_one(pool)
    .await?;
    Ok(count)
}

/// Count bulk data access (>N reads from same user in window).
async fn count_bulk_access(pool: &PgPool, window_minutes: i32) -> Result<i64, sqlx::Error> {
    let cutoff = Utc::now() - chrono::Duration::minutes(window_minutes as i64);
    let (count,): (i64,) = sqlx::query_as(
        "SELECT COALESCE(MAX(user_count), 0) FROM (
            SELECT COUNT(*) AS user_count
            FROM audit_events
            WHERE action IN ('read', 'created') AND event_timestamp >= $1 AND user_id IS NOT NULL
            GROUP BY user_id
        ) sub"
    )
    .bind(cutoff)
    .fetch_one(pool)
    .await?;
    Ok(count)
}

/// Count privilege escalation events in the detection window.
async fn count_privilege_escalation(pool: &PgPool, window_minutes: i32) -> Result<i64, sqlx::Error> {
    let cutoff = Utc::now() - chrono::Duration::minutes(window_minutes as i64);
    let (count,): (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM audit_events
         WHERE event_type = 'auth.role_changed' AND event_timestamp >= $1"
    )
    .bind(cutoff)
    .fetch_one(pool)
    .await?;
    Ok(count)
}

/// Count after-hours access events (events outside 06:00-22:00 ET from non-service accounts).
async fn count_after_hours_access(pool: &PgPool, window_minutes: i32) -> Result<i64, sqlx::Error> {
    let cutoff = Utc::now() - chrono::Duration::minutes(window_minutes as i64);
    let (count,): (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM audit_events
         WHERE event_timestamp >= $1
           AND user_id IS NOT NULL
           AND user_id NOT LIKE 'service-%'
           AND (EXTRACT(HOUR FROM event_timestamp AT TIME ZONE 'America/New_York') < 6
                OR EXTRACT(HOUR FROM event_timestamp AT TIME ZONE 'America/New_York') >= 22)"
    )
    .bind(cutoff)
    .fetch_one(pool)
    .await?;
    Ok(count)
}

/// Insert a breach alert into the database.
async fn create_alert(
    pool: &PgPool,
    rule_name: &str,
    severity: &str,
    description: &str,
    user_id: Option<&str>,
    source_service: Option<&str>,
    evidence: &serde_json::Value,
) -> Result<SecurityAlert, sqlx::Error> {
    sqlx::query_as::<_, SecurityAlert>(
        "INSERT INTO breach_alerts (id, rule_name, severity, description, user_id, source_service, evidence)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         RETURNING *"
    )
    .bind(Uuid::now_v7())
    .bind(rule_name)
    .bind(severity)
    .bind(description)
    .bind(user_id)
    .bind(source_service)
    .bind(evidence)
    .fetch_one(pool)
    .await
}

/// Publish a security alert event to canopy.events.
async fn publish_alert_created(
    publisher: &Publisher,
    alert_id: Uuid,
    rule_name: &str,
    severity: &str,
) -> Result<(), lapin::Error> {
    let payload = serde_json::json!({
        "alert_id": alert_id,
        "rule_name": rule_name,
        "severity": severity,
        "created_at": Utc::now(),
    });
    let envelope = EventEnvelope::new("canopy-security", "security.alert_created", payload);
    publisher.publish(&envelope).await
}

Detection rules:

Rule Severity Trigger

failed_auth

High

More than 5 auth.failed events from the same user within 10 minutes

bulk_access

Medium

More than 100 read events from the same user within 5 minutes (possible data exfiltration)

privilege_escalation

Critical

auth.role_changed event where the new role has higher privileges than the previous role AND the change was not made by an administrator

after_hours_access

Low

Any event from a non-service-account user outside configured business hours (default: 06:00-22:00 ET)

Subscriber Wiring

The wildcard subscriber is wired in canopy-security’s `main.rs.

Reference: "Port wildcard subscription from d:/code/craig/services/craig-security/src/main.rs (lines 37-49)."

// In main.rs, after bootstrap:
let db_for_handler = boot.db.clone();
let publisher_for_handler = publisher.clone();

boot.subscriber
    .subscribe(
        "canopy-security.audit",
        &["#"],  // wildcard -- captures ALL events on canopy.events
        move |envelope: EventEnvelope| {
            let db = db_for_handler.clone();
            let publisher = publisher_for_handler.clone();
            async move {
                handle_inbound_event(&db, &publisher, envelope).await
            }
        },
    )
    .await
    .context("failed to start wildcard event subscriber")?;

The inbound event handler:

/// Handle inbound events from ALL Canopy services -- populates the audit log.
/// Ported from d:/code/craig/services/craig-security/src/main.rs (lines 80-120).
async fn handle_inbound_event(
    db: &DbPool,
    publisher: &Publisher,
    envelope: EventEnvelope,
) -> anyhow::Result<()> {
    // Step 1: Parse the event into structured audit fields
    let parsed = event_parsing::parse_event(&envelope);

    // Step 2: Persist the parsed audit event
    store::audit::insert_audit_event(db, &parsed).await?;

    // Step 3: Run breach detection scan
    // Detection rules are evaluated after each event insert
    // This is acceptable because detection queries are simple count queries against indexed columns
    let alerts = detection::run_detection_scan(db.inner(), publisher).await?;

    if !alerts.is_empty() {
        tracing::warn!(
            alert_count = alerts.len(),
            "breach detection alerts triggered"
        );
    }

    Ok(())
}

API Endpoints

Method Path Description

GET

/v1/audit/events

Query audit events with filters (date range, source_service, action, resource_type, user_id). Paginated.

GET

/v1/audit/events/{id}

Get a single audit event by ID.

GET

/v1/audit/alerts

Query breach alerts with filters (status, severity, rule_name). Paginated.

GET

/v1/audit/alerts/{id}

Get a single breach alert.

PATCH

/v1/audit/alerts/{id}

Update alert status (investigating, resolved, false_positive).

GET

/v1/audit/nist-controls

List NIST control mappings with implementation status.

GET

/v1/audit/summary

Summary: events per service per day, active alerts by severity, NIST control coverage percentage.

All endpoints require the security_auditor or security_admin role. The PATCH endpoint for alert status requires security_admin.

CLI Commands (ADR-007)

Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:

  • canopy audit events — query audit events with filters (date range, service, action, resource type, user)

  • canopy audit event <id> — get a single audit event

  • canopy audit alerts — query breach alerts with filters (status, severity, rule name)

  • canopy audit alert <id> — get a single breach alert

  • canopy audit alert update <id> — update alert status (investigating, resolved, false_positive)

  • canopy audit nist-controls — list NIST control mappings with implementation status

  • canopy audit summary — show audit summary (events per service, active alerts, NIST coverage)

Steps

Step 1: Database Migration

Files: services/canopy-security/migrations/20260326000000_create_security_tables.sql, services/canopy-security/migrations/20260326000001_seed_nist_controls.sql, services/canopy-security/migrations/20260326000002_seed_detection_rules.sql

Create all tables and indexes from the Design section. Seed NIST control mappings. Seed detection rules with default thresholds. Uncomment migration runner in services/canopy-security/src/main.rs.

Full SQL for the tables migration:

-- services/canopy-security/migrations/20260326000000_create_security_tables.sql

CREATE TABLE audit_events (
    id UUID PRIMARY KEY,
    event_id UUID NOT NULL,
    event_type TEXT NOT NULL,
    source_service TEXT NOT NULL,
    action TEXT NOT NULL,
    resource_type TEXT NOT NULL,
    resource_id TEXT,
    user_id TEXT,
    user_role TEXT,
    ip_address TEXT,
    metadata JSONB NOT NULL DEFAULT '{}',
    event_timestamp TIMESTAMPTZ NOT NULL,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE breach_alerts (
    id UUID PRIMARY KEY,
    rule_name TEXT NOT NULL,
    severity TEXT NOT NULL,
    description TEXT NOT NULL,
    user_id TEXT,
    source_service TEXT,
    evidence JSONB NOT NULL DEFAULT '{}',
    status TEXT NOT NULL DEFAULT 'open',
    resolved_by TEXT,
    resolved_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE nist_control_mappings (
    id UUID PRIMARY KEY,
    control_id TEXT NOT NULL,
    control_name TEXT NOT NULL,
    control_family TEXT NOT NULL,
    event_types TEXT[] NOT NULL,
    description TEXT NOT NULL,
    implementation_status TEXT NOT NULL DEFAULT 'planned',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE detection_rules (
    id UUID PRIMARY KEY,
    rule_name TEXT NOT NULL UNIQUE,
    rule_type TEXT NOT NULL,
    threshold INTEGER NOT NULL,
    window_minutes INTEGER NOT NULL,
    severity TEXT NOT NULL,
    enabled BOOLEAN NOT NULL DEFAULT true,
    active BOOLEAN NOT NULL DEFAULT true,
    notify_webhook TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE audit_events_archive (
    LIKE audit_events INCLUDING ALL
);

CREATE INDEX idx_audit_events_event_type ON audit_events(event_type);
CREATE INDEX idx_audit_events_source_service ON audit_events(source_service);
CREATE INDEX idx_audit_events_action ON audit_events(action);
CREATE INDEX idx_audit_events_user_id ON audit_events(user_id);
CREATE INDEX idx_audit_events_resource_type ON audit_events(resource_type);
CREATE INDEX idx_audit_events_event_timestamp ON audit_events(event_timestamp);
CREATE INDEX idx_audit_events_metadata ON audit_events USING GIN(metadata);
CREATE INDEX idx_breach_alerts_rule_name ON breach_alerts(rule_name);
CREATE INDEX idx_breach_alerts_status ON breach_alerts(status);
CREATE INDEX idx_breach_alerts_severity ON breach_alerts(severity);
CREATE INDEX idx_nist_control_mappings_control_id ON nist_control_mappings(control_id);
CREATE INDEX idx_detection_rules_rule_type ON detection_rules(rule_type);

NIST control seed INSERT (see Design section above).

Detection rules seed INSERT (see Design section above).

Step 2: Wildcard Subscriber

Files: services/canopy-security/src/main.rs

Wire the wildcard subscriber using boot.subscriber.subscribe("canopy-security.audit", &["#"], handler). The handler calls the event parsing, store, and detection modules.

Reference: "Port wildcard subscription from d:/code/craig/services/craig-security/src/main.rs (lines 37-49)."

Full main.rs startup sequence:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let (
        settings,
        BootstrapResult {
            db, auth, publisher, subscriber, _telemetry, mq_health,
        },
    ) = bootstrap("CANOPY_SECURITY", "canopy-security").await?;

    // 1. Run migrations
    db.run_migrations(&sqlx::migrate!())
        .await
        .context("failed to run database migrations")?;

    // 2. Wire wildcard subscriber for audit logging
    let db_for_sub = db.clone();
    let publisher_for_sub = publisher.clone();
    let _sub_handle = subscriber
        .subscribe(
            "canopy-security.audit",
            &["#"],
            move |envelope: EventEnvelope| {
                let db = db_for_sub.clone();
                let pub_clone = publisher_for_sub.clone();
                async move { handle_inbound_event(&db, &pub_clone, envelope).await }
            },
        )
        .await
        .context("failed to start wildcard event subscriber")?;

    // 3. Build Axum router
    let state = AppState { db, auth };
    let service_routes = api::routes(publisher);
    let router = ApiServer::router(state, service_routes, server_opts, Some(api::ApiDoc::openapi()))
        .layer(Extension(mq_health));

    // 4. Start HTTP server
    ApiServer::serve(router, settings.port, shutdown_signal()).await?;
    Ok(())
}

Step 3: Event Parsing Module

Files: services/canopy-security/src/event_parsing.rs (new)

Implement parse_event, parse_event_type, and extract_string_field as shown in the Design section.

Reference: "Port from d:/code/craig/services/craig-security/src/event_parsing.rs (312 lines)."

The full parse_event_type function mapping is shown in the Design section above. It includes explicit mappings for all known Canopy event types and a fallback that splits on the first dot.

Step 4: Store Layer

Files: services/canopy-security/src/store/mod.rs (new), services/canopy-security/src/store/audit.rs (new), services/canopy-security/src/store/alerts.rs (new), services/canopy-security/src/store/detection_rules.rs (new), services/canopy-security/src/store/nist.rs (new)

Audit event persistence

// services/canopy-security/src/store/audit.rs

use crate::event_parsing::ParsedAuditEvent;

/// Row type for the audit_events table.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuditEventRow {
    pub id: Uuid,
    pub event_id: Uuid,
    pub event_type: String,
    pub source_service: String,
    pub action: String,
    pub resource_type: String,
    pub resource_id: Option<String>,
    pub user_id: Option<String>,
    pub user_role: Option<String>,
    pub ip_address: Option<String>,
    pub metadata: serde_json::Value,
    pub event_timestamp: DateTime<Utc>,
    pub received_at: DateTime<Utc>,
    pub created_at: DateTime<Utc>,
}

/// Insert a parsed audit event into the audit_events table.
pub async fn insert_audit_event(db: &DbPool, event: &ParsedAuditEvent) -> Result<(), sqlx::Error> {
    sqlx::query(
        "INSERT INTO audit_events
            (id, event_id, event_type, source_service, action, resource_type,
             resource_id, user_id, user_role, ip_address, metadata, event_timestamp)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"
    )
    .bind(Uuid::now_v7())
    .bind(event.event_id)
    .bind(&event.event_type)
    .bind(&event.source_service)
    .bind(&event.action)
    .bind(&event.resource_type)
    .bind(&event.resource_id)
    .bind(&event.user_id)
    .bind(&event.user_role)
    .bind(&event.ip_address)
    .bind(&event.metadata)
    .bind(event.event_timestamp)
    .execute(db.inner())
    .await?;
    Ok(())
}

/// Query parameters for the audit events list.
#[derive(Debug, Deserialize)]
pub struct AuditEventFilter {
    pub from: Option<DateTime<Utc>>,
    pub to: Option<DateTime<Utc>>,
    pub source_service: Option<String>,
    pub action: Option<String>,
    pub resource_type: Option<String>,
    pub user_id: Option<String>,
    pub event_type: Option<String>,
    pub page: Option<i64>,
    pub page_size: Option<i64>,
}

/// Paginated audit event result.
#[derive(Debug, Serialize)]
pub struct AuditEventPage {
    pub items: Vec<AuditEventRow>,
    pub total: i64,
    pub page: i64,
    pub page_size: i64,
}

/// Query audit events with filters and pagination.
pub async fn query_audit_events(
    db: &DbPool,
    filter: AuditEventFilter,
) -> Result<AuditEventPage, sqlx::Error> {
    let page = filter.page.unwrap_or(0);
    let page_size = filter.page_size.unwrap_or(50).min(200);
    let offset = page * page_size;

    let total: (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM audit_events
         WHERE ($1::timestamptz IS NULL OR event_timestamp >= $1)
           AND ($2::timestamptz IS NULL OR event_timestamp <= $2)
           AND ($3::text IS NULL OR source_service = $3)
           AND ($4::text IS NULL OR action = $4)
           AND ($5::text IS NULL OR resource_type = $5)
           AND ($6::text IS NULL OR user_id = $6)
           AND ($7::text IS NULL OR event_type = $7)"
    )
    .bind(filter.from)
    .bind(filter.to)
    .bind(&filter.source_service)
    .bind(&filter.action)
    .bind(&filter.resource_type)
    .bind(&filter.user_id)
    .bind(&filter.event_type)
    .fetch_one(db.inner())
    .await?;

    let items = sqlx::query_as::<_, AuditEventRow>(
        "SELECT * FROM audit_events
         WHERE ($1::timestamptz IS NULL OR event_timestamp >= $1)
           AND ($2::timestamptz IS NULL OR event_timestamp <= $2)
           AND ($3::text IS NULL OR source_service = $3)
           AND ($4::text IS NULL OR action = $4)
           AND ($5::text IS NULL OR resource_type = $5)
           AND ($6::text IS NULL OR user_id = $6)
           AND ($7::text IS NULL OR event_type = $7)
         ORDER BY event_timestamp DESC
         LIMIT $8 OFFSET $9"
    )
    .bind(filter.from)
    .bind(filter.to)
    .bind(&filter.source_service)
    .bind(&filter.action)
    .bind(&filter.resource_type)
    .bind(&filter.user_id)
    .bind(&filter.event_type)
    .bind(page_size)
    .bind(offset)
    .fetch_all(db.inner())
    .await?;

    Ok(AuditEventPage { items, total: total.0, page, page_size })
}

/// Get a single audit event by ID.
pub async fn get_audit_event(db: &DbPool, id: Uuid) -> Result<Option<AuditEventRow>, sqlx::Error> {
    sqlx::query_as::<_, AuditEventRow>("SELECT * FROM audit_events WHERE id = $1")
        .bind(id)
        .fetch_optional(db.inner())
        .await
}

Alert persistence

// services/canopy-security/src/store/alerts.rs

/// Query parameters for breach alerts list.
#[derive(Debug, Deserialize)]
pub struct AlertFilter {
    pub status: Option<String>,
    pub severity: Option<String>,
    pub rule_name: Option<String>,
    pub page: Option<i64>,
    pub page_size: Option<i64>,
}

/// Paginated alert result.
#[derive(Debug, Serialize)]
pub struct AlertPage {
    pub items: Vec<SecurityAlert>,
    pub total: i64,
    pub page: i64,
    pub page_size: i64,
}

/// Update request for alert status.
#[derive(Debug, Deserialize)]
pub struct AlertStatusUpdate {
    pub status: String,     // investigating, resolved, false_positive
    pub resolved_by: Option<String>,
}

pub async fn query_breach_alerts(
    db: &DbPool,
    filter: AlertFilter,
) -> Result<AlertPage, sqlx::Error> {
    let page = filter.page.unwrap_or(0);
    let page_size = filter.page_size.unwrap_or(50).min(200);
    let offset = page * page_size;

    let total: (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM breach_alerts
         WHERE ($1::text IS NULL OR status = $1)
           AND ($2::text IS NULL OR severity = $2)
           AND ($3::text IS NULL OR rule_name = $3)"
    )
    .bind(&filter.status)
    .bind(&filter.severity)
    .bind(&filter.rule_name)
    .fetch_one(db.inner())
    .await?;

    let items = sqlx::query_as::<_, SecurityAlert>(
        "SELECT * FROM breach_alerts
         WHERE ($1::text IS NULL OR status = $1)
           AND ($2::text IS NULL OR severity = $2)
           AND ($3::text IS NULL OR rule_name = $3)
         ORDER BY created_at DESC
         LIMIT $4 OFFSET $5"
    )
    .bind(&filter.status)
    .bind(&filter.severity)
    .bind(&filter.rule_name)
    .bind(page_size)
    .bind(offset)
    .fetch_all(db.inner())
    .await?;

    Ok(AlertPage { items, total: total.0, page, page_size })
}

pub async fn get_breach_alert(db: &DbPool, id: Uuid) -> Result<Option<SecurityAlert>, sqlx::Error> {
    sqlx::query_as::<_, SecurityAlert>("SELECT * FROM breach_alerts WHERE id = $1")
        .bind(id)
        .fetch_optional(db.inner())
        .await
}

pub async fn update_alert_status(
    db: &DbPool,
    id: Uuid,
    update: &AlertStatusUpdate,
) -> Result<SecurityAlert, sqlx::Error> {
    let resolved_at = if update.status == "resolved" || update.status == "false_positive" {
        Some(Utc::now())
    } else {
        None
    };

    sqlx::query_as::<_, SecurityAlert>(
        "UPDATE breach_alerts
         SET status = $1, resolved_by = $2, resolved_at = $3, updated_at = now()
         WHERE id = $4
         RETURNING *"
    )
    .bind(&update.status)
    .bind(&update.resolved_by)
    .bind(resolved_at)
    .bind(id)
    .fetch_one(db.inner())
    .await
}

NIST control mapping store

// services/canopy-security/src/store/nist.rs

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct NistControlMapping {
    pub id: Uuid,
    pub control_id: String,
    pub control_name: String,
    pub control_family: String,
    pub event_types: Vec<String>,
    pub description: String,
    pub implementation_status: String,
    pub created_at: DateTime<Utc>,
}

pub async fn list_nist_controls(db: &DbPool) -> Result<Vec<NistControlMapping>, sqlx::Error> {
    sqlx::query_as::<_, NistControlMapping>(
        "SELECT * FROM nist_control_mappings ORDER BY control_id"
    )
    .fetch_all(db.inner())
    .await
}

Step 5: Breach Detection Rules

Files: services/canopy-security/src/detection.rs (new)

Implement the four detection rules from the Design section. The full run_detection_scan function with per-rule count queries is shown in the Design section above.

Reference: "Port from d:/code/craig/services/craig-security/src/detection.rs (187 lines)."

Each detection rule is a simple count query against indexed columns in audit_events. Detection rules are loaded from the detection_rules table so thresholds and windows are configurable without code changes. The detection_rules() function returns Vec<DetectionRule> from the database.

Detection rules are evaluated synchronously after each event is stored (not batched). This is acceptable because detection queries are simple count queries against indexed columns.

Step 6: Archive Management

Files: services/canopy-security/src/archive.rs (new)

/// Move audit events older than retention_days to audit_events_archive.
/// Default retention: 365 days for active table; archived records retained 7 years.
pub async fn archive_old_events(
    db: &DbPool,
    retention_days: u32,
) -> Result<u64, sqlx::Error> {
    let cutoff = Utc::now() - chrono::Duration::days(retention_days as i64);

    let mut tx = db.inner().begin().await?;

    let archived = sqlx::query(
        "WITH moved AS (
            DELETE FROM audit_events
            WHERE event_timestamp < $1
            RETURNING *
        )
        INSERT INTO audit_events_archive
        SELECT * FROM moved"
    )
    .bind(cutoff)
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;

    Ok(archived.rows_affected())
}

/// Purge archived records older than purge_years.
pub async fn purge_archived_events(
    db: &DbPool,
    purge_years: u32,
) -> Result<u64, sqlx::Error> {
    let cutoff = Utc::now() - chrono::Duration::days(purge_years as i64 * 365);

    let purged = sqlx::query(
        "DELETE FROM audit_events_archive WHERE event_timestamp < $1"
    )
    .bind(cutoff)
    .execute(db.inner())
    .await?;

    Ok(purged.rows_affected())
}

Add a scheduled task or CLI command (cargo xtask archive-audit-events) for running archival.

Step 7: API Endpoints

Files: services/canopy-security/src/api/mod.rs, services/canopy-security/src/api/audit.rs (new), services/canopy-security/src/api/alerts.rs (new)

Implement all endpoints from the Design section. Wire into the router. Add utoipa OpenAPI documentation.

// services/canopy-security/src/api/audit.rs

/// GET /v1/audit/events
/// Requires security_auditor or security_admin role.
pub async fn list_audit_events(
    claims: Extension<Claims>,
    Query(params): Query<AuditEventFilter>,
    State(state): State<AppState>,
) -> Result<Json<AuditEventPage>, ApiError> {
    claims.require_any_role(&["security_auditor", "security_admin"])?;
    let page = store::audit::query_audit_events(&state.db, params).await.map_err(ApiError::internal)?;
    Ok(Json(page))
}

/// GET /v1/audit/events/{id}
pub async fn get_audit_event(
    claims: Extension<Claims>,
    Path(id): Path<Uuid>,
    State(state): State<AppState>,
) -> Result<Json<AuditEventRow>, ApiError> {
    claims.require_any_role(&["security_auditor", "security_admin"])?;
    match store::audit::get_audit_event(&state.db, id).await.map_err(ApiError::internal)? {
        Some(event) => Ok(Json(event)),
        None => Err(ApiError::not_found("audit_event", id)),
    }
}

/// GET /v1/audit/summary
/// Returns events per service per day, active alerts by severity, NIST coverage.
#[derive(Debug, Serialize)]
pub struct AuditSummary {
    pub events_per_service: Vec<ServiceEventCount>,
    pub active_alerts_by_severity: Vec<SeverityCount>,
    pub nist_coverage: NistCoverage,
}

#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ServiceEventCount {
    pub source_service: String,
    pub event_count: i64,
}

#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct SeverityCount {
    pub severity: String,
    pub count: i64,
}

#[derive(Debug, Serialize)]
pub struct NistCoverage {
    pub total_controls: i64,
    pub implemented: i64,
    pub partial: i64,
    pub planned: i64,
    pub coverage_percentage: f64,
}

pub async fn audit_summary(
    claims: Extension<Claims>,
    State(state): State<AppState>,
) -> Result<Json<AuditSummary>, ApiError> {
    claims.require_any_role(&["security_auditor", "security_admin"])?;
    // Build summary from aggregate queries
    // ...
    todo!()
}
// services/canopy-security/src/api/alerts.rs

/// GET /v1/audit/alerts
pub async fn list_alerts(
    claims: Extension<Claims>,
    Query(params): Query<AlertFilter>,
    State(state): State<AppState>,
) -> Result<Json<AlertPage>, ApiError> {
    claims.require_any_role(&["security_auditor", "security_admin"])?;
    let page = store::alerts::query_breach_alerts(&state.db, params).await.map_err(ApiError::internal)?;
    Ok(Json(page))
}

/// GET /v1/audit/alerts/{id}
pub async fn get_alert(
    claims: Extension<Claims>,
    Path(id): Path<Uuid>,
    State(state): State<AppState>,
) -> Result<Json<SecurityAlert>, ApiError> {
    claims.require_any_role(&["security_auditor", "security_admin"])?;
    match store::alerts::get_breach_alert(&state.db, id).await.map_err(ApiError::internal)? {
        Some(alert) => Ok(Json(alert)),
        None => Err(ApiError::not_found("breach_alert", id)),
    }
}

/// PATCH /v1/audit/alerts/{id}
/// Requires security_admin role.
pub async fn update_alert(
    claims: Extension<Claims>,
    Path(id): Path<Uuid>,
    State(state): State<AppState>,
    Json(update): Json<AlertStatusUpdate>,
) -> Result<Json<SecurityAlert>, ApiError> {
    claims.require_role("security_admin")?;
    let alert = store::alerts::update_alert_status(&state.db, id, &update)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(alert))
}

/// GET /v1/audit/nist-controls
pub async fn list_nist_controls(
    claims: Extension<Claims>,
    State(state): State<AppState>,
) -> Result<Json<Vec<NistControlMapping>>, ApiError> {
    claims.require_any_role(&["security_auditor", "security_admin"])?;
    let controls = store::nist::list_nist_controls(&state.db).await.map_err(ApiError::internal)?;
    Ok(Json(controls))
}

JSON response example for GET /v1/audit/events?source_service=canopy-tanf&page=0&page_size=5:

{
  "items": [
    {
      "id": "019513a0-0001-7000-8000-000000000001",
      "event_id": "019513a0-0001-7000-8000-000000000099",
      "event_type": "tanf.determined",
      "source_service": "canopy-tanf",
      "action": "determine",
      "resource_type": "tanf_determination",
      "resource_id": "019513a0-0001-7000-8000-000000000050",
      "user_id": "caseworker-jane@agency.gov",
      "user_role": "caseworker",
      "ip_address": "10.0.1.42",
      "metadata": {"application_id": "...", "household_id": "...", "status": "approved"},
      "event_timestamp": "2025-11-15T14:22:33Z",
      "received_at": "2025-11-15T14:22:33.050Z",
      "created_at": "2025-11-15T14:22:33.050Z"
    }
  ],
  "total": 1,
  "page": 0,
  "page_size": 5
}

Step 8: Tests

Files: services/canopy-security/tests/subscriber.rs (new), services/canopy-security/src/event_parsing.rs (unit tests), services/canopy-security/src/detection.rs (unit tests)

Unit tests in event_parsing.rs

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_person_created() {
        let (action, resource_type) = parse_event_type("person.created");
        assert_eq!(action, "create");
        assert_eq!(resource_type, "person");
    }

    #[test]
    fn parse_determination_completed() {
        let (action, resource_type) = parse_event_type("determination.completed");
        assert_eq!(action, "determine");
        assert_eq!(resource_type, "determination");
    }

    #[test]
    fn parse_household_member_added() {
        let (action, resource_type) = parse_event_type("household.member_added");
        assert_eq!(action, "add_member");
        assert_eq!(resource_type, "household");
    }

    #[test]
    fn parse_tanf_determined() {
        let (action, resource_type) = parse_event_type("tanf.determined");
        assert_eq!(action, "determine");
        assert_eq!(resource_type, "tanf_determination");
    }

    #[test]
    fn parse_auth_failed() {
        let (action, resource_type) = parse_event_type("auth.failed");
        assert_eq!(action, "fail");
        assert_eq!(resource_type, "auth");
    }

    #[test]
    fn parse_unknown_event_type_fallback() {
        let (action, resource_type) = parse_event_type("widget.frobnicated");
        assert_eq!(action, "frobnicated");
        assert_eq!(resource_type, "widget");
    }

    #[test]
    fn parse_no_dot_event_type() {
        let (action, resource_type) = parse_event_type("system_startup");
        assert_eq!(action, "unknown");
        assert_eq!(resource_type, "system_startup");
    }

    #[test]
    fn parse_security_events_avoid_loop() {
        let (action, resource_type) = parse_event_type("security.alert_created");
        assert_eq!(action, "system");
        assert_eq!(resource_type, "security");
    }

    #[test]
    fn extract_user_id_from_created_by() {
        let payload = serde_json::json!({"created_by": "user-123"});
        let result = extract_string_field(&payload, &["user_id", "created_by"]);
        assert_eq!(result, Some("user-123".to_string()));
    }

    #[test]
    fn extract_resource_id_from_application_id() {
        let payload = serde_json::json!({"application_id": "app-456"});
        let result = extract_string_field(&payload, &["id", "resource_id", "application_id"]);
        assert_eq!(result, Some("app-456".to_string()));
    }

    #[test]
    fn extract_returns_none_for_missing_fields() {
        let payload = serde_json::json!({"unrelated_field": "value"});
        let result = extract_string_field(&payload, &["user_id", "created_by"]);
        assert_eq!(result, None);
    }

    #[test]
    fn parse_full_event_envelope() {
        let envelope = EventEnvelope {
            id: Uuid::new_v4(),
            event_type: "person.created".to_string(),
            source_service: "canopy-persons".to_string(),
            payload: serde_json::json!({
                "id": "person-001",
                "created_by": "caseworker-jane",
                "role": "caseworker"
            }),
            timestamp: Utc::now(),
        };
        let parsed = parse_event(&envelope);
        assert_eq!(parsed.action, "create");
        assert_eq!(parsed.resource_type, "person");
        assert_eq!(parsed.resource_id, Some("person-001".to_string()));
        assert_eq!(parsed.user_id, Some("caseworker-jane".to_string()));
        assert_eq!(parsed.user_role, Some("caseworker".to_string()));
    }
}

Integration tests in services/canopy-security/tests/subscriber.rs

#[tokio::test]
async fn published_event_appears_in_audit_events_table() {
    // Setup: testcontainers Postgres + RabbitMQ, run migrations
    // Act: publish a "person.created" event to canopy.events
    // Assert: poll audit_events table, verify row exists with:
    //   - event_type = "person.created"
    //   - action = "create"
    //   - resource_type = "person"
    //   - source_service matches publisher
}

#[tokio::test]
async fn failed_auth_breach_detection_triggers_alert() {
    // Setup: testcontainers Postgres + RabbitMQ, run migrations, seed detection rules
    // Act: publish 6 "auth.failed" events in rapid succession (threshold is 5)
    // Assert: breach_alerts table has 1 row with:
    //   - rule_name = "Failed Authentication"
    //   - severity = "high"
    //   - status = "open"
    //   - evidence contains count >= 6
}

#[tokio::test]
async fn below_threshold_does_not_trigger_alert() {
    // Setup: testcontainers Postgres + RabbitMQ, run migrations, seed detection rules
    // Act: publish 3 "auth.failed" events (below threshold of 5)
    // Assert: breach_alerts table has 0 rows
}

#[tokio::test]
async fn query_audit_events_api_with_filters() {
    // Setup: testcontainers, insert 10 audit events (5 from canopy-tanf, 5 from canopy-snap)
    // Act: GET /v1/audit/events?source_service=canopy-tanf
    // Assert: response has 5 items, all with source_service = "canopy-tanf"
}

#[tokio::test]
async fn update_alert_status_via_patch() {
    // Setup: testcontainers, insert a breach alert with status "open"
    // Act: PATCH /v1/audit/alerts/{id} with { "status": "resolved", "resolved_by": "admin-1" }
    // Assert: response has status = "resolved", resolved_by = "admin-1", resolved_at is set
}

#[tokio::test]
async fn nist_control_mappings_seeded_correctly() {
    // Setup: testcontainers, run migrations + seed
    // Act: GET /v1/audit/nist-controls
    // Assert: response includes AU-2, AU-3, AU-6, AU-9, AU-11, AC-2, AC-6, AC-7, SI-4, IR-4, IR-5
}

#[tokio::test]
async fn security_auditor_role_required() {
    // Setup: testcontainers, start server
    // Act: GET /v1/audit/events with a token that has role "caseworker" (not security_auditor)
    // Assert: response status 403
    // Act: GET /v1/audit/events with a token that has role "security_auditor"
    // Assert: response status 200
}

#[tokio::test]
async fn security_admin_required_for_alert_update() {
    // Setup: testcontainers, insert an alert
    // Act: PATCH /v1/audit/alerts/{id} with security_auditor role (not admin)
    // Assert: response status 403
    // Act: PATCH /v1/audit/alerts/{id} with security_admin role
    // Assert: response status 200
}

#[tokio::test]
async fn archive_old_events() {
    // Setup: testcontainers, insert events spanning 2 years
    // Act: call archive_old_events with retention_days = 365
    // Assert: events older than 1 year moved to audit_events_archive
    //         events newer than 1 year remain in audit_events
}

Files Touched

File Change

services/canopy-security/migrations/20260326000000_create_security_tables.sql

New: audit_events, breach_alerts, nist_control_mappings, detection_rules, audit_events_archive tables

services/canopy-security/migrations/20260326000001_seed_nist_controls.sql

New: NIST SP 800-53 control mapping seed data

services/canopy-security/migrations/20260326000002_seed_detection_rules.sql

New: detection rule seed data (failed_auth, bulk_access, privilege_escalation, after_hours_access)

services/canopy-security/src/main.rs

Wire wildcard subscriber, detection rules, migration runner

services/canopy-security/src/event_parsing.rs

New: parse_event, parse_event_type, extract_string_field, unit tests

services/canopy-security/src/detection.rs

New: DetectionRule, SecurityAlert, run_detection_scan, per-rule count queries, publish_alert_created

services/canopy-security/src/store/mod.rs

New: store module

services/canopy-security/src/store/audit.rs

New: AuditEventRow, insert_audit_event, query_audit_events, get_audit_event

services/canopy-security/src/store/alerts.rs

New: AlertFilter, AlertPage, AlertStatusUpdate, query_breach_alerts, get_breach_alert, update_alert_status

services/canopy-security/src/store/detection_rules.rs

New: list_enabled_detection_rules

services/canopy-security/src/store/nist.rs

New: NistControlMapping, list_nist_controls

services/canopy-security/src/archive.rs

New: archive_old_events, purge_archived_events

services/canopy-security/src/api/mod.rs

Wire audit and alert routes

services/canopy-security/src/api/audit.rs

New: list_audit_events, get_audit_event, audit_summary, AuditSummary types

services/canopy-security/src/api/alerts.rs

New: list_alerts, get_alert, update_alert, list_nist_controls

services/canopy-security/Cargo.toml

Add chrono, serde_json (if not present)

Verification

  1. cargo nextest run -p canopy-security — unit tests pass

  2. cargo xtask dev restart — migration runs, tables created, NIST controls seeded

  3. cargo nextest run -p canopy-security --profile integration — subscriber and detection integration tests pass

  4. Manual: start devstack, publish a test event from another service, verify it appears in canopy-security’s audit_events table

  5. Manual: trigger 6 rapid auth.failed events, verify breach alert is created and visible via API

  6. Manual: query /v1/audit/nist-controls, verify control mappings are present

  7. Manual: query /v1/audit/summary, verify summary statistics

Documentation Updates

  • .claude/docs/services.md — add canopy-security endpoints, event subscription, table list

  • .claude/docs/security.md — document audit architecture, breach detection rules, NIST mapping

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · default