feat(config): accept the authorization.unenforced opt-in (S09 Enforcement plan: 1/20)
🎯 What
Adds the authorization configuration section and its one field, unenforced — the explicit operator opt-out from authorization enforcement — plus the cross-field invariant that rejects it alongside a configured iam: block, and sets the opt-in in every harness that boots without IAM.
The zero value is the enforcing posture, so an omitted block, a present-but-empty block, and an explicit unenforced: false all mean the same thing. The field name is chosen so the value an operator must write to weaken the posture reads as what it does.
💭 Why
Step 1 of the S09 enforcement plan. The setting has to be accepted by the loader before the posture that depends on it can flip, so the harnesses that boot with no iam: block can carry the opt-in ahead of that change, and so a contradictory pairing fails at startup rather than at the moment the posture becomes load-bearing.
The value is inert in this MR — nothing reads it. The boot behavior it selects (refusing to boot without a constructible IAM client, and the WARN announcing the unenforced posture) arrives in step 20, along with the retirement of the allow-all pass-through.
🔍 Design notes
Presence of iam: is read off the proto, not the converted config. iamFromProto maps an absent block onto the zero IAMConfig, so the converted value cannot tell absent from present — only a caller holding the proto can. Present is the same as configured there, because protovalidate requires address and service_token: a childless iam: key, in any of its three spellings, is rejected before the invariant runs. That is pinned by a test rather than left to a comment, because the behavior belongs to the YAML decoder rather than to this repository.
glaz: is deliberately excluded from the invariant. Its client is constructed unconditionally, the address defaulting to the in-pod loopback sidecar, so the block is optional tuning of a client that exists either way and its presence signals no intent to enforce. Pairing against it would reject a rig that only lengthened a timeout while admitting one that named no sidecar at all. TestLoad_Authorization_GlazBlockAloneAccepted records this so the next reader does not have to re-derive it.
A separate error type. AuthorizationConfigError shares AuthConfigError's shape exactly, and exists for the one thing a shared type could not give: a caller discriminating a contradicted posture from a misconfigured auth block does it with errors.As rather than by matching a message.
⚠️ Plan drift: the rig inventory is five, not four
The plan's Research Findings name four harnesses that boot without an iam: block. There is a fifth: test/caproni/values/artifact-registry.yaml, whose appConfig.data is the complete configuration for the e2e Caproni environment, and which already drops the GLAZ sidecar on the grounds that "nothing here exercises an authorization decision".
It gets the opt-in here too. Left out, step 20's posture flip would fail that environment at deploy rather than in an assertion. The plan file is not edited by this MR; the inventory correction is recorded here and in the commit body.
The same exposure exists in the external gitlab-caproni repository's values file, which cannot be fixed from here — worth naming in step 20.
🧪 Testing
| Acceptance criterion | Covered by |
|---|---|
unenforced: true with an iam: block fails to load, error names the contradiction |
TestLoad_AuthorizationContradictsIAMBlock |
| Either alone loads | TestLoad_Authorization_HappyPath, TestLoad_Authorization_IAMBlockAloneAccepted |
| An omitted block defaults to enforcement | TestLoad_Authorization_OmittedBlockDefaults |
A present block reaches the same default, empty or explicit false |
TestLoad_Authorization_PresentBlockDefaults |
config.example.yaml carries the block and loads to the documented default |
TestLoad_Authorization_ConfigExampleDefault |
A childless iam: key beside the opt-in is still rejected |
TestLoad_AuthorizationChildlessIAMBlockStillRejected |
The five rigs boot unchanged, driver.sh smoke passes |
TestRigsCarryTheAuthorizationOptIn pins the stanza in all five at the tip. The smoke run behind 71 passed, 0 failed is a snapshot taken at 5dc9ddbb, before the review rounds; nothing since then touches a boot path |
Negative controls were run: stubbing the conversion fails the happy path, and deleting the check fails the contradiction test.
go test ./... green at dff1d891, and golangci-lint 0 issues there. buf lint, buf format, shfmt, yamllint, markdownlint clean; vale 0 errors. This MR adds no //go:build integration test, so the integration-tagged lint pass does not apply.
e2e scenario catalogs: unchanged, deliberately. The docs/testing/e2e/ Access control sections are moved off blocked by the steps that make each surface enforce (9, 13, and 20); this step changes no request-path behavior, so no scenario is added or affected.
📏 Diff size
Hand-written additions are 450 lines, inside the 500 development-model.md asks be justified. Split by file group:
| Group | Added | Deleted |
|---|---|---|
Generated (gen/.../config.pb.go) |
343 | 256 |
| Go production | 57 | 34 |
| Go tests + fixture | 262 | 4 |
| Proto schema | 34 | 0 |
Docs + config.example.yaml |
41 | 0 |
| Harness configs (5 rigs + run recipe) | 56 | 0 |
Of the 450 hand-written lines, 219 are statements; the remainder is doc comments and prose. The count was 622 until 538a21f6 compressed this package's comment blocks to the caps lint:comment-caps enforces. That commit removed no statement, so the added-line ratio between tests and production no longer measures anything: the caps cut production doc comments hardest.
Splitting would not reduce what a reviewer has to hold at once. The schema, its loader, and the tests that pin it are one unit: every test asserts against the invariant the loader adds, and the invariant is the only behavior the schema introduces. The harness stanzas are the half that makes the setting's purpose legible, and they are two lines each.
Only the first of the 10 commits here (5dc9ddbb) adds a code path; the rest are documentation and test commits answering review notes, and the last one shrinks them again. The statement count moved from 159 to 219 across the review rounds, all of it test assertions.
✂️ Comment caps
538a21f6 compresses every comment block in internal/config that this branch touches, because main gained the lint:comment-caps gate after the branch was cut and the merged-results pipeline runs it. Three consequences a reviewer of the earlier rounds should know about:
- The prose those rounds added is gone, down to the one load-bearing claim per block. The claims themselves survive; two moved to the tests that pin them, since an unexported function caps at one line.
- b7b76c6a's error taxonomy in the package doc is gone with it. That block was already over the cap before the gate landed, and listing
AuthorizationConfigErrorin it made it a block the diff touches, which the ratchet then obliges to shrink. Each error type documents itself inerrors.go, which is what godoc lists. - The gate has no waiver, so none of this is optional.
Related to #843 (closed)