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)

Edited by David Fernandez

Merge request reports

Loading
Loading