Plan: FTI Audit Logging

On this page

Status

Step Description Status

1

Shared FTI audit logging module in canopy-common (or a new canopy-fti-audit crate)

Done (2026-04-07) — fti_audit.rs with trait, Postgres impl, audited wrapper, scrub function, 4 unit tests

2

canopy-tanf integration: wire audit logging into all FTI access paths

Done (2026-04-07) — migration expanded, logger wired, 3 auditor query endpoints. FTI store functions depend on TANF eligibility plan.

3

canopy-medicaid migration and integration: fti_audit_log table + audit logging on all FTI access paths

Done (2026-04-07) — migration created, logger wired, 3 auditor query endpoints. FTI store functions depend on Medicaid eligibility plan.

4

FTI scrubbing middleware for event payloads

Done (2026-04-07) — scrub_fti_fields() in canopy-common + existing RESTRICTED_FIELDS in canopy-mq publisher (27 blocked field names)

5

Retention management (5-year minimum, configurable)

Done (2026-04-07) — archive_expired_records() and purge_archived_records() in canopy-common. Archive tables in both migrations.

6

Audit log query endpoint for IRS auditors

Done (2026-04-07) — GET /v1/fti-audit-log (list), GET /v1/fti-audit-log/{id} (single), GET /v1/fti-audit-log/summary (stats). Admin role required.

7

Tests

Done (2026-04-09) — 8 unit tests for FTI types + scrubbing + IRC citations in crates/canopy-common/src/fti_audit.rs; 4 integration tests in services/canopy-tanf/tests/tanf_test.rs (list_fti_audit_returns_array, fti_audit_summary_returns_data, get_fti_audit_entry_returns_404_for_fake, caseworker_fti_audit_returns_403); 4 integration tests in services/canopy-medicaid/tests/medicaid_test.rs (same pattern). 3 hash-chain integration tests added in MR !122 (services/canopy-tanf/tests/fti_audit_hash_chain_test.rs). All devstack-gated.

Epic: &34
Branch: feature/fti-audit-logging

Context

IRS Publication 1075 section 4 requires that every access to Federal Tax Information (FTI) be logged with sufficient detail to support IRS on-site inspection. ADR-004 (Legally-Scoped Data Tenancy) specifies that FTI audit logs:

  • Are stored in a separate table within the program service database, not in the shared canopy-security audit log

  • Are written directly to the database, NOT published to the canopy.events message bus

  • Are available for IRS on-site inspection independently of other audit logs

  • Record who accessed FTI, when, for what purpose, which data elements, and from which system

  • Are retained for a minimum of 5 years per Pub 1075

Two services hold FTI:

  • canopy-tanf — FTI authorized under IRC section 6103(l)(7) for TANF eligibility

  • canopy-medicaid — FTI authorized under IRC section 6103(l)(12) for Medicaid/CHIP eligibility

The fti_audit_log table schema already exists as a migration stub in services/canopy-tanf/migrations/20260325000001_create_fti_audit_log.sql. canopy-medicaid needs the same table.

The event bus restriction is critical: program services must NOT publish any FTI fields to canopy.events. Only IDs, status codes, and timestamps may appear in event payloads. The determination object (per ADR-002) satisfies this — it contains the outcome of FTI processing, not the FTI itself.

Scope

In scope:

  • Shared FtiAuditLogger trait and implementation usable by both canopy-tanf and canopy-medicaid

  • canopy-medicaid migration for fti_audit_log table (matching canopy-tanf schema)

  • Wrapper/middleware pattern that automatically logs FTI access on read or write

  • FTI purpose codes per IRS guidelines

  • Event payload scrubbing: compile-time and runtime guards against FTI leaking to event bus

  • Retention management: archive/purge strategy for records older than 5 years

  • Query endpoint for IRS auditors (read-only, restricted to auditor role)

Out of scope:

  • The FTI data itself (TANF and Medicaid program data is implemented in their respective plans)

  • Physical isolation (network segmentation, database-level access controls) — infrastructure concern

  • HSM encryption of FTI at rest — may be revisited per ADR-004

Design

FTI Audit Log Schema

The schema is already defined in canopy-tanf’s migration stub. canopy-medicaid gets an identical table:

-- services/canopy-medicaid/migrations/20260326000001_create_fti_audit_log.sql
-- FTI audit log per IRS Publication 1075.
-- This table is maintained separately from the application audit log
-- and is available for IRS on-site inspection independently.

