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 compliance/data-tenancy-authorisation.toml

Done (2026-04-18)

2

Write an audit tool cargo xtask compliance audit-data-tenancy that walks migrations, source, and event payloads looking for protected fields in unauthorised services

Done (2026-04-18) — 240-line xtask/src/cmd/compliance.rs, regex-based identifier scanner with glob-pattern matching; 9 unit tests

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 .gitlab-ci.yml as a blocking stage

Done (2026-04-18) — new compliance-data-tenancy job; added to docker-promote needs list

5

Document the authorisation matrix and audit tool in security.md and the ATO-readiness doc

Done (2026-04-18) — .claude/docs/security.md cites the job; docs/modules/ROOT/pages/ato-readiness.adoc Pub 1075 "Authorized access" row extended with the code-level enforcement note

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-mq event 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

compliance/data-tenancy-authorisation.toml

New matrix file

xtask/src/cmd/compliance.rs

New audit subcommand

xtask/src/main.rs

Wire subcommand

.gitlab-ci.yml

New compliance-data-tenancy job

.claude/docs/security.md

"Data tenancy enforcement" section

docs/modules/ROOT/pages/ato-readiness.adoc

Cite control

CHANGELOG.adoc

Entry under == Unreleased

Verification

  1. cargo xtask compliance audit-data-tenancy — clean baseline

  2. Deliberately add an irs_placeholder field to services/canopy-snap/src/store/models.rs in a scratch branch; run the audit — clear finding pointing at the file

  3. Open a draft MR with the same scratch change — CI compliance-data-tenancy job fails

  4. Revert the scratch change, re-push — CI green

  5. 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_completed in 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_source in canopy-web — display-only fields on the worker-portal IncomeRow struct; canopy-web fetches these from canopy-snap over HTTP for rendering and does not store them.

  • 1 × fti_audit in canopy-snap — test-only import of canopy_common::fti_audit::scrub_fti_fields that 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.rs is 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 field fti_audit: String. A richer pass that looks at Rust use / mod keywords vs. field / column declarations would eliminate the fti_audit false positive without an allowlist entry.

  • Event-envelope-aware scanning. The plan called for parsing each service’s events.rs separately 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 the serde_json::json! literal), but the finding message would not specifically call out the event-bus regression mode. A second pass over events.rs that emits protected field X appears in event Y published by unauthorised service Z would be sharper.

  • Field allowlist generalisation. Allowlist entries currently pair file + pattern. A (class, service, rationale) form would let the TOML say "canopy-reporting accepts ievs_match_completed because 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):

  • #326 — Context-aware identifier extraction in audit (from Potential Improvements)

  • #327 — Event-envelope-aware scanning (from Potential Improvements)

  • #328 — Allowlist (class, service, rationale) refactor (from Potential Improvements)

Edit this page · default