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 |
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-securityaudit log -
Are written directly to the database, NOT published to the
canopy.eventsmessage 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
FtiAuditLoggertrait and implementation usable by both canopy-tanf and canopy-medicaid -
canopy-medicaid migration for
fti_audit_logtable (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 eligibility determination |
IRC section 6103(l)(7)(A) |
|
TANF benefit calculation |
IRC section 6103(l)(7)(A) |
|
TANF periodic redetermination |
IRC section 6103(l)(7)(A) |
|
Medicaid eligibility determination |
IRC section 6103(l)(12)(A) |
|
Medicaid MAGI income verification |
IRC section 6103(l)(12)(A) |
|
CHIP eligibility determination |
IRC section 6103(l)(12)(A) |
|
IRS auditor reviewing FTI access logs |
Pub 1075 section 4 |
|
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:
-
Coding convention: FTI-holding structs are NOT
Serializefor event purposes. Define a separateEventPayloadstruct for each event type that contains only IDs and timestamps. -
Runtime guard: A
scrub_fti_fieldsfunction 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 |
GET |
/v1/fti-audit-log/{id} |
Get a single audit log entry by ID. Requires |
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() })))
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 |
|---|---|
|
New: FtiAuditEntry, FtiPurposeCode, FtiAction, FtiAuditFilter, FtiAuditPage, FtiAuditSummaryRow, FtiAuditError, FtiAuditLogger trait, PostgresFtiAuditLogger, fti_audited wrapper, scrub_fti_fields, archive/purge functions, unit tests |
|
Add |
|
Update: expanded columns, indexes, archive table |
|
New: wire FtiAuditLogger, service-specific purpose codes |
|
Wire audit logger into startup |
|
Add FTI-free EventPayload structs, scrub_fti_fields calls |
|
New: auditor query endpoints |
|
New: fti_audit_log table, indexes, archive table |
|
New: wire FtiAuditLogger, service-specific purpose codes |
|
Wire audit logger into startup |
|
Add FTI-free EventPayload structs, scrub_fti_fields calls |
|
New: auditor query endpoints |
|
Add async-trait if not present |
Verification
-
cargo nextest run -p canopy-common— fti_audit unit tests pass -
cargo xtask dev restart— both migrations run successfully -
cargo nextest run -p canopy-tanf— FTI audit integration tests pass -
cargo nextest run -p canopy-medicaid— FTI audit integration tests pass -
Manual: trigger an FTI access in canopy-tanf, query the audit log endpoint, verify the entry
-
Manual: publish an event from canopy-tanf, inspect RabbitMQ payload, confirm no FTI fields present
-
Manual: attempt audit log query without
fti_auditorrole, 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