Securing Customer PII in Utility Databases
A utility database is one of the richest stores of personal data a city holds — names, service addresses, bank details, and consumption curves that betray when a household is home or away. The failure mode this page resolves is the accidental read: a bulk rate recalculation issues SELECT *, an ORM lazy-loads a relationship that drags customer_ssn into application memory, or a developer’s debugging query returns a service address that then lands in a log aggregator. None of those requires an attacker — just a query broader than the caller’s role needed. This guide sits under Data Governance & Privacy Compliance, inside Municipal Utility Billing Architecture & Rate Taxonomy, and gives you one focused remedy: a field-level projection layer that tags every column with a sensitivity level, lets each role read only up to its ceiling, replaces PII with a join-safe pseudonym instead of dropping it, and defaults unknown columns to the most restrictive class so a schema migration can never silently leak a new field.
Prerequisites
The imports and the single data assumption this layer depends on — nothing else.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import hashlib
import hmac
import logging
import os
logger = logging.getLogger("billing.pii")
- Python 3.11+ for
StrEnum-style string enums, theX | Noneunion syntax, and moderndataclassslots. hmac+hashlibfrom the standard library — pseudonym tokens are keyed HMAC-SHA256, not bare hashes, so a stolen digest cannot be reversed against a rainbow table of account numbers. No third-party crypto is required.- A resolved caller role. Authentication and role resolution happen upstream in Security Boundaries & Role-Based Access; this layer governs which fields a settled role may read, not who the caller is.
- An explicit column list per query. The projector operates on named columns. Expanding
SELECT *into concrete column names must happen before this layer runs — a literal"*"is treated as unknown and withheld by design.
Annotated Implementation
The whole protection layer is a sensitivity registry, a per-role ceiling, and one projection function. Every column the billing schema exposes is tagged; every role declares the highest sensitivity it may ever receive in cleartext; the projector returns only the columns at or below that ceiling, masks PII to a stable token, and emits an audit statement that is safe to persist because it contains no raw values.
class Sensitivity(str, Enum):
PUBLIC = "public" # safe to return and to log (tokens, kWh)
INTERNAL = "internal" # billing-safe, role-gated (account id, totals)
PII = "pii" # identifying: name, address — masked, not raw
SECRET = "secret" # SSN, bank details — never leaves the vault
class Role(str, Enum):
BILLING_MANAGER = "billing_manager"
FINANCE = "finance"
DEVELOPER = "developer"
# Every exposed column carries a sensitivity tag. A column with no entry is
# treated as SECRET below — the registry fails closed, never open, so a new
# column added by a migration is withheld until someone classifies it.
COLUMN_SENSITIVITY: dict[str, Sensitivity] = {
"service_token": Sensitivity.PUBLIC,
"consumption_kwh": Sensitivity.PUBLIC,
"zone_id": Sensitivity.PUBLIC,
"account_id": Sensitivity.INTERNAL,
"invoice_total": Sensitivity.INTERNAL,
"customer_name": Sensitivity.PII,
"service_address": Sensitivity.PII,
"customer_ssn": Sensitivity.SECRET,
"bank_account": Sensitivity.SECRET,
}
# The highest sensitivity each role may ever receive in cleartext. A developer
# working against production may see only PUBLIC columns; PII and SECRET never
# reach application memory for any billing role.
ROLE_CEILING: dict[Role, Sensitivity] = {
Role.BILLING_MANAGER: Sensitivity.INTERNAL,
Role.FINANCE: Sensitivity.INTERNAL,
Role.DEVELOPER: Sensitivity.PUBLIC,
}
# Total order so ceilings are comparable. Nothing above a role's ceiling is
# ever returned in the clear.
_RANK: dict[Sensitivity, int] = {
Sensitivity.PUBLIC: 0,
Sensitivity.INTERNAL: 1,
Sensitivity.PII: 2,
Sensitivity.SECRET: 3,
}
def pseudonym(value: str) -> str:
"""Deterministic, keyed token for a PII value. Same input -> same token,
so joins, grouping, and dedupe still work, but the raw value is
unrecoverable without the key. HMAC (not a bare SHA-256) defeats a rainbow
table of known account numbers or addresses."""
key = os.environ["PII_TOKEN_KEY"].encode() # rotated out of band
return "tok_" + hmac.new(key, value.encode(), hashlib.sha256).hexdigest()[:24]
@dataclass(frozen=True, slots=True)
class Projection:
"""The result of projecting a request for one role. Frozen so a projection
can never be widened after the access decision was made and logged."""
role: Role
returned: tuple[str, ...] # columns delivered as cleartext
masked: tuple[str, ...] # PII columns delivered as a token
withheld: tuple[str, ...] # columns removed from the query entirely
audit_statement: str # sanitized — safe to persist to the log
def project_for_role(role: Role, requested: list[str]) -> Projection:
"""Reduce a requested column list to what `role` may actually read."""
ceiling = _RANK[ROLE_CEILING[role]]
returned: list[str] = []
masked: list[str] = []
withheld: list[str] = []
for col in requested:
# Unknown column ("*", a typo, a freshly migrated field) -> SECRET.
level = COLUMN_SENSITIVITY.get(col, Sensitivity.SECRET)
if _RANK[level] <= ceiling:
returned.append(col) # within the role's reach
elif level is Sensitivity.PII:
masked.append(col) # shape kept, value hidden
else:
withheld.append(col) # never selected at all
# The SELECT the DB actually runs: cleartext columns plus tokenized PII.
# SECRET columns above the ceiling are absent, so they cannot reach memory.
select_cols = returned + [f"pseudonym({c}) AS {c}_token" for c in masked]
audit_statement = (
f"role={role.value} "
f"SELECT {', '.join(select_cols) or '<no columns granted>'} "
f"[withheld={','.join(withheld) or 'none'}]"
)
# The audit line names columns and the decision, never a raw value.
logger.info("pii.projection %s", audit_statement)
return Projection(role, tuple(returned), tuple(masked), tuple(withheld), audit_statement)
The load-bearing decision is the dict.get(col, Sensitivity.SECRET) default. Access control that fails open — where an untagged column is assumed safe — is how the next schema migration ships a leak. Here, anything the registry does not explicitly bless is treated as the most sensitive thing in the system and withheld, so classification is a deliberate act, not an omission. PII is masked rather than dropped because downstream stages still need a stable key: the rate engine can group by customer_name_token or join on service_address_token without ever handling the raw string, and the same input always yields the same token so reconciliation against the Data Governance & Privacy Compliance ledger stays intact.
Edge Cases and Billing Gotchas
- A migration adds an untagged column. Someone ships
emergency_contact_phoneand forgets the registry entry. BecauseCOLUMN_SENSITIVITY.getdefaults toSECRET, every role’s projection withholds it until it is classified — the field is invisible instead of leaking. The withheld list in the audit statement makes the gap loud, so the missing tag surfaces in review rather than in a breach. SELECT *reaches the projector. A literal"*"is an unknown column, so it is treated asSECRETand withheld, and the projection returns no data for it. This forces callers to expand to explicit column names before querying — the projector cannot bless a wildcard it cannot classify. Pair this withlazy="raise"on ORM relationships so an accidental lazy-load raises instead of silently pulling PII.- Fallback tiers must not touch customer tables. When a rate cannot be resolved, Fallback Routing for Missing Rate Data must key off a pre-computed
zone_id(aPUBLICcolumn) and the Customer Class & Service Tier Mapping, never aSELECTagainst the customer identity table. Resolving a default by re-readingservice_addressreintroduces the exact PII exposure the projector removed. - Exception context leaks a raw value into a retry queue. A failed task serialized with its exception often captures the offending row — including a raw address — into the queue payload, which then persists far longer than the request. Strip
exc.argsand any row context to PUBLIC-only fields before enqueueing, and serialize with JSON (never pickle) so nothing outside the sanitized projection survives a retry.
Verification Snippet
Prove the two invariants that keep this layer honest: a SECRET column is never returned or masked for any role, and an unknown column fails closed. Assert on the exact projection, not on log output.
def test_projection_fails_closed_and_hides_secrets():
# A billing manager may read INTERNAL, sees PII only as a token, and never
# touches a SECRET column even when the query explicitly asks for it.
p = project_for_role(
Role.BILLING_MANAGER,
["invoice_total", "consumption_kwh", "customer_name", "customer_ssn"],
)
assert "invoice_total" in p.returned # INTERNAL <= ceiling
assert "consumption_kwh" in p.returned # PUBLIC <= ceiling
assert "customer_name" in p.masked # PII -> tokenized, not raw
assert "customer_ssn" in p.withheld # SECRET -> never selected
assert "customer_ssn" not in p.returned and "customer_ssn" not in p.masked
# A developer against production may see PUBLIC columns only.
d = project_for_role(Role.DEVELOPER, ["consumption_kwh", "account_id"])
assert d.returned == ("consumption_kwh",) # account_id is INTERNAL
assert "account_id" in d.withheld
# An unclassified / wildcard column fails closed for everyone.
u = project_for_role(Role.FINANCE, ["*", "new_unclassified_col"])
assert u.returned == () and set(u.withheld) == {"*", "new_unclassified_col"}
Run this against a fixture that mirrors the production schema before every deploy. A single column that appears in returned when it should be withheld is a leak that would otherwise ship to every caller of that role.
Frequently Asked Questions
Related Topics
- Data Governance & Privacy Compliance — the parent workflow whose sensitivity classification and retention rules this projector enforces at the column level.
- Security Boundaries & Role-Based Access — how the caller role this layer keys on is authenticated and scoped.
- Fallback Routing for Missing Rate Data — resolving default tiers without a PII lookup.
- Customer Class & Service Tier Mapping — the pre-computed classifications that let the rate engine avoid raw addresses.
- Assistance Program Eligibility Taxonomy — why eligibility flows should carry a boolean flag, not stored income data.
Up one level: Data Governance & Privacy Compliance · Parent guide: Municipal Utility Billing Architecture & Rate Taxonomy · Return to the utilitybilling.org home.