CREATE TABLE IF NOT EXISTS fti_audit_log (
    id UUID PRIMARY KEY,
    accessed_by TEXT NOT NULL,              -- user ID or service account
    accessed_at TIMESTAMPTZ NOT NULL,       -- when the access occurred
    purpose_code TEXT NOT NULL,             -- IRS purpose code
    data_elements_accessed TEXT[] NOT NULL,  -- which FTI fields were accessed
    originating_system TEXT NOT NULL,       -- which service initiated the access
    action TEXT NOT NULL DEFAULT 'read',    -- read, write, delete
    resource_type TEXT NOT NULL,            -- e.g., 'tax_return', 'wage_data'
    resource_id UUID,                       -- ID of the specific record accessed
    request_id UUID,                        -- correlation ID for the originating request
    ip_address TEXT,                        -- source IP if available
    success BOOLEAN NOT NULL DEFAULT true,  -- whether the access succeeded
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_fti_audit_accessed_at ON fti_audit_log(accessed_at);
CREATE INDEX idx_fti_audit_accessed_by ON fti_audit_log(accessed_by);
CREATE INDEX idx_fti_audit_purpose_code ON fti_audit_log(purpose_code);

The canopy-tanf migration stub (20260325000001) will be updated to match this expanded schema (adding action, resource_type, resource_id, request_id, ip_address, success columns).

Purpose Codes

IRS Pub 1075 requires a purpose code for each FTI access. Canopy uses the following codes, mapped to authorized statutory use:

Code Description Statute

TANF_ELIG

TANF eligibility determination

IRC section 6103(l)(7)(A)

TANF_BENEFIT

TANF benefit calculation

IRC section 6103(l)(7)(A)

TANF_REDETERMINATION

TANF periodic redetermination

IRC section 6103(l)(7)(A)

MEDICAID_ELIG

Medicaid eligibility determination

IRC section 6103(l)(12)(A)

MEDICAID_MAGI

Medicaid MAGI income verification

IRC section 6103(l)(12)(A)

CHIP_ELIG

CHIP eligibility determination

IRC section 6103(l)(12)(A)

AUDIT_REVIEW

IRS auditor reviewing FTI access logs

Pub 1075 section 4

SYSTEM_MAINTENANCE

Authorized system maintenance (backup, archive)

Pub 1075 section 7

Shared FTI Audit Logger

/// Trait for logging FTI access. Implemented once, used by canopy-tanf and canopy-medicaid.
#[async_trait]
pub trait FtiAuditLogger: Send + Sync {
    async fn log_access(&self, entry: FtiAuditEntry) -> Result<(), FtiAuditError>;
    async fn query_log(&self, filter: FtiAuditFilter) -> Result<Vec<FtiAuditRecord>, FtiAuditError>;
}

/// Entry to be logged for every FTI access.
#[derive(Debug)]
pub struct FtiAuditEntry {
    pub accessed_by: String,
    pub purpose_code: FtiPurposeCode,
    pub data_elements: Vec<String>,
    pub originating_system: String,
    pub action: FtiAction,
    pub resource_type: String,
    pub resource_id: Option<Uuid>,
    pub request_id: Option<Uuid>,
    pub ip_address: Option<String>,
    pub success: bool,
}

#[derive(Debug, Clone, Copy)]
pub enum FtiAction {
    Read,
    Write,
    Delete,
}

#[derive(Debug, Clone)]
pub enum FtiPurposeCode {
    TanfElig,
    TanfBenefit,
    TanfRedetermination,
    MedicaidElig,
    MedicaidMagi,
    ChipElig,
    AuditReview,
    SystemMaintenance,
}

/// Concrete implementation that writes directly to the fti_audit_log table.
pub struct PostgresFtiAuditLogger {
    pool: DbPool,
}

impl PostgresFtiAuditLogger {
    pub fn new(pool: DbPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl FtiAuditLogger for PostgresFtiAuditLogger {
    async fn log_access(&self, entry: FtiAuditEntry) -> Result<(), FtiAuditError> {
        sqlx::query(
            "INSERT INTO fti_audit_log
                (id, accessed_by, accessed_at, purpose_code, data_elements_accessed,
                 originating_system, action, resource_type, resource_id,
                 request_id, ip_address, success)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"
        )
        .bind(Uuid::now_v7())
        .bind(&entry.accessed_by)
        .bind(Utc::now())
        .bind(entry.purpose_code.as_str())
        .bind(&entry.data_elements)
        .bind(&entry.originating_system)
        .bind(entry.action.as_str())
        .bind(&entry.resource_type)
        .bind(entry.resource_id)
        .bind(entry.request_id)
        .bind(entry.ip_address.as_deref())
        .bind(entry.success)
        .execute(self.pool.inner())
        .await?;
        Ok(())
    }

    async fn query_log(&self, filter: FtiAuditFilter) -> Result<Vec<FtiAuditRecord>, FtiAuditError> {
        // Query with pagination, date range, purpose code, user filters
        // Used by IRS auditor query endpoint
        todo!()
    }
}

FTI Access Wrapper

Every function that reads or writes FTI must be wrapped with automatic audit logging. Use a wrapper pattern rather than middleware because FTI access occurs at the store layer, not the HTTP layer:

/// Wraps a store function call with automatic FTI audit logging.
/// Usage:
///   let result = fti_audited(
///       &audit_logger,
///       FtiAuditEntry { ... },
///       store.read_tax_return(person_id),
///   ).await?;
pub async fn fti_audited<T, E>(
    logger: &dyn FtiAuditLogger,
    entry: FtiAuditEntry,
    operation: impl Future<Output = Result<T, E>>,
) -> Result<T, FtiAuditError>
where
    E: Into<FtiAuditError>,
{
    let result = operation.await;
    let success = result.is_ok();
    let mut entry = entry;
    entry.success = success;
    // Always log, even on failure — Pub 1075 requires logging failed access attempts
    logger.log_access(entry).await?;
    result.map_err(Into::into)
}

Event Payload Scrubbing

ADR-004 requires that events published to canopy.events contain NO FTI fields. Two layers of protection:

  1. Coding convention: FTI-holding structs are NOT Serialize for event purposes. Define a separate EventPayload struct for each event type that contains only IDs and timestamps.

  2. Runtime guard: A scrub_fti_fields function that strips known FTI field names from arbitrary JSON before publishing:

/// FTI field names that must never appear in event payloads.
const FTI_FIELD_NAMES: &[&str] = &[
    "tax_return", "adjusted_gross_income", "agi", "filing_status",
    "taxable_income", "tax_liability", "w2_wages", "1099_income",
    "fti_data", "irs_data", "federal_tax",
];

/// Removes any FTI-named fields from a JSON value.
/// This is a defense-in-depth measure; the primary protection is
/// using separate EventPayload structs that never include FTI.
pub fn scrub_fti_fields(value: &mut serde_json::Value) {
    if let serde_json::Value::Object(map) = value {
        map.retain(|key, _| !FTI_FIELD_NAMES.iter().any(|f| key.contains(f)));
        for (_, v) in map.iter_mut() {
            scrub_fti_fields(v);
        }
    }
}

IRS Auditor Query Endpoint

Each FTI-holding service exposes a read-only audit log query endpoint restricted to the fti_auditor role:

Method Path Description

GET

/v1/fti-audit-log

Query FTI audit log with filters (date range, user, purpose code). Paginated. Requires fti_auditor role.

GET

/v1/fti-audit-log/{id}

Get a single audit log entry by ID. Requires fti_auditor role.

GET

/v1/fti-audit-log/summary

Summary statistics: total accesses by purpose code, by user, by date. For IRS inspection dashboards.

Steps

Step 1: Shared FTI Audit Module

Files: crates/canopy-common/src/fti_audit.rs, crates/canopy-common/src/lib.rs

Place in canopy-common because both canopy-tanf and canopy-medicaid depend on it. If the FTI audit module grows large, it can be extracted to a canopy-fti-audit crate later.

Add async-trait dependency to canopy-common if not already present.

Core Rust types

/// Record of a single FTI data access, per IRS Publication 1075 section 4.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct FtiAuditEntry {
    pub id: Uuid,
    pub accessed_by: String,
    pub accessed_at: DateTime<Utc>,
    pub purpose_code: String,
    pub data_elements_accessed: Vec<String>,
    pub originating_system: String,
    pub action: String,
    pub resource_type: String,
    pub resource_id: Option<Uuid>,
    pub request_id: Option<Uuid>,
    pub ip_address: Option<String>,
    pub success: bool,
    pub created_at: DateTime<Utc>,
}

/// Purpose codes mapped to authorizing IRC sections.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FtiPurposeCode {
    /// IRC section 6103(l)(7) -- TANF eligibility determination
    TanfEligibility,
    /// IRC section 6103(l)(7) -- TANF benefit computation
    TanfBenefitComputation,
    /// IRC section 6103(l)(7) -- TANF periodic redetermination
    TanfRedetermination,
    /// IRC section 6103(l)(12) -- Medicaid eligibility determination
    MedicaidEligibility,
    /// IRC section 6103(l)(12) -- Medicaid MAGI income verification
    MedicaidMagi,
    /// IRC section 6103(l)(12) -- CHIP eligibility determination
    ChipEligibility,
    /// Pub 1075 section 4 -- IRS auditor reviewing FTI access logs
    AuditReview,
    /// Pub 1075 section 7 -- Authorized system maintenance
    SystemMaintenance,
}

impl FtiPurposeCode {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::TanfEligibility => "TANF_ELIG",
            Self::TanfBenefitComputation => "TANF_BENEFIT",
            Self::TanfRedetermination => "TANF_REDETERMINATION",
            Self::MedicaidEligibility => "MEDICAID_ELIG",
            Self::MedicaidMagi => "MEDICAID_MAGI",
            Self::ChipEligibility => "CHIP_ELIG",
            Self::AuditReview => "AUDIT_REVIEW",
            Self::SystemMaintenance => "SYSTEM_MAINTENANCE",
        }
    }

    pub fn irc_section(&self) -> &'static str {
        match self {
            Self::TanfEligibility | Self::TanfBenefitComputation | Self::TanfRedetermination
                => "IRC 6103(l)(7)(A)",
            Self::MedicaidEligibility | Self::MedicaidMagi | Self::ChipEligibility
                => "IRC 6103(l)(12)(A)",
            Self::AuditReview => "Pub 1075 section 4",
            Self::SystemMaintenance => "Pub 1075 section 7",
        }
    }
}

