fix(authz): complete the logs for two under-reported denials
🔍 What this fixes
Two denial paths in the authorization layer under-reported, leaving the masked-404 audit trail one-sided.
1️⃣ unresolved_repository was counted but never logged
It incremented denials_total and wrote no log line. ADR-021 makes a denial
indistinguishable from a miss to the client on purpose, so the log is what
lets an operator tell them apart. Without it, a single request could not be
separated into "the repository does not exist" and "the denial was logged but
the row never arrived".
Both emitters now route through one internal helper that logs an INFO as it counts:
| Emitter | Surface | decision_reason it can name |
|---|---|---|
resolveTarget |
format middleware (OCI, npm, Maven) | namespace-not-found, repository-not-found |
authorizeRepositoryScope |
management API | repository-not-found only |
The management API reaches only the repository tier because slugMiddleware
resolves and gates the namespace before it runs.
The line carries action, namespace_slug, repository_name,
allowed=false, and decision_reason. decision_reason is a log attribute
only, so it adds no metric cardinality and the denial_reason closed set is
unchanged.
Two glosses are deliberately wider than they first read, because the sentinels
they describe are: namespace-not-found also covers a namespace that exists
but fails S33 (gitlab-api)'s read predicate (blocked, disabled, deleted,
purged), and repository-not-found also covers a repository row that exists
but is not bound to the requested format.
The counter-only CountUnresolvedRepositoryDenial is deleted. With
denialsTotal unexported, that removes the only way to book this reason
without logging it.
2️⃣ the unresolved-principal WARN dropped its sibling's fields
logEmptyPrincipalDenial logged its routine zero-tuple branch at INFO with
four attributes, but its IAM-contract-violation branch — the more serious one —
logged WARN with none, so the branch an operator most needs said only that it
happened. It now carries action, object_id, allowed, decision_reason.
MountSourceAuthorizer.CanPullSource carried the same defect, copied along
with the INFO/WARN split it mirrors. Both its arms now carry the same four
attributes, so a decision_reason query reaches the mount path too.
🔒 Security
No client-visible byte changes: the masked 404 renders exactly as before, so ADR-021's indistinguishability holds. Both emitters sit behind the anonymous gate, so no unauthenticated caller can drive the new line.
namespace_slug and repository_name are attacker-controlled path segments,
so both are stripped of C0 control characters and bounded at 255 characters.
255 is lossless here: check_namespaces_slug_length and
check_repositories_name_length each cap their column at 255, so no value
that ever resolved can exceed it.
⚠️ Operational note
The new INFO is unsampled, one record per denied request, matching the
no_role_assignment and org-membership mismatch lines already emitted at
that tier. Volume is bounded by misconfigured or enumerating clients rather
than steady-state traffic: repositories are never auto-created, so a routine
404 inside an existing repository never reaches this path.
🧪 Testing
Both decision_reason values are covered positively at unit level and
end-to-end through Assembly.Handler; the management API's single reachable
value is covered through its real handler stack. The truncation helper is
covered for the under-cap, at-cap, over-cap, and multi-byte cases, plus its
composition with sanitization.
No e2e scenario in docs/testing/ is added or affected: the client-visible response is byte-identical before and after, and the catalogs track client-observable behavior rather than log lines or metrics.
Related to #1142 (closed)