feat(authz)!: fail closed on an unconfigured authorization posture (S09 Enforcement plan: 20/20)

Step 20 of 20 of the S09 enforcement plan — Fail-closed boot and stub retirement.

Originally stacked on step 19 part 4 (!2254 (merged)); rebased onto main after that MR merged, carrying its late review fixes through the posture value.

Summary

The service now has exactly two authorization postures, chosen once at boot:

  • Enforcing (an iam: block is configured): the OCI, Maven, and npm dispatchers are wrapped unconditionally and the management API receives the IAM/GLAZ evaluator and the listing seams. No per-format nil-client gate remains. Boot logs one INFO, authorization is enforced, plus a WARN when auth.token_exchange is unset, because the bootstrap token then resolves every caller to an anonymous identity and every request answers a masked 404 with readiness green.
  • Unenforced (authorization.unenforced: true): nothing is wrapped, the management API receives the allow-all evaluator, and boot logs exactly one WARN naming the consequence.

Neither block present and no opt-in: config.Load succeeds and assembleApp refuses before any tier is dialed, with composition: authorization: no iam block is configured and authorization.unenforced is not set: configure iam.address to enforce authorization, or set authorization.unenforced to true to serve without it.

⚙️ Posture value

cmd/artifact-registry/wire_authz.go introduces authzWiring, built by enforcingAuthz, unenforcedAuthz, or authzWiringFrom(w). Its zero value reads as enforcing and panics on every client read, so a value that skipped both constructors fails closed instead of silently unenforced. checkAuthzBootPosture is the boot check; logUnenforcedAuthorization is the WARN. wireOCI, mountSlugAnchoredFormats, and wireManagementAPI take the posture value in place of raw clients and gates; managementListingSeams and slugAnchoredAuthzConfigured are gone.

🗑️ Stub retirement

Deleted: internal/authz/authz.go (Middleware, Register, the chain-position-10 slot), internal/authz/bootstrap/, cmd/artifact-registry/bootstrap_authz_link.go, the authz.Middleware() slot in internal/server/server.go, and the .golangci.yaml rule authz-bootstrap-only-via-link-files (the negative-control CI loop never named it). The one server test on the slot is ported as TestServer_ComposedChain_ReachesMux.

logStubWarning and its bootstrap_token_auth_stub field are deleted too, deliberately: the only boot-posture log is now the unenforced WARN. The bootstrap-token authenticator's own signal returns with the development mode in #692, which is sequenced after this step. Until then, an enforcing deployment that still authenticates with the bootstrap token denies every request as a masked 404; docs/dev/self-managed-install.md Open items names auth.token_exchange as the prerequisite for iam:.

💥 Breaking change

The commit carries a BREAKING CHANGE: footer and the title the ! marker, so the next release cuts a major version rather than a minor one and the release notes name the migration.

What breaks is the config contract, not an API: a file carrying neither an iam: block nor authorization.unenforced: true loaded and served before this change, and refuses to boot after it. The migration is to add one of the two to every such config — iam.address to enforce authorization, or authorization.unenforced: true to keep serving without it — before the new image rolls. The prerequisite below names the two configs that live outside this repository, where no pipeline here reports a file that still needs it.

📦 Deployment prerequisite

The service crashloops on a config carrying neither iam: nor the opt-in. Every in-repo config has a posture: the four conformance and e2e rigs, test/caproni/values/artifact-registry.yaml, the cmd/artifact-registry/testdata fixtures, and config.example.yaml.

Two configs the boot refusal reaches live outside this repo, and no pipeline here reports either one. Both have to carry a posture before this merges; this MR lands second.

  • The deployed environments' ar-config secret. Staging carries iam.address. Confirm production's ar-config carries either iam.address or authorization.unenforced: true first — an ar-config predating the opt-in crashloops the moment Flux rolls the new image, and the rollout is the first thing that reports it.
  • The dev rig's Helm values in gitlab-org/gitlab-caproni. caproni/scripts/seed-edit-config.sh, run from the rig's edit_mode_start hook, writes the edit-mode host binary's config.yaml from .appConfig.data in values/artifact-registry.yaml two directories above the AR checkout — the rig repo's copy, which the in-repo test/caproni/values/artifact-registry.yaml edit in this MR does not touch. Add authorization: {unenforced: true} there too, or the next edit_mode_start seeds a posture-less config.yaml and the edit-mode binary refuses to boot.