/// Actions performed on FTI data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FtiAction {
    Read,
    Write,
    Delete,
}

impl FtiAction {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Read => "read",
            Self::Write => "write",
            Self::Delete => "delete",
        }
    }
}

/// Filter for querying the FTI audit log. Used by the auditor query endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct FtiAuditFilter {
    pub from: Option<DateTime<Utc>>,
    pub to: Option<DateTime<Utc>>,
    pub accessed_by: Option<String>,
    pub purpose_code: Option<String>,
    pub action: Option<String>,
    pub resource_type: Option<String>,
    pub page: Option<i64>,
    pub page_size: Option<i64>,
}

/// Paginated query result returned by the auditor query endpoint.
#[derive(Debug, Serialize)]
pub struct FtiAuditPage {
    pub items: Vec<FtiAuditEntry>,
    pub total: i64,
    pub page: i64,
    pub page_size: i64,
}

/// Summary statistics for the IRS inspection dashboard.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FtiAuditSummaryRow {
    pub purpose_code: String,
    pub access_count: i64,
    pub unique_users: i64,
    pub first_access: DateTime<Utc>,
    pub last_access: DateTime<Utc>,
}

/// Error type for FTI audit operations.
#[derive(Debug, thiserror::Error)]
pub enum FtiAuditError {
    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("unauthorized: {0}")]
    Unauthorized(String),
    #[error("invalid purpose code: {0}")]
    InvalidPurposeCode(String),
}

FTI audit logger trait and Postgres implementation

