feat(authz): answer advisory permission verdicts through one BatchCheck (S09 Verdicts plan: 2/10)

What

internal/authz gains the verdict evaluator for S09's Permission verdicts: VerdictEvaluator.Evaluate answers advisory per-action allow/deny sets over namespace and repository objects through one ReadRelationships and one BatchCheck, correlated by (ItemIndex, Action). Nothing routes to it yet; Step 4 wires the Deps.Verdicts seam.

Behavior highlights:

  • The unenforced posture answers all-allow without touching IAM or GLAZ; Posture is a named type whose zero value is the enforced, fail-closed arm.
  • Anonymous, zero-tuple, and empty-principal evaluations answer all-deny with no GLAZ call.
  • The evaluator enforces the spec's consumer-side exactly-one-result obligation itself: a missing, duplicate, or unrequested result, a wrong object echo, or an out-of-range index answers the 503 outcome, never a partial set.
  • A malformed query (empty or nil-UUID object id, empty or duplicate action set) fails the whole batch with 503 at WARN, distinguishable from a GLAZ outage.

Beyond the plan's Files list

  • internal/authz/decision.go: behavior-preserving extraction of readObjectTuples, shared by the point-check and verdict paths (the plan's Research Findings record the correction from "reusing readTuples").
  • internal/auth/auth.go: the IsAnonymous doc claim is rephrased so it stays true once verdict callers exist.
  • docs/plans/2026-08-31-s09-permission-verdicts.md: operator-authorized inline amendments — research corrections, the Posture and VerdictQuery naming, Step 3's empty-encoding pin (repositories required, non-null, [] when empty), Step 4's separate caching-resolver directive, measured sizes, and the Status rows for steps 1 and 2. The Status-table single-writer rule is deliberately carried by this MR while it is the only open step MR editing the table.

Size (guardrail 18)

~880 added LOC, split: 275 source (internal/authz/verdicts.go + the decision.go extraction + the auth.go doc), 598 test (internal/authz/verdicts_test.go), the rest plan text. Splitting would separate the evaluator from its suite; the tests are the bulk and ship with the code.

Authorship

Test-first: the test(authz) commit lands the suite against a panic-skeleton under the documented --no-verify carve-out; every later commit ran the full hook chain (one bypass on the branch, by contract). The feat commit also carries the test file's comment-cap compression; its body says why.

e2e catalogs

No scenario added or affected: the evaluator is unreachable until Step 4 wires it.

Spec coverage

Spec: docs/specs/S09-authorization.md

This MR is Step 2 of the S09 Verdicts plan: the evaluator only. Criteria owned by the shipped decision layer cite the existing internal/authz suite; criteria owned by other steps of this plan or by the S09 enforcement plan say so.

Acceptance criteria

# Criterion Tests
1 Anonymous deny Point-check arm: existing suite. Verdict arm: TestVerdictEvaluator_AnonymousAllDeny.
2 Zero-tuple deny Point-check arm: existing suite. Verdict arm: TestVerdictEvaluator_ShortCircuitAllDeny/zero_tuples_deny_without_a_GLAZ_call.
3 Read deny masks Shipped decision layer; existing suite.
4 Write deny consults the read verdict Shipped decision layer; existing suite.
5 Infrastructure failure fails closed Point-check arm: existing suite. Evaluator arm: TestVerdictEvaluator_DependencyFailureAnswers503.
6 Grammar misses fail closed Shipped decision layer; existing suite.
7 Target-anchored ancestor Shipped decision layer; existing suite. Evaluator ancestor assembly is Step 4's wiring.
8 Resolution reuse and pattern preservation Shipped decision layer; existing suite.
9 Mount-source check Shipped decision layer; existing suite.
10 Maven mapping Enforcement plan scope; not this MR.
11 Maven denial shape Enforcement plan scope; not this MR.
12 npm route mapping Enforcement plan scope; not this MR.
13 npm denial shape Enforcement plan scope; not this MR.
14 Management point checks Enforcement plan scope; not this MR.
15 Namespace-scoped checks Enforcement plan scope; not this MR.
16 Suspension gate ordering Enforcement plan scope; not this MR.
17 Listing, namespace branch Step 6 scope (this plan).
18 Listing, filtered branch Enforcement plan scope + Step 6 (this plan).
19 Listing, no access Enforcement plan scope; not this MR.
20 Boot posture Verdict clause (all-allow, nothing calls IAM or GLAZ): TestVerdictEvaluator_Unenforced. Boot/exit/WARN arms: Step 4 wiring and enforcement plan.
21 Namespace endpoint gate Steps 7 and 8 scope (this plan). All-deny groundwork: TestVerdictEvaluator_ShortCircuitAllDeny.
22 Detail verdicts Step 5 scope (this plan). Per-action correlation groundwork: TestVerdictEvaluator_Evaluate.
23 List verdicts under a namespace role Step 6 scope (this plan). Per-row evaluation groundwork (one BatchCheck, one item per row): TestVerdictEvaluator_Evaluate.
24 Opt-out is inert Handler scope, Steps 5, 6, and 8 (this plan): the evaluator is simply not invoked. No evaluator arm exists to test.
25 Malformed BatchCheck fails closed Evaluator arm: TestVerdictEvaluator_MalformedBatchResponseAnswers503 (missing, duplicate, unrequested, wrong object echo, out-of-range and negative index) and TestVerdictEvaluator_DependencyFailureAnswers503/BatchCheck_error. Malformed-shape detection also in the glaz client's own suite. Listing-filter arm: enforcement plan.
26 Management reads are cache-private Step 9 scope (this plan).
27 Parameter validation precedes evaluation Handler scope, Steps 5, 6, and 8 (this plan). Malformed-query refusal at the evaluator boundary: TestVerdictEvaluator_MalformedQueryAnswers503.

Evaluation rules without their own criterion number, pinned here: whole-action-set sets (absence is never a verdict) in assertUniformVerdicts used by every arm; correlation by (ItemIndex, Action) with an overlapping action across queries, never response order, object, or action alone, in TestVerdictEvaluator_Evaluate; the two surface action sets in TestVerdictActionSets; abandon on a done context across all three failure arms in TestVerdictEvaluator_CanceledContextAbandons; the exported zero value stays fail-closed in TestVerdictEvaluator_ZeroValue.

Error cases

# Condition Tests
E-1 Container/OCI denial envelopes Shipped decision layer; existing suite.
E-2 Maven denial envelopes Shipped decision layer; existing suite.
E-3 npm denial envelopes Shipped decision layer; existing suite.
E-4 Management API denial envelopes Rendering: handler steps. The evaluator's 503 outcome feeding the management row: TestVerdictEvaluator_DependencyFailureAnswers503.
E-5 No denial names the deciding permission/role/policy Shipped decision layer; existing suite. Evaluator surfaces booleans only, no reasons: TestVerdictEvaluator_Evaluate (VerdictSet shape).
E-6 No denial echoes request-derived values Shipped decision layer; existing suite.
E-7 503 Retry-After exceeds dependency timeouts Handler/renderer scope; not the evaluator's.
E-8 Canceled request receives no write Evaluator arm: TestVerdictEvaluator_CanceledContextAbandons (all three failure arms). Write suppression is the caller's, as in the decision layer.
E-9 Namespace endpoint: one 404 for unknown slug and cross-org caller Step 7 scope (this plan).

Security considerations

# Concern Tests
S-1 Existence masking is shape-sensitive Shipped decision layer; existing renderer suites.
S-2 Every denial is enveloped Shipped decision layer; existing suite.
S-3 Fail closed everywhere Evaluator arms: TestVerdictEvaluator_DependencyFailureAnswers503, TestVerdictEvaluator_MalformedBatchResponseAnswers503, TestVerdictEvaluator_MalformedQueryAnswers503, TestVerdictEvaluator_ShortCircuitAllDeny, TestVerdictEvaluator_ZeroValue. Other arms: existing suite and enforcement plan.
S-4 Trusts nothing it did not derive Principal from IAM's tuples, never the token: TestVerdictEvaluator_Evaluate. Result identity checked against the request (object echo + index): TestVerdictEvaluator_MalformedBatchResponseAnswers503. Ancestor derivation: Step 4 wiring.
S-5 Log injection Shipped decision layer; existing suite. The evaluator's WARN logs carry only derived fields (query_index, defect).
S-6 Unenforced opt-in hazard Verdict arm (all-allow matches the posture): TestVerdictEvaluator_Unenforced. Posture zero value pinned enforced: TestVerdictEvaluator_ZeroValue. Boot mitigations: Step 4 and enforcement plan.
S-7 Verdicts advisory and organization-bound Advisory-by-construction (evaluator returns booleans, grants nothing). Organization binding: Step 7 scope.
S-8 Credential-type-blind decisions ADR-020/021 accepted trade-off; owned by the decision layer. Not tested in this MR.

Related to #670 (closed)

Merge request reports

Loading
Loading