PII log redaction for caller.object_id (audit P0 #1)¶
Audience: operators, future audit-remediation PR authors. Status: code change, no API/IaC change. First of the ~11 audit-remediation PRs landing under §12a Security Hardening Discipline.
Motivation¶
The 2026-05-30 permission-risk audit flagged P0 #1: 22 LOGGER.* call sites
across 13 route files were emitting caller.object_id (the Entra ID GUID of
the signed-in user) in cleartext. These lines flow into Application Insights
and any log aggregator, which:
- exposes a stable PII correlation key,
- makes it trivial to enumerate all activity of a single user, and
- contradicts the charter §12 sanitisation rule.
The matching _log_identity_hash helper in
api/routes/terminal/ws.py already used a
sha256 prefix internally but was not reused anywhere else.
User-facing change¶
None. All affected log lines keep the same fields and order; only the value of
the caller_oid=… / oid=… / by=… token changes from a 36-char GUID to a
deterministic 12-char sha256 prefix. Operators can still grep a single user's
trail by the prefix — they just cannot reverse it.
Example before:
external BLAST submit accepted caller_oid=11111111-2222-3333-4444-555555555555 db=swissprot program=blastp
Example after:
API / IaC diff summary¶
| File | Change |
|---|---|
api/services/sanitise.py |
New redact_oid(value) — deterministic hashlib.sha256(value.encode()).hexdigest()[:12], returns None for falsy input. Single source of truth so every route imports the same helper. |
api/routes/terminal/ws.py |
_log_identity_hash now delegates to redact_oid (back-compat preserved — same signature, same output shape for non-empty input). |
api/routes/elastic_blast.py |
6 LOGGER.info sites wrapped. |
api/routes/settings/app_insights.py |
3 sites wrapped. |
api/routes/aks/autostop.py |
3 sites wrapped. The ownership-refusal LOGGER.warning block refactored from f"...{caller.object_id[-8:]}" if caller.object_id else "?" to redact_oid(caller.object_id) or "?" for consistency. |
api/routes/aks/openapi.py |
3 sites wrapped (token update, public-https enable/disable). |
api/routes/aks/peering.py |
1 site wrapped. |
api/routes/blast/databases.py |
2 sites wrapped (shard accept, db-order oracle). |
api/routes/blast/submit.py |
1 site wrapped (canonical external submit). |
api/routes/resources.py |
3 sites wrapped (ensure_rg, ensure_storage, ensure_acr). |
api/routes/settings/aks_observability.py |
2 sites wrapped (enable, disable). |
api/routes/settings/vnet_peering.py |
13 sites wrapped — 9 positional LOGGER args via regex sweep, 2 _validate_target_ip(target_ip, caller.object_id) call sites rewritten to pass redacted value (signature unchanged so internal warnings now receive redacted value). |
api/routes/storage/local_debug.py |
1 site wrapped. The pre-existing caller.object_id[:8] slice on line 186 is treated as already-redacted by the AST scan. |
api/routes/storage/prepare_db.py |
3 sites wrapped. The cancel metadata f-string at line 1572 uses a local _cancel_oid var to stay under the 100-char line limit. |
api/tests/test_pii_log_redaction.py |
New regression guard. AST-based static scan (no FastAPI boot): for every *.py under api/routes/, walks LOGGER.* calls and recursively classifies each argument. Understands Subscript slicing, JoinedStr / FormattedValue f-strings, IfExp ternaries, and a _REDACTING_FUNCTIONS = frozenset({"redact_oid", "_log_identity_hash"}) allowlist. Also asserts redact_oid is deterministic and the output is never a substring of the input GUID. |
Not touched on purpose (these are audit/storage row fields or Celery task kwargs, not log lines):
api/routes/aks/autostop.pyline 453payload["owner_oid"] = caller.object_idapi/routes/aks/openapi.pyCelerycaller_oid=caller.object_id or ""kwargsapi/routes/blast/databases.pyline 546"requested_by": caller.object_idapi/routes/blast/submit.pytask kwargs / audit dict fieldsapi/routes/resources.pycaller_oid=caller.object_idkwargs tomonitoring_svchelpers
These are deliberately raw because they are persisted as audit trail and need to remain joinable against the platform identity. PR-2 (sanitised exception detail) and PR-7 (audit table hashing strategy) revisit them separately.
Validation evidence¶
$ uv run ruff check api/routes/ api/services/sanitise.py api/tests/test_pii_log_redaction.py
All checks passed!
$ uv run pytest -q api/tests/test_pii_log_redaction.py
66 passed in 7.50s
$ uv run pytest -q api/tests
2007 passed, 3 skipped in 53.08s
The wide sweep confirms no test asserted on the exact pre-redaction log line content (no fixture or mock had to change).
§12a Hardening discipline checklist¶
- In scope: sanitise (output sanitisation at log boundary).
- RBAC change is single-PR safe — no role narrowed. N/A for Rule 1 2-phase rule.
- Persona Matrix tests pass for owner / contributor / reader / dev_bypass (full suite green: 2007 passed).
- Reader allowlist unchanged.
- Capability Probe N/A — this PR does not touch RBAC; the probe is wired but not re-asserted.
- Not a new validation guard — N/A for Rule 4 default-OFF gate. The redaction is unconditional because the prior log lines were a PII leak, not a feature.
- No
Depends(require_caller)added to an SSE event stream — N/A. - Change note (this file) summarises persona impact: none — all personas still see the same log lines, just with a 12-char hash instead of the raw GUID. No action a Reader / Contributor / Owner can take is gained or lost.
Follow-up¶
- PR-2 (next): Sanitise
HTTPException(detail=str(exc))sites inapi/routes/monitor/metrics.pyandapi/routes/settings/vnet_peering.py(audit P1 #7, #8). - PR-7 (later): Decide whether the audit-table
owner_oid/requested_byfields should be hashed at storage time. Currently they remain raw to keep the audit trail joinable against Entra.