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 whenauth.token_exchangeis unset, because the bootstrap token then resolves every caller to an anonymous identity and every request answers a masked404with 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-configsecret. Staging carriesiam.address. Confirm production'sar-configcarries eitheriam.addressorauthorization.unenforced: truefirst — anar-configpredating 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'sedit_mode_starthook, writes the edit-mode host binary'sconfig.yamlfrom.appConfig.datainvalues/artifact-registry.yamltwo directories above the AR checkout — the rig repo's copy, which the in-repotest/caproni/values/artifact-registry.yamledit in this MR does not touch. Addauthorization: {unenforced: true}there too, or the nextedit_mode_startseeds a posture-lessconfig.yamland 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 answering503, is unreachable fromassembleApponce this MR merges, because the boot check refuses that configuration first; the arm's declaration inmanagementVerdictEvaluatorrecords this. - The plan text diverges from what landed:
wire_authz.gois a new file the plan's Files list does not name, the deleted server test isserver_authz_test.gorather than an edit toserver_test.go, and all fourdocs/testing/e2e/*.mdcatalogs needed the Access control update, not onlydocker.md. Corrections go to #941. docs/specs/S01-http-server-and-routing.mdstill 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
wireServicesthreads the posture intowireManagementAPI: the management router resolves the slug through the database before the authorizer runs, so both postures answer500without one.TestIntegration_WireManagementAPI_EnforcesUnderTheEnforcingPostureOnlyis the proof, against real Postgres.
Governing ADRs
- ADR-021 Authorization: every request to a resource-bearing route is evaluated against GLAZ under the enforcing posture; the unenforced posture is an explicit operator opt-in that boot refuses to assume. No deviation.
- ADR-020 Authentication flow and ADR-023 Code structure and enforcement: read for conflict, none found. The link-file lint rule that ADR-023 motivated is removed together with the package it guarded.
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,authzWiringFromselection 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;TestLogBootstrapTokenUnderEnforcementpins its WARN on one cell of the authenticator-by-posture grid and silence on the other three.wire_iam_test.go: an absentiam: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_BareDispatcherResolvesItselfpins that the unenforced arm installs no resolution holder. - Integration:
TestIntegration_WireManagementAPI_EnforcesUnderTheEnforcingPostureOnlyand..._ListingFilterUsesWiredSeamsgain the unenforced rows (unfiltered200with 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-lintplain and--build-tags=integrationclean on touched lines;scripts/ci/check-comment-caps.sh --base origin/mainOK; the three new//nolint:ireturntokens 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)