2026-05-30 — caller_hash on the request-completion log line (audit P3 #26)¶
Motivation¶
Audit P3 #26 flagged that the per-request completion line emitted by
api/app/middleware.py::RequestIdMiddleware
(req rid=... method=... path=... status=... elapsed=...ms) carried no
identity field at all. The dashboard's request-detail panel and any
downstream KQL query had no way to count traffic per caller without
parsing the bearer token a second time — and any quick-and-dirty fix
that stamped the raw oid / upn on the line would have leaked
identifiers into the standard log stream.
The fix adds a single caller_hash=<sha256-prefix> token to both the
success path (req rid=...) and the failure path (req_failed rid=...)
of the middleware. The hash is produced by the existing
api.services.sanitise.redact_oid helper (same family as PR-1's PII
log redaction and PR-7's STRICT_AUDIT_HASH), so an operator can
deterministically join "all requests by user X across the last hour"
without ever recovering the raw OID.
This is purely additive observability — no behavioural change — so it
is not gated behind a STRICT_* env var (§12a Rule 4 explicitly
scopes the default-OFF requirement to "new positive validation"
changes; an extra log token is observability, not validation).
User-facing change¶
- Every middleware completion line now ends with
caller_hash=<sha256-prefix>. Anonymous requests render ascaller_hash=Noneso the token shape is consistent for log shippers. api/app/jwt_utils.pygains_decode_jwt_oid(authz), the OID/sub twin of the existing_decode_jwt_upn(authz). Same "best-effort, display-only, NEVER auth" contract — payload is base64-decoded without signature verification because the route's ownrequire_callerdependency remains the real auth gate.- The shared base64 path is now extracted into
_decode_jwt_payloadso the two helpers cannot drift apart on malformed-token handling.
API / IaC diff summary¶
api/app/jwt_utils.py:- Added
_decode_jwt_payload(authz)(shared helper). - Added
_decode_jwt_oid(authz)— returnsoidfalling back tosub, truncated to 128 chars (matches_decode_jwt_upn).
- Added
api/app/middleware.py:- Imported
_decode_jwt_oidandredact_oid. - Both the
LOGGER.exception("req_failed ...")line and theLOGGER.log(level, "req ...")line now includecaller_hash=%scomputed from the bearer'soid/subclaim viaredact_oid.
- Imported
api/tests/test_caller_hash_logging.py— 10 new tests covering: anonymous →caller_hash=None, authenticated → expected hash, raw OID/UPN redaction on both 4xx and 5xx paths, plus 4 direct_decode_jwt_oidunit tests (oid claim, sub fallback, missing / malformed bearer, 128-char truncation).- No Bicep, IaC, or container-image changes. No persona-matrix changes (no auth surface touched).
Validation evidence¶
- Focused:
uv run pytest -q api/tests/test_caller_hash_logging.py→ 10 passed in 2.37s. - Wide:
uv run pytest -q api/tests→ 2152 passed, 3 skipped in 33.60s. - Lint:
uv run ruff check api/app/jwt_utils.py api/app/middleware.py api/tests/test_caller_hash_logging.py→ All checks passed! (one auto-fixable import-order nit was fixed byruff --fix). - Frontend: no
web/src/**files touched —npm run buildnot required. - IaC: no Bicep touched —
azd provision --previewnot required.
Hardening discipline (§12a):¶
- In scope: observability/audit (additive log token only, no validation gate)
- RBAC change is single-PR safe (no role narrowed) — no RBAC change in this PR
- Persona Matrix tests pass for owner / contributor / reader / dev_bypass — wide sweep green
- Reader allowlist unchanged — no Reader-required route touched
- Capability Probe passes locally — no new Azure surface, probe unaffected
- New guard ships default-OFF — N/A (additive observability per Rule 4 scoping; no
STRICT_*flag needed) - No
Depends(require_caller)added to an SSE event stream — no SSE changes - Change note (this file) summarises persona impact: every persona's requests now produce a redacted
caller_hashtoken; raw OID / UPN never appears in the completion line, validated by the redaction regression tests