/// Trait for logging FTI access. Implemented once, used by canopy-tanf and canopy-medicaid.
#[async_trait]
pub trait FtiAuditLogger: Send + Sync {
    async fn log_access(&self, entry: FtiAuditEntry) -> Result<(), FtiAuditError>;
    async fn query_log(&self, filter: FtiAuditFilter) -> Result<FtiAuditPage, FtiAuditError>;
    async fn get_entry(&self, id: Uuid) -> Result<Option<FtiAuditEntry>, FtiAuditError>;
    async fn summary(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Result<Vec<FtiAuditSummaryRow>, FtiAuditError>;
}

/// Concrete implementation that writes directly to the fti_audit_log table.
pub struct PostgresFtiAuditLogger {
    pool: DbPool,
}

impl PostgresFtiAuditLogger {
    pub fn new(pool: DbPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl FtiAuditLogger for PostgresFtiAuditLogger {
    async fn log_access(&self, entry: FtiAuditEntry) -> Result<(), FtiAuditError> {
        sqlx::query(
            "INSERT INTO fti_audit_log
                (id, accessed_by, accessed_at, purpose_code, data_elements_accessed,
                 originating_system, action, resource_type, resource_id,
                 request_id, ip_address, success)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"
        )
        .bind(Uuid::now_v7())
        .bind(&entry.accessed_by)
        .bind(Utc::now())
        .bind(&entry.purpose_code)
        .bind(&entry.data_elements_accessed)
        .bind(&entry.originating_system)
        .bind(&entry.action)
        .bind(&entry.resource_type)
        .bind(entry.resource_id)
        .bind(entry.request_id)
        .bind(entry.ip_address.as_deref())
        .bind(entry.success)
        .execute(self.pool.inner())
        .await?;
        Ok(())
    }

    async fn query_log(&self, filter: FtiAuditFilter) -> Result<FtiAuditPage, FtiAuditError> {
        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 fti_audit_log
             WHERE ($1::timestamptz IS NULL OR accessed_at >= $1)
               AND ($2::timestamptz IS NULL OR accessed_at <= $2)
               AND ($3::text IS NULL OR accessed_by = $3)
               AND ($4::text IS NULL OR purpose_code = $4)
               AND ($5::text IS NULL OR action = $5)
               AND ($6::text IS NULL OR resource_type = $6)"
        )
        .bind(filter.from)
        .bind(filter.to)
        .bind(&filter.accessed_by)
        .bind(&filter.purpose_code)
        .bind(&filter.action)
        .bind(&filter.resource_type)
        .fetch_one(self.pool.inner())
        .await?;

        let items = sqlx::query_as::<_, FtiAuditEntry>(
            "SELECT * FROM fti_audit_log
             WHERE ($1::timestamptz IS NULL OR accessed_at >= $1)
               AND ($2::timestamptz IS NULL OR accessed_at <= $2)
               AND ($3::text IS NULL OR accessed_by = $3)
               AND ($4::text IS NULL OR purpose_code = $4)
               AND ($5::text IS NULL OR action = $5)
               AND ($6::text IS NULL OR resource_type = $6)
             ORDER BY accessed_at DESC
             LIMIT $7 OFFSET $8"
        )
        .bind(filter.from)
        .bind(filter.to)
        .bind(&filter.accessed_by)
        .bind(&filter.purpose_code)
        .bind(&filter.action)
        .bind(&filter.resource_type)
        .bind(page_size)
        .bind(offset)
        .fetch_all(self.pool.inner())
        .await?;

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

    async fn get_entry(&self, id: Uuid) -> Result<Option<FtiAuditEntry>, FtiAuditError> {
        let entry = sqlx::query_as::<_, FtiAuditEntry>(
            "SELECT * FROM fti_audit_log WHERE id = $1"
        )
        .bind(id)
        .fetch_optional(self.pool.inner())
        .await?;
        Ok(entry)
    }

    async fn summary(
        &self,
        from: DateTime<Utc>,
        to: DateTime<Utc>,
    ) -> Result<Vec<FtiAuditSummaryRow>, FtiAuditError> {
        let rows = sqlx::query_as::<_, FtiAuditSummaryRow>(
            "SELECT
                purpose_code,
                COUNT(*) AS access_count,
                COUNT(DISTINCT accessed_by) AS unique_users,
                MIN(accessed_at) AS first_access,
                MAX(accessed_at) AS last_access
             FROM fti_audit_log
             WHERE accessed_at >= $1 AND accessed_at <= $2
             GROUP BY purpose_code
             ORDER BY access_count DESC"
        )
        .bind(from)
        .bind(to)
        .fetch_all(self.pool.inner())
        .await?;
        Ok(rows)
    }
}

Audit wrapper function

/// Wrap any FTI data access with automatic audit logging.
/// Use this for every function that reads or writes FTI data.
///
/// The audit entry is written BEFORE the operation executes.
/// If the operation subsequently fails, we still have the audit record.
/// After the operation completes, the entry's success field is updated.
pub async fn fti_audited<T, F, Fut>(
    pool: &PgPool,
    accessed_by: &str,
    purpose_code: FtiPurposeCode,
    data_elements: &[&str],
    originating_system: &str,
    action: FtiAction,
    resource_type: &str,
    resource_id: Option<Uuid>,
    request_id: Option<Uuid>,
    ip_address: Option<&str>,
    operation: F,
) -> Result<T, FtiAuditError>
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = Result<T, FtiAuditError>>,
{
    let entry_id = Uuid::now_v7();
    let now = Utc::now();

    // Log BEFORE the access -- if the access fails, we still have the audit record
    sqlx::query(
        "INSERT INTO fti_audit_log
            (id, accessed_by, accessed_at, purpose_code, data_elements_accessed,
             originating_system, action, resource_type, resource_id,
             request_id, ip_address, success)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true)"
    )
    .bind(entry_id)
    .bind(accessed_by)
    .bind(now)
    .bind(purpose_code.as_str())
    .bind(&data_elements.iter().map(|s| s.to_string()).collect::<Vec<_>>())
    .bind(originating_system)
    .bind(action.as_str())
    .bind(resource_type)
    .bind(resource_id)
    .bind(request_id)
    .bind(ip_address)
    .execute(pool)
    .await?;

    // Perform the actual FTI data access
    let result = operation().await;

    // If the operation failed, update the audit record to reflect failure
    if result.is_err() {
        let _ = sqlx::query("UPDATE fti_audit_log SET success = false WHERE id = $1")
            .bind(entry_id)
            .execute(pool)
            .await;
    }

    result
}

Event payload scrubbing

/// FTI field names that must never appear in event payloads.
const FTI_FIELD_NAMES: &[&str] = &[
    "tax_return", "adjusted_gross_income", "agi", "filing_status",
    "taxable_income", "tax_liability", "w2_wages", "1099_income",
    "fti_data", "irs_data", "federal_tax", "wages_salaries_tips",
    "self_employment_income", "social_security_benefits",
    "tax_exempt_interest", "foreign_earned_income", "fti_gross_income",
];

/// Removes any FTI-named fields from a JSON value.
/// This is a defense-in-depth measure; the primary protection is
/// using separate EventPayload structs that never include FTI.
pub fn scrub_fti_fields(value: &mut serde_json::Value) {
    if let serde_json::Value::Object(map) = value {
        map.retain(|key, _| !FTI_FIELD_NAMES.iter().any(|f| key.contains(f)));
        for (_, v) in map.iter_mut() {
            scrub_fti_fields(v);
        }
    }
}

Example of WRONG vs. RIGHT event publishing:

// WRONG -- never do this:
// publisher.publish(&EventEnvelope::new("canopy-tanf", "tanf.determined",
//     json!({ "household_id": id, "fti_gross_income": 45000 })))  // FTI FIELD!

// RIGHT -- IDs and status only:
publisher.publish(&EventEnvelope::new("canopy-tanf", "tanf.determined",
    json!({ "household_id": id, "status": "approved", "determined_at": Utc::now() })))

Module registration

In crates/canopy-common/src/lib.rs, add:

pub mod fti_audit;

Step 2: Update canopy-tanf FTI Audit Migration

Files: services/canopy-tanf/migrations/20260325000001_create_fti_audit_log.sql

Update the existing stub migration to include the expanded columns (action, resource_type, resource_id, request_id, ip_address, success) and indexes.

Full SQL:

-- services/canopy-tanf/migrations/20260325000001_create_fti_audit_log.sql
-- FTI audit log per IRS Publication 1075 section 4.
-- Maintained separately from the application audit log.
-- Available for IRS on-site inspection independently.

CREATE TABLE IF NOT EXISTS fti_audit_log (
    id UUID PRIMARY KEY,
    accessed_by TEXT NOT NULL,
    accessed_at TIMESTAMPTZ NOT NULL,
    purpose_code TEXT NOT NULL,
    data_elements_accessed TEXT[] NOT NULL,
    originating_system TEXT NOT NULL,
    action TEXT NOT NULL DEFAULT 'read',
    resource_type TEXT NOT NULL,
    resource_id UUID,
    request_id UUID,
    ip_address TEXT,
    success BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_fti_audit_accessed_at ON fti_audit_log(accessed_at);
CREATE INDEX idx_fti_audit_accessed_by ON fti_audit_log(accessed_by);
CREATE INDEX idx_fti_audit_purpose_code ON fti_audit_log(purpose_code);

-- Archive table for retention management (Pub 1075: 5-year minimum).
CREATE TABLE IF NOT EXISTS fti_audit_log_archive (
    LIKE fti_audit_log INCLUDING ALL
);

Step 3: canopy-tanf Integration

Files: services/canopy-tanf/src/fti_audit.rs (new), services/canopy-tanf/src/main.rs

Create fti_audit.rs that instantiates PostgresFtiAuditLogger and wires it into the service:

use canopy_common::fti_audit::{PostgresFtiAuditLogger, FtiAuditLogger};
use canopy_db::DbPool;

pub fn create_fti_audit_logger(db: &DbPool) -> PostgresFtiAuditLogger {
    PostgresFtiAuditLogger::new(db.clone())
}

In main.rs, add the audit logger as an Axum Extension:

// In main.rs, after bootstrap:
let fti_audit_logger = Arc::new(fti_audit::create_fti_audit_logger(&boot.db));
// Add to router as Extension
let router = router.layer(Extension(fti_audit_logger as Arc<dyn FtiAuditLogger>));

Every store function in canopy-tanf that reads or writes FTI must use the fti_audited wrapper. The purpose codes for canopy-tanf are TanfEligibility, TanfBenefitComputation, TanfRedetermination.

Example FTI-wrapped data access:

use canopy_common::fti_audit::{fti_audited, FtiPurposeCode, FtiAction, FtiAuditError};

/// Read FTI tax data for a TANF application with automatic audit logging.
pub async fn read_fti_tax_data(
    pool: &PgPool,
    tanf_application_id: Uuid,
    person_id: Uuid,
    accessed_by: &str,
    request_id: Option<Uuid>,
    ip_address: Option<&str>,
) -> Result<Vec<FtiTaxData>, FtiAuditError> {
    fti_audited(
        pool,
        accessed_by,
        FtiPurposeCode::TanfEligibility,
        &["adjusted_gross_income", "filing_status", "wages_salaries_tips"],
        "canopy-tanf",
        FtiAction::Read,
        "fti_tax_data",
        None,
        request_id,
        ip_address,
        || async {
            sqlx::query_as::<_, FtiTaxData>(
                "SELECT id, tanf_application_id, person_id, tax_year,
                        filing_status, adjusted_gross_income, wages_salaries_tips,
                        self_employment_income, received_at, created_at
                 FROM fti_tax_data
                 WHERE tanf_application_id = $1 AND person_id = $2"
            )
            .bind(tanf_application_id)
            .bind(person_id)
            .fetch_all(pool)
            .await
            .map_err(FtiAuditError::Database)
        },
    )
    .await
}

Step 4: canopy-medicaid FTI Audit Migration

Files: services/canopy-medicaid/migrations/20260326000001_create_fti_audit_log.sql

Create the fti_audit_log table identical to canopy-tanf’s expanded schema:

-- services/canopy-medicaid/migrations/20260326000001_create_fti_audit_log.sql
-- FTI audit log per IRS Publication 1075 section 4.
-- Maintained separately from the application audit log.
-- Available for IRS on-site inspection independently.

CREATE TABLE IF NOT EXISTS fti_audit_log (
    id UUID PRIMARY KEY,
    accessed_by TEXT NOT NULL,
    accessed_at TIMESTAMPTZ NOT NULL,
    purpose_code TEXT NOT NULL,
    data_elements_accessed TEXT[] NOT NULL,
    originating_system TEXT NOT NULL,
    action TEXT NOT NULL DEFAULT 'read',
    resource_type TEXT NOT NULL,
    resource_id UUID,
    request_id UUID,
    ip_address TEXT,
    success BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_fti_audit_accessed_at ON fti_audit_log(accessed_at);
CREATE INDEX idx_fti_audit_accessed_by ON fti_audit_log(accessed_by);
CREATE INDEX idx_fti_audit_purpose_code ON fti_audit_log(purpose_code);

-- Archive table for retention management.
CREATE TABLE IF NOT EXISTS fti_audit_log_archive (
    LIKE fti_audit_log INCLUDING ALL
);

Step 5: canopy-medicaid Integration

Files: services/canopy-medicaid/src/fti_audit.rs (new), services/canopy-medicaid/src/main.rs

Same wiring pattern as canopy-tanf. Purpose codes for canopy-medicaid: MedicaidEligibility, MedicaidMagi, ChipEligibility.

use canopy_common::fti_audit::{PostgresFtiAuditLogger, FtiAuditLogger};
use canopy_db::DbPool;

pub fn create_fti_audit_logger(db: &DbPool) -> PostgresFtiAuditLogger {
    PostgresFtiAuditLogger::new(db.clone())
}

Example Medicaid FTI-wrapped access:

pub async fn read_fti_tax_data_for_magi(
    pool: &PgPool,
    medicaid_application_id: Uuid,
    person_id: Uuid,
    accessed_by: &str,
    request_id: Option<Uuid>,
    ip_address: Option<&str>,
) -> Result<Vec<FtiTaxData>, FtiAuditError> {
    fti_audited(
        pool,
        accessed_by,
        FtiPurposeCode::MedicaidMagi,
        &["adjusted_gross_income", "filing_status", "wages_salaries_tips",
          "social_security_benefits", "tax_exempt_interest", "foreign_earned_income"],
        "canopy-medicaid",
        FtiAction::Read,
        "fti_tax_data",
        None,
        request_id,
        ip_address,
        || async {
            sqlx::query_as::<_, FtiTaxData>(
                "SELECT * FROM fti_tax_data
                 WHERE medicaid_application_id = $1 AND person_id = $2"
            )
            .bind(medicaid_application_id)
            .bind(person_id)
            .fetch_all(pool)
            .await
            .map_err(FtiAuditError::Database)
        },
    )
    .await
}

Step 6: Event Payload Scrubbing

Files: services/canopy-tanf/src/events.rs, services/canopy-medicaid/src/events.rs

Define separate EventPayload structs for each event type that contain ONLY IDs, status codes, and timestamps. Apply scrub_fti_fields as a defense-in-depth measure before publishing any event.

Add a compile-time lint: FTI-holding structs should NOT derive Serialize for the event payload path. Use a separate, minimal struct:

/// Published to canopy.events when TANF determination completes.
/// Contains NO FTI -- only IDs and status per ADR-004.
#[derive(Serialize)]
pub struct TanfDeterminedEvent {
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub status: String,  // "approved" / "denied" / "pending"
    pub determined_at: DateTime<Utc>,
}

/// Published to canopy.events when Medicaid determination completes.
/// Contains NO FTI, NO FDSH details, NO clinical data.
#[derive(Serialize)]
pub struct MedicaidDeterminedEvent {
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub status: String,
    pub assigned_category: Option<String>,
    pub determined_at: DateTime<Utc>,
}

Publishing function with defense-in-depth scrubbing:

pub async fn publish_tanf_determined(
    publisher: &Publisher,
    application_id: Uuid,
    household_id: Uuid,
    status: &str,
) -> Result<(), lapin::Error> {
    let payload = serde_json::json!({
        "application_id": application_id,
        "household_id": household_id,
        "status": status,
        "determined_at": Utc::now(),
    });
    // Defense-in-depth: scrub even though we constructed a clean payload
    let mut payload = payload;
    canopy_common::fti_audit::scrub_fti_fields(&mut payload);

    let envelope = EventEnvelope::new("canopy-tanf", "tanf.determined", payload);
    publisher.publish(&envelope).await
}

Step 7: Retention Management

Files: crates/canopy-common/src/fti_audit.rs

Add a retention management function:

/// Archive FTI audit records older than the retention period.
/// Default retention: 5 years per Pub 1075.
/// Records are not deleted -- they are moved to fti_audit_log_archive.
pub async fn archive_expired_records(
    pool: &PgPool,
    retention_years: u32,
) -> Result<u64, FtiAuditError> {
    let cutoff = Utc::now() - chrono::Duration::days(retention_years as i64 * 365);

    // Move old records to archive in a single transaction
    let mut tx = pool.begin().await?;

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

    tx.commit().await?;

    Ok(archived.rows_affected())
}

/// Purge archived records older than the purge threshold.
/// Default: 7 years (Pub 1075 minimum is 5, we retain archived 2 extra).
pub async fn purge_archived_records(
    pool: &PgPool,
    purge_years: u32,
) -> Result<u64, FtiAuditError> {
    let cutoff = Utc::now() - chrono::Duration::days(purge_years as i64 * 365);

    let purged = sqlx::query(
        "DELETE FROM fti_audit_log_archive WHERE accessed_at < $1"
    )
    .bind(cutoff)
    .execute(pool)
    .await?;

    Ok(purged.rows_affected())
}

Create the archive table in both TANF and Medicaid migrations (shown in Steps 2 and 4 above).

Step 8: Auditor Query Endpoint

Files: services/canopy-tanf/src/api/fti_audit.rs (new), services/canopy-medicaid/src/api/fti_audit.rs (new)

Implement the three GET endpoints from the Design section. Restrict to fti_auditor role via canopy-auth claim check.

use axum::{extract::{Extension, Path, Query, State}, Json};
use canopy_common::fti_audit::{
    FtiAuditEntry, FtiAuditFilter, FtiAuditPage, FtiAuditSummaryRow, FtiAuditLogger,
};
use std::sync::Arc;

/// Query parameters for the FTI audit log list endpoint.
#[derive(Debug, Deserialize)]
pub struct FtiAuditQuery {
    pub from: Option<DateTime<Utc>>,
    pub to: Option<DateTime<Utc>>,
    pub accessed_by: Option<String>,
    pub purpose_code: Option<String>,
    pub page: Option<i64>,
    pub page_size: Option<i64>,
}

/// GET /v1/fti-audit-log
/// Requires fti_auditor role (not caseworker, not admin -- dedicated role).
pub async fn list_fti_audit(
    claims: Extension<Claims>,
    Query(params): Query<FtiAuditQuery>,
    State(logger): State<Arc<dyn FtiAuditLogger>>,
) -> Result<Json<FtiAuditPage>, ApiError> {
    claims.require_role("fti_auditor")?;

    let filter = FtiAuditFilter {
        from: params.from,
        to: params.to,
        accessed_by: params.accessed_by,
        purpose_code: params.purpose_code,
        action: None,
        resource_type: None,
        page: params.page,
        page_size: params.page_size,
    };

    let page = logger.query_log(filter).await.map_err(ApiError::internal)?;
    Ok(Json(page))
}

/// GET /v1/fti-audit-log/{id}
/// Requires fti_auditor role.
pub async fn get_fti_audit_entry(
    claims: Extension<Claims>,
    Path(id): Path<Uuid>,
    State(logger): State<Arc<dyn FtiAuditLogger>>,
) -> Result<Json<FtiAuditEntry>, ApiError> {
    claims.require_role("fti_auditor")?;

    let entry = logger.get_entry(id).await.map_err(ApiError::internal)?;
    match entry {
        Some(e) => Ok(Json(e)),
        None => Err(ApiError::not_found("fti_audit_log", id)),
    }
}

/// GET /v1/fti-audit-log/summary
/// Requires fti_auditor role.
pub async fn fti_audit_summary(
    claims: Extension<Claims>,
    Query(params): Query<FtiAuditSummaryQuery>,
    State(logger): State<Arc<dyn FtiAuditLogger>>,
) -> Result<Json<Vec<FtiAuditSummaryRow>>, ApiError> {
    claims.require_role("fti_auditor")?;

    let from = params.from.unwrap_or_else(|| Utc::now() - chrono::Duration::days(365));
    let to = params.to.unwrap_or_else(Utc::now);

    let summary = logger.summary(from, to).await.map_err(ApiError::internal)?;
    Ok(Json(summary))
}

#[derive(Debug, Deserialize)]
pub struct FtiAuditSummaryQuery {
    pub from: Option<DateTime<Utc>>,
    pub to: Option<DateTime<Utc>>,
}

JSON response example for GET /v1/fti-audit-log?from=2025-01-01&to=2026-01-01&page=0&page_size=10:

{
  "items": [
    {
      "id": "019513a0-7f8b-7000-8000-000000000001",
      "accessed_by": "caseworker-jane@agency.gov",
      "accessed_at": "2025-11-15T14:22:33Z",
      "purpose_code": "TANF_ELIG",
      "data_elements_accessed": ["adjusted_gross_income", "filing_status"],
      "originating_system": "canopy-tanf",
      "action": "read",
      "resource_type": "fti_tax_data",
      "resource_id": "019513a0-7f8b-7000-8000-000000000099",
      "request_id": "019513a0-7f8b-7000-8000-000000000050",
      "ip_address": "10.0.1.42",
      "success": true,
      "created_at": "2025-11-15T14:22:33Z"
    }
  ],
  "total": 1,
  "page": 0,
  "page_size": 10
}

Step 9: Tests

Files: crates/canopy-common/src/fti_audit.rs (unit tests), services/canopy-tanf/tests/fti_audit.rs (integration), services/canopy-medicaid/tests/fti_audit.rs (integration)

Unit tests in crates/canopy-common/src/fti_audit.rs

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

    #[test]
    fn scrub_fti_fields_removes_known_fti_field_names() {
        let mut payload = serde_json::json!({
            "household_id": "abc-123",
            "adjusted_gross_income": 45000,
            "status": "approved"
        });
        scrub_fti_fields(&mut payload);
        assert!(payload.get("adjusted_gross_income").is_none());
        assert!(payload.get("household_id").is_some());
        assert!(payload.get("status").is_some());
    }

    #[test]
    fn scrub_fti_fields_handles_nested_objects() {
        let mut payload = serde_json::json!({
            "household_id": "abc-123",
            "details": {
                "fti_data": { "agi": 50000 },
                "status": "ok"
            }
        });
        scrub_fti_fields(&mut payload);
        let details = payload.get("details").unwrap().as_object().unwrap();
        assert!(details.get("fti_data").is_none());
        assert!(details.get("status").is_some());
    }

    #[test]
    fn scrub_fti_fields_preserves_non_fti_fields() {
        let mut payload = serde_json::json!({
            "household_id": "abc-123",
            "status": "approved",
            "determined_at": "2025-11-15T14:22:33Z"
        });
        let original = payload.clone();
        scrub_fti_fields(&mut payload);
        assert_eq!(payload, original);
    }

    #[test]
    fn fti_purpose_code_string_roundtrip() {
        let code = FtiPurposeCode::TanfEligibility;
        assert_eq!(code.as_str(), "TANF_ELIG");
        assert_eq!(code.irc_section(), "IRC 6103(l)(7)(A)");

        let code = FtiPurposeCode::MedicaidEligibility;
        assert_eq!(code.as_str(), "MEDICAID_ELIG");
        assert_eq!(code.irc_section(), "IRC 6103(l)(12)(A)");
    }

    #[test]
    fn fti_purpose_codes_map_to_irc_sections() {
        // All TANF codes map to 6103(l)(7)
        assert!(FtiPurposeCode::TanfEligibility.irc_section().contains("6103(l)(7)"));
        assert!(FtiPurposeCode::TanfBenefitComputation.irc_section().contains("6103(l)(7)"));
        assert!(FtiPurposeCode::TanfRedetermination.irc_section().contains("6103(l)(7)"));

        // All Medicaid/CHIP codes map to 6103(l)(12)
        assert!(FtiPurposeCode::MedicaidEligibility.irc_section().contains("6103(l)(12)"));
        assert!(FtiPurposeCode::MedicaidMagi.irc_section().contains("6103(l)(12)"));
        assert!(FtiPurposeCode::ChipEligibility.irc_section().contains("6103(l)(12)"));
    }

    #[test]
    fn fti_action_as_str() {
        assert_eq!(FtiAction::Read.as_str(), "read");
        assert_eq!(FtiAction::Write.as_str(), "write");
        assert_eq!(FtiAction::Delete.as_str(), "delete");
    }
}

Integration tests in services/canopy-tanf/tests/fti_audit.rs

use canopy_common::fti_audit::*;

#[tokio::test]
async fn fti_access_creates_audit_entry() {
    // Setup: testcontainers Postgres, run migrations
    // Act: call fti_audited with a mock FTI read operation
    // Assert: query fti_audit_log, verify exactly one entry with correct fields
    //   - accessed_by matches the test user
    //   - purpose_code = "TANF_ELIG"
    //   - data_elements_accessed = ["adjusted_gross_income", "filing_status"]
    //   - originating_system = "canopy-tanf"
    //   - action = "read"
    //   - success = true
}

#[tokio::test]
async fn fti_audit_log_independent_of_app_audit() {
    // Setup: testcontainers Postgres with BOTH fti_audit_log and audit_events tables
    // Act: perform an FTI read (which creates fti_audit_log entry)
    //      perform a non-FTI operation (which would create audit_events entry via event bus)
    // Assert: fti_audit_log has exactly 1 entry
    //         audit_events has 0 entries (FTI audit does not go through event bus)
}

#[tokio::test]
async fn fti_audit_logs_failed_access_attempts() {
    // Setup: testcontainers Postgres, run migrations
    // Act: call fti_audited with an operation that returns Err
    // Assert: fti_audit_log entry exists with success = false
}

#[tokio::test]
async fn scrub_fti_from_event_payload() {
    // Setup: construct a JSON payload that accidentally includes FTI fields
    // Act: call scrub_fti_fields
    // Assert: all FTI fields removed, non-FTI fields preserved
    let mut payload = serde_json::json!({
        "household_id": "abc",
        "fti_gross_income": 45000,
        "adjusted_gross_income": 45000,
        "status": "approved",
        "determined_at": "2025-11-15T14:22:33Z"
    });
    scrub_fti_fields(&mut payload);
    assert!(payload.get("fti_gross_income").is_none());
    assert!(payload.get("adjusted_gross_income").is_none());
    assert!(payload.get("household_id").is_some());
    assert!(payload.get("status").is_some());
}

#[tokio::test]
async fn fti_auditor_role_required_for_query() {
    // Setup: testcontainers Postgres, run migrations, start Axum test server
    // Act: call GET /v1/fti-audit-log with a token that has role "caseworker" (not "fti_auditor")
    // Assert: response status 403
    // Act: call GET /v1/fti-audit-log with a token that has role "fti_auditor"
    // Assert: response status 200
}

#[tokio::test]
async fn fti_audit_query_pagination() {
    // Setup: insert 25 fti_audit_log entries
    // Act: query with page_size=10, page=0
    // Assert: 10 items returned, total=25, page=0
    // Act: query with page_size=10, page=2
    // Assert: 5 items returned, total=25, page=2
}

#[tokio::test]
async fn fti_audit_retention_archive() {
    // Setup: insert entries with accessed_at spanning 6 years
    // Act: call archive_expired_records with retention_years=5
    // Assert: entries older than 5 years moved to fti_audit_log_archive
    //         entries newer than 5 years remain in fti_audit_log
}

Files Touched

File Change

crates/canopy-common/src/fti_audit.rs

New: FtiAuditEntry, FtiPurposeCode, FtiAction, FtiAuditFilter, FtiAuditPage, FtiAuditSummaryRow, FtiAuditError, FtiAuditLogger trait, PostgresFtiAuditLogger, fti_audited wrapper, scrub_fti_fields, archive/purge functions, unit tests

crates/canopy-common/src/lib.rs

Add pub mod fti_audit;

services/canopy-tanf/migrations/20260325000001_create_fti_audit_log.sql

Update: expanded columns, indexes, archive table

services/canopy-tanf/src/fti_audit.rs

New: wire FtiAuditLogger, service-specific purpose codes

services/canopy-tanf/src/main.rs

Wire audit logger into startup

services/canopy-tanf/src/events.rs

Add FTI-free EventPayload structs, scrub_fti_fields calls

services/canopy-tanf/src/api/fti_audit.rs

New: auditor query endpoints

services/canopy-medicaid/migrations/20260326000001_create_fti_audit_log.sql

New: fti_audit_log table, indexes, archive table

services/canopy-medicaid/src/fti_audit.rs

New: wire FtiAuditLogger, service-specific purpose codes

services/canopy-medicaid/src/main.rs

Wire audit logger into startup

services/canopy-medicaid/src/events.rs

Add FTI-free EventPayload structs, scrub_fti_fields calls

services/canopy-medicaid/src/api/fti_audit.rs

New: auditor query endpoints

crates/canopy-common/Cargo.toml

Add async-trait if not present

Verification

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

  2. cargo xtask dev restart — both migrations run successfully

  3. cargo nextest run -p canopy-tanf — FTI audit integration tests pass

  4. cargo nextest run -p canopy-medicaid — FTI audit integration tests pass

  5. Manual: trigger an FTI access in canopy-tanf, query the audit log endpoint, verify the entry

  6. Manual: publish an event from canopy-tanf, inspect RabbitMQ payload, confirm no FTI fields present

  7. Manual: attempt audit log query without fti_auditor role, confirm 403

Documentation Updates

  • .claude/docs/services.md — add FTI audit endpoints to canopy-tanf and canopy-medicaid

  • .claude/docs/security.md — document FTI audit logging pattern, Pub 1075 compliance approach

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/docs/architecture.md — document FTI isolation and audit logging architecture

Edit this page · default