📏 Size

Against main: 62 files, +1080 / −1638. Production Go about +245 / −450 (16 files), Go tests about +680 / −1090 (27 files), docs and run recipe about +90 / −45, config, proto, generated code, CI values +37 / −38, shell rigs +24 / −25. Roughly half is the deleted stub, its package, and their tests; most of the rest is the test migration from nil, nil clients to the posture value (about 40 call sites) plus wire_authz.go and its test. A split would separate the stub deletion from the posture that replaces its gates, leaving an intermediate main where the gates are gone and the stub still registers, so the step ships as one MR.

🔀 What this step does not carry

  • Criterion 20's verdict clause (the unenforced posture answers requested verdicts all-allow) belongs to step 4 of the permission-verdicts plan (!2166 (merged)), already on main. Its third arm, no resolver and no opt-in answering 503, is unreachable from assembleApp once this MR merges, because the boot check refuses that configuration first; the arm's declaration in managementVerdictEvaluator records this.
  • The plan text diverges from what landed: wire_authz.go is a new file the plan's Files list does not name, the deleted server test is server_authz_test.go rather than an edit to server_test.go, and all four docs/testing/e2e/*.md catalogs needed the Access control update, not only docker.md. Corrections go to #941.
  • docs/specs/S01-http-server-and-routing.md still lists position 10 as the authorization placeholder; that is a spec MR, recorded on the work item below.
  • There is no database-less unit proof that wireServices threads the posture into wireManagementAPI: the management router resolves the slug through the database before the authorizer runs, so both postures answer 500 without one. TestIntegration_WireManagementAPI_EnforcesUnderTheEnforcingPostureOnly is the proof, against real Postgres.

Governing ADRs

Testing

  • cmd/artifact-registry/wire_authz_test.go: the boot check's four-cell table, the three nil guards, the zero value's fail-closed contract, both postures' accessors, authzWiringFrom selection including the distinct pre-cache listing stub, and the three boot records. The posture pair pins one record per boot and silence on the other side; TestLogBootstrapTokenUnderEnforcement pins its WARN on one cell of the authenticator-by-posture grid and silence on the other three.
  • wire_iam_test.go: an absent iam: block fails boot without the opt-in before any factory is called, and boots with it.
  • wire_services_authz_boot_test.go: TestAssembleApp_UnenforcedBootLeavesEveryDispatcherUnwrapped (OCI, Maven, npm) and the enforcing counterparts.
  • Per-mount tests renamed to ...UnderTheEnforcingPosture / ...UnwrappedUnderTheUnenforcedPosture; TestBuildNpmDispatcher_BareDispatcherResolvesItself pins that the unenforced arm installs no resolution holder.
  • Integration: TestIntegration_WireManagementAPI_EnforcesUnderTheEnforcingPostureOnly and ..._ListingFilterUsesWiredSeams gain the unenforced rows (unfiltered 200 with every repository); run against Colima-backed Postgres and Redis.
  • driver.sh smoke: 73/73 on a cold stack, exactly one authorization WARN in the boot log. The run recipe in .claude/skills/run-artifact-registry/ is updated in this MR.
  • Real binary: a config without iam: and without the opt-in exits with the error quoted above before listening.
  • Lint: golangci-lint plain and --build-tags=integration clean on touched lines; scripts/ci/check-comment-caps.sh --base origin/main OK; the three new //nolint:ireturn tokens verified to fire with --enable-only nolintlint,ireturn.

e2e catalogs: the Access control preamble of docs/testing/e2e/docker.md, oci.md, maven.md, and npm.md now states the opt-in condition instead of "enforces when iam:/glaz: are configured". No scenario is added; the boot refusal is a configuration error, not a request-path behavior.

Configuration reference: ## authorization and ## iam in docs/dev/configuration-reference.md describe the two postures, the boot refusal, and the two unevaluated OCI paths (GET /v2/ and /v2/auth/token); config.example.yaml and the proto comment are updated with them.

Related to #862 (closed)

Edited by David Fernandez

Merge request reports

Loading
Loading