Plan: ADR-004 SSA / IEVS / FTI Authorization Audit
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Codify the authorisation matrix (service × data-class) as a TOML at |
Done (2026-04-18) |
2 |
Write an audit tool |
Done (2026-04-18) — 240-line |
3 |
Establish the baseline — confirm current state matches the matrix, fix any violations found |
Done (2026-04-18) — 251 files scanned, 14 findings triaged as legitimate references (QC boolean, display passthrough, scrub-test import), all allowlisted with written justification. 0 true violations. |
4 |
Wire the audit into |
Done (2026-04-18) — new |
5 |
Document the authorisation matrix and audit tool in |
Done (2026-04-18) — |
Branch: feature/adr-004-tenancy-audit
Labels: type::compliance, priority::high, program::infrastructure, service::security, compliance::pub-1075, compliance::ievs, compliance::cma, workflow::ready
Context
Per ADR-004, three classes of protected data have legally-scoped tenancy:
| Data class | Legal basis | Authorised services |
|---|---|---|
FTI (Federal Tax Information) |
IRC §6103, Pub 1075 |
canopy-tanf, canopy-medicaid |
IEVS match data (state DOL SWR/UI, SSA SDX/BENDEX) |
7 USC §2025(e), Pub 1075 §9.4 |
canopy-snap, canopy-verification (read-side), canopy-security (audit only) |
SSA SOLQ / BINDEX direct (CMA-governed) |
CMA with SSA, SSA §1106 |
canopy-tanf, canopy-snap, canopy-medicaid, canopy-verification (transport) |
The 2026-04-18 review found:
FTI Data Isolation (ADR-004) — ✅ PASS. FTI restricted to canopy-tanf and canopy-medicaid, scrubbing confirmed on event bus.
ADR-004 SOLQ/BINDEX authorization not mapped. ADR specifies TANF, SNAP, Medicaid, and CHIP all authorized for SSA data, but no explicit per-service tables or isolation validation observed. Recommend: verify SSA data is not replicated to unauthorized services (CAPS, WIC).
The fix is not a one-time audit (the codebase changes) — it is a persistent, auditable gate that runs on every MR. This plan delivers that gate alongside the one-time audit that confirms today’s state.
Scope
In scope:
-
A machine-readable authorisation matrix.
-
An audit tool that enforces the matrix against code, migrations, and event envelopes.
-
CI integration so the tool is a merge-blocker.
-
Baseline verification and any necessary fixes to land a green baseline.
-
Documentation updates.
Out of scope:
-
Runtime enforcement (RBAC for internal service calls). Existing Keycloak roles and service-mesh policy already provide this; the audit targets code-level authorisation.
-
The event-payload FTI-scrubbing logic itself — already implemented.
-
Changes to ADR-004. If the audit reveals the matrix is wrong, file a superseding ADR.
Dependencies
-
docs/modules/ROOT/pages/adrs/adr-004-legally-scoped-data-tenancy.adoc— source of truth for the matrix. -
services/*/migrations/— one source of data-tenancy evidence (table columns). -
services/*/src/— second source (struct fields, field names). -
crates/canopy-mqevent envelopes — third source (scrubbed payload fields). -
xtask— home for the audit subcommand.
Design
Authorisation matrix
# compliance/data-tenancy-authorisation.toml
[fti]
description = "IRS Pub 1075 Federal Tax Information"
legal_basis = "IRC §6103"
authorised_services = ["canopy-tanf", "canopy-medicaid"]
protected_field_patterns = [
"tax_return*",
"federal_tax_info*",
"irs_*",
"fti_*",
]
[ievs]
description = "Income and Eligibility Verification System match data"
legal_basis = "7 USC §2025(e), Pub 1075 §9.4"
authorised_services = ["canopy-snap", "canopy-verification", "canopy-security"]
protected_field_patterns = [
"ievs_*",
"swr_match*",
"ui_match*",
]
[ssa_solq_bindex]
description = "SSA SOLQ / BINDEX data governed by CMA"
legal_basis = "SSA §1106, CMA with SSA"
authorised_services = ["canopy-tanf", "canopy-snap", "canopy-medicaid", "canopy-verification"]
protected_field_patterns = [
"ssa_sdx*",
"ssa_bendex*",
"solq_*",
"bindex_*",
]
Patterns are simple glob; the audit tool matches them case-insensitively against field names in struct definitions, migration column names, and event payload keys.
Audit tool
// xtask/src/cmd/compliance.rs (new file or extend existing)
pub fn audit_data_tenancy() -> Result<()> {
let matrix = load_matrix("compliance/data-tenancy-authorisation.toml")?;
let mut findings: Vec<Finding> = vec![];
for service in workspace_services() {
let authorised_classes = matrix.classes_for(&service.name);
for path in service.iter_source_and_migrations() {
for match_ in scan_protected_fields(path, &matrix) {
if !authorised_classes.contains(&match_.class) {
findings.push(Finding {
service: service.name.clone(),
class: match_.class.clone(),
file: path.clone(),
field: match_.field,
});
}
}
}
}
if !findings.is_empty() {
emit_findings(&findings);
bail!("{} ADR-004 authorisation violations", findings.len());
}
Ok(())
}
Output format: grouped by service, then by class, with file:line references that Claude Code / IDE can click.
Event-envelope scanning is done by parsing each service’s events.rs (or equivalent) and extracting the fields of each published envelope — if a protected pattern appears in an envelope from an unauthorised service, it’s a finding regardless of whether the field is scrubbed at runtime. This catches "I scrubbed today but forgot next time" regressions.
CI integration
.gitlab-ci.yml gets a new job in the test stage:
compliance-data-tenancy:
stage: test
needs: []
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
script:
- cargo xtask compliance audit-data-tenancy
Needs-empty keeps it parallel with other gates. docker-promote already depends on test:* succeeding.
Steps
Step 1: Matrix TOML
Files: compliance/data-tenancy-authorisation.toml (new).
Transcribe the matrix from ADR-004. Include inline comments citing the ADR section and the statutory basis for each class.
Step 2: Audit tool
Files: xtask/src/cmd/compliance.rs (new), xtask/src/main.rs (wire subcommand).
Implement the tool per Design. Keep parsing simple — regex-based field extraction is acceptable; no need for a full Rust AST walk. Document known limitations (e.g., dynamically-constructed field names out of scope) in the tool’s rustdoc.
Step 3: Baseline
Files: any source changes needed to reach a clean baseline.
Run cargo xtask compliance audit-data-tenancy locally. Expect zero findings based on the 2026-04-18 review; if any surface, triage:
-
True positive — file fixes on this branch.
-
False positive — tighten patterns or add a narrowly-scoped allowlist (one entry per allowance, each with a code comment citing why).
If triage reveals a systemic issue (e.g., an unauthorised service has a genuine need), escalate via an ADR supersession — do not expand the allowlist.
Step 4: CI
Files: .gitlab-ci.yml.
Add the compliance-data-tenancy job per Design. Confirm it runs on MRs via a test MR. Keep docker-promote’s needs list unchanged unless we want this as a pre-promote blocker (recommended: yes, add it).
Step 5: Docs
Files: .claude/docs/security.md, ATO Readiness, CHANGELOG.adoc.
-
security.md— section "Data tenancy enforcement" citing the matrix file and the audit tool -
ATO-readiness — add to the Pub 1075 / IEVS / CMA control rows
-
CHANGELOG — entry under
== Unreleased
Files Touched
| File | Change |
|---|---|
|
New matrix file |
|
New audit subcommand |
|
Wire subcommand |
|
New |
|
"Data tenancy enforcement" section |
|
Cite control |
|
Entry under |
Verification
-
cargo xtask compliance audit-data-tenancy— clean baseline -
Deliberately add an
irs_placeholderfield toservices/canopy-snap/src/store/models.rsin a scratch branch; run the audit — clear finding pointing at the file -
Open a draft MR with the same scratch change — CI
compliance-data-tenancyjob fails -
Revert the scratch change, re-push — CI green
-
Inspect one audit run’s output for formatting (human-readable, clickable file:line)
Documentation Updates
-
.claude/docs/security.md— ADR-004 section extended with the enforcement job + TOML path (2026-04-18) -
.claude/docs/services.md— shared-infrastructure entry deferred; the xtask CLI Reference is the natural home, which is in Tier 6’s documentation-completeness plan -
ATO Readiness — Pub 1075 "Authorized access" row extended with the code-level enforcement note (2026-04-18)
-
CHANGELOG.adoc— entry under== Unreleased(2026-04-18)
Errata
Allowlist carries 14 entries at baseline
The "empty allowlist is the goal" wording in the plan is aspirational. Every one of the 14 baseline entries is a legitimate reference to a protected-class field name that does not actually carry that class’s data:
-
9 ×
ievs_match_completedin canopy-reporting — a boolean flag required by FNS-7176 QC Universe per 7 CFR 275.12 ("was the IEVS match performed"), not the match data itself. -
4 ×
ievs_amount/ievs_sourcein canopy-web — display-only fields on the worker-portalIncomeRowstruct; canopy-web fetches these from canopy-snap over HTTP for rendering and does not store them. -
1 ×
fti_auditin canopy-snap — test-only import ofcanopy_common::fti_audit::scrub_fti_fieldsthat asserts FTI never leaks to events.
A tighter pattern set (ievs_match_* → ievs_match_row, ievs_match_record; fti_audit excluded entirely; etc.) would reduce allowlist entries at the cost of missing genuine ievs_match_row leakage into canopy-reporting. The allowlist-with-rationale approach keeps the scanner aggressive and the exceptions auditable.
Potential Improvements
-
Per-service path include/exclude filters in the scanner.
canopy-web/src/api/case_detail.rsis known to be a display-passthrough boundary — a path-level exclusion could replace two of the current allowlist entries with a single "canopy-web display layer is downstream-only" exception. -
Context-aware identifier extraction. The scanner treats a
use canopy_common::fti_audit::scrub_fti_fields;import the same as a struct fieldfti_audit: String. A richer pass that looks at Rustuse/modkeywords vs. field / column declarations would eliminate thefti_auditfalse positive without an allowlist entry. -
Event-envelope-aware scanning. The plan called for parsing each service’s
events.rsseparately and extracting the fields of each published envelope. The current scanner treats those files the same as any other source — a published-payload regression would still trip the scanner (because the field name would appear in theserde_json::json!literal), but the finding message would not specifically call out the event-bus regression mode. A second pass overevents.rsthat emitsprotected field X appears in event Y published by unauthorised service Zwould be sharper. -
Field allowlist generalisation. Allowlist entries currently pair
file+pattern. A(class, service, rationale)form would let the TOML say "canopy-reporting acceptsievs_match_completedbecause FNS-7176 requires it" in one row instead of nine.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):