refactor(authz): masking action per surface (S09 Enforcement plan: 5/20)
🎯 What does this MR do?
Makes the masking follow-up read action a required, validated parameter of authz.NewAssembly, and exports ADR-021's ten permission names from internal/authz.
ADR-021 masks a denied non-read action with the read permission that governs the resource's own direct read on that surface: read_artifact for an artifact operation, read_repository for a management one. denialStatus named read_artifact at its follow-up Check and in the log line it writes, which no rule reading the action alone can correct — a denied delete_artifact masks with read_artifact on a format's unpublish route and with read_repository on a management route.
This is step 5 of 20 of the S09 enforcement plan, a root that unblocks steps 7, 11, 14, and 16.
🔨 Changes
| File | Change |
|---|---|
internal/authz/actions.go (new) |
ADR-021's ten permission names as exported glaz.Action values, plus isReadAction |
internal/authz/middleware.go |
unexported const block dropped; Assembly carries maskingReadAction; NewAssembly takes and validates it; denialStatus uses it for the follow-up Check and its log line |
internal/authz/oci.go, internal/authz/mount.go |
moved to the exported names; NewOCIMiddleware supplies read_artifact |
internal/authz/actions_test.go (new) |
pins all ten wire values against literals |
internal/authz/actions_internal_test.go (new) |
pins isReadAction's membership per action, plus the zero value |
internal/authz/middleware_test.go |
arity fix at every call site, plus the construction-time rejection and the per-surface masking cases |
internal/authz/metrics_test.go, internal/authz/oci_internal_test.go |
exported names; the masking field set on all three partial Assembly literals in the denialStatus test, the read case that returns before reading it included |
Exporting the four shipped actions is required rather than cosmetic: the management API names them at its own route bindings, outside this package. The three *_repository_upstream actions bind to no route here and are declared with the rest so the surface that comes to register those operations names an existing value instead of widening the set itself.
✅ Behavior
Unchanged for the container/OCI surface, the only wired caller: NewOCIMiddleware supplies read_artifact, exactly the value denialStatus hardcoded before. Its signature does not change, so no caller outside internal/authz moved.
NewAssembly panics at construction on a write action, an unknown action, or the empty zero value. The validation is a positive allowlist over the two read permissions, so none of the eight newly exported names can be supplied as a masking action.
denialStatus's read-detection arm is unchanged: a denied read of either kind still masks directly with no follow-up Check, whichever action the surface masks with.
🧪 Testing
TestActions_ADR021PermissionNamespins each of the ten wire values against a hand-written literal, since a typo in a GLAZ permission name is aCheckno policy matches rather than a compile error.TestIsReadAction_Membershippins the predicate's answer for each of the ten actions and for the zero value.isReadActiongates two unrelated things --NewAssembly's validation of a supplied masking action, anddenialStatus's decision that a denied read masks directly -- so a read permission that reaches the constants but not the predicate fails closed at the first site and open at the second. Mutation-checked: droppingActionReadRepositoryfrom the predicate fails theread_repositorysubtest.TestNewAssembly_RejectsNonReadMaskingActioncovers the eight write and management actions, an unknown action, and the empty one, plus the two accepted reads.TestAssembly_MaskingReadActionIsPerSurfacepins that the follow-upCheckasks about the surface's own masking action, and that both read permissions still mask with no follow-up. Mutation-checked: restoring theread_artifactliteral indenialStatusfails it.
go test ./... and go build ./... pass. golangci-lint run ./internal/authz/... --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false reports 0 issues. No //go:build integration file is touched, so no build-tagged lint run applies. The permission names were checked against docs/adr/021_authorization.md with the mirror-freshness script reporting no unsynced decision commits for that file at the time of writing.
📊 Diff size
559 insertions, 111 deletions across 10 files — 131/39 source, 428/72 tests (test-to-source 3.3x, the top of the 1.3–3.3x range the plan measured across the OCI authorization predecessors).
Over the 500 reviewable-LOC ceiling, and not split. The gate counts the numstat total, so this is 670 with nothing excludable — all ten files are hand-written Go. Per docs/dev/development-model.md, the justification: 500 of those lines are tests, and the excess over the ceiling is dominated by the NewAssembly arity change rippling through the existing call sites in internal/authz/middleware_test.go, which is mechanical and reviewable in bulk. The source half is 170 lines, well inside the range the ceiling exists to protect. No seam splits this usefully — exporting the actions without the arity change leaves constants no caller reads, and the arity change without them does not compile, so any split produces a non-building intermediate commit.
📚 Conventions
- e2e scenarios: none added or affected. This is a behavior-preserving refactor; the Maven and npm Access control rows move off
blockedin the wiring steps that enforce them. - Configuration reference: no configuration surface changes.
- ADRs: conforms to ADR-021 — the ten names match its permission table verbatim, and the 403/404 masking rule is unchanged.
Related to #847 (closed)