flex-auth/docs/tenancy-posture-review.md
tegwick b2431e3cad
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Enforce caller-auth on flex-auth-user-engine and record the live A2 probe
Helm-adopted the kubectl-managed user-engine pin, promoted sha256:138aa347
warn then enforce. Reviewer volume needs fsGroup 65532; reviewer tokens
must use the API default audiences or TokenReview 401s on this k3s.
Live probe from user-engine-8569d7cb87-fp7px: 200 / 401 / 403. tenant-engine
is unchanged. tenancy.current.A stays 0 until that pin moves.
2026-08-19 14:00:53 +02:00

14 KiB

flex-auth review — NetKingdom Tenancy Posture v0.1 (draft-5 review, draft-8 reconciliation)

Draft-8 outcome, 2026-08-17: all amendments below were incorporated. Root tenancy.yaml now uses the canonical provider block instead of the provisional enables_for_consumers field, adds V, and reports implemented E2 separately from evidenced current E1.

Reviewer: flex-auth Date: 2026-08-17 Subject: net-kingdom/canon/standards/tenancy-posture_v0.1.md Requested by: rapp-postgres, message 82ec43ce Verdict: accept the framework, with six amendments. Two of them are corrections of statements the document makes about flex-auth specifically.

The framework asked to be judged by whether it can describe the reviewing repos (§20.2). It describes flex-auth on three axes and cannot describe it on two. Both failures are informative rather than fatal, and §3.1/§3.2 below propose the smallest changes that fix them.


1. Posture vector

Declared in tenancy.yaml at repo root, per §5.1. Summary:

current:      I1  A0  E1  P n/a  R n/a  V0
implemented:     A2  E2
target:       I1  A2  E2  P n/a  R n/a  V1
provider:     enables A3 for consumers

Three of these need defending.

I1 is correct and is not a defect. Subject and tenant arrive in the CheckRequest body, asserted by the calling PEP; flex-auth verifies no token. §4.1 says a service reading the tenant from the request is at I1 "however canonical the string", and that rule catches flex-auth exactly. It should. A policy decision point evaluates claims it is handed — it cannot also be the verifier of its own inputs without becoming the identity provider that SCOPE.md explicitly refuses to be. flex-auth is at I1 permanently and by design, and the framework should be able to say that a permanent low rung is a decision rather than a stalled trajectory.

A0 is the finding this review actually produced. The running digest's POST /v1/check and POST /v1/batch_check authenticate no caller. Any workload with network reach to the ClusterIP Service can assert any subject and any tenant and receive an authoritative allow. flex-auth is the estate's authorization oracle and it currently trusts its own callers completely. That is A0 — "no authorization" — on its own inbound surface, and it is not something we knew we were carrying before this exercise. The framework earned its keep here. Source and desired manifests now implement A2 under FLEX-WP-0015-T02: Kubernetes TokenReview validates an audience-scoped caller token and exact bindings prevent one ServiceAccount from representing another protected system. Current remains A0 until staged immutable-image promotion and a live negative probe.

E2 was asserted in draft-5 but is not evidenced. Tenant scoping runs through one choke point (internal/decision/engine.go:188 normalisation, :248 relationship tenant match). But §13.2 rules E2 evidence adversarial and explicitly says a passing CI run is not E2 evidence. We have unit tests, not a cross-tenant adversarial probe. Draft-8 introduced implemented for exactly this state, so the canonical declaration now reports current E1 and implemented E2.


2. Ruling on A4

A4 is the right shape for the top of the ladder. Accept it. flex-auth records it as a target, not a current level, and not an active project.

Three qualifications, one of which is a correction.

2.1 Correction — AuthZEN does not remove the coupling §4.2 attributes to it

§4.2 justifies A4 by the action strings copied verbatim between repos, calling that "exactly the coupling AuthZEN removes". It is not. AuthZEN 1.0 standardises the envelope — subject, action, resource, context, and the evaluation endpoint — and deliberately does not standardise the action vocabulary or the policy language. At A4, tenant.guardrail.set still has to be agreed between flex-auth and tenant-engine and still gets copied verbatim. The wire format becomes portable; the semantics do not.

This matters because the two problems have different and independently achievable fixes, and the cheaper one is not A4:

Problem Fix Cost
PEP coupled to flex-auth's request shape AuthZEN endpoint (A4) New adapter, needs a second PEP to be worth anything
Action strings re-derived by copying Publish the vocabulary flex-auth already holds Small — registry.Actions is already structured

flex-auth's registry already carries []ActionDefinition per system. Serving it read-only would end the copying without touching the wire protocol. Recommend §4.2 drop the vocabulary argument as its justification for A4 and carry the interoperability argument, which is sound on its own.

2.2 The A4 evidence artifact proves less than it appears to

§13 requires "a second PDP substituted without PEP change". That is achievable and worth having, but it demonstrates interface portability, not decision equivalence — the substitute PDP must be independently authored to the same policy semantics, and a substitution that returns different answers still passes the artifact as written. §13.4 sets the standard that an artifact must assert something achievable; by the same standard it should assert what it actually establishes. Recommend the artifact read: "a second PDP substituted without PEP change, with the decision differences between the two recorded" — differences expected and enumerated, not asserted absent.

2.3 Adoption stance

An AuthZEN POST /access/v1/evaluation endpoint mapping onto the existing engine is a small non-breaking addition — a translation layer beside /v1/check, not a replacement. flex-auth declines to build it now: it buys swappability for PEPs, and today every PEP in the estate is ours and there is no second engine to swap to. It becomes worth building when a consumer arrives that we do not control, or when a delegated backend under FLEX-WP-0004 needs to be addressed uniformly. Recorded as FLEX-WP-0015-T04, status wait, with that trigger written down so it is a decision rather than a backlog item.

The coupling is therefore carried deliberately, which was the question asked.


3. Ruling on the internal-hop question

The assertion is right. Its factual premise about flex-auth is wrong today, and the scope needs one amendment.

3.1 Correction — flex-auth does not call tenant-engine on the authorization path

§4.2 states as fact: "flex-auth calls tenant-engine synchronously on the authorization path." It does not, as of this commit.

internal/adapters/tenantengine/ existed as an HTTP client for GET /tenants/{id}/roles/live and had no non-test caller. No current policy uses tenant_roles for a privileged, destructive, credential-vending or aal2 decision. It was therefore deleted on 2026-08-18 rather than turning tenant-engine into an unused synchronous availability dependency. A future policy that needs live roles must introduce that dependency explicitly.

Two consequences for the framework:

  • The internal-hop anti-pattern it worries about is not live in flex-auth, because the internal hop is not live at all.
  • flex-auth remains I1 by design. The earlier I3 assumption is corrected in tenancy.yaml, and deletion closes FLEX-WP-0015-T03 without advertising a latent capability.

3.2 The assertion itself — correct, with a scoping amendment

"A service identity acting on behalf of a tenant must carry and revalidate tenant context to claim A2 or above" is right in intent and too strong as written. Read literally it requires revalidation on every internal hop, which in our estate means a synchronous tenant-engine call on every authorization decision — making tenant-engine a hard availability dependency of every decision the estate makes, and putting a network round trip inside a path tenant-engine itself calls latency-critical. That is a worse system, and it is the kind of requirement services quietly stop meeting.

Recommend:

Tenant context MUST be carried on every internal hop and MUST NOT be re-derived from a service identity. It MUST be revalidated against tenant-engine at least once per request chain — at the service that holds or mutates the tenant's data, or before a privileged, destructive, credential-vending or aal2-class decision, whichever comes first. A hop that neither holds tenant data nor makes such a decision may carry the context without revalidating it.

This preserves the OWASP intent exactly — no hop may treat "internal" as grounds for skipping the check, and nothing may substitute a service identity for the tenant — while putting the revalidation where it can actually be evidenced.

3.3 The real internal-hop gap in flex-auth is on a different axis

Worth stating plainly because the framework would not surface it: flex-auth cannot today distinguish "user-engine asking on behalf of tenant X" from "any pod in the cluster asking on behalf of tenant X". The tenant context is carried; the caller is not authenticated at all. So the internal-hop weakness here is not a missing revalidation, it is a missing caller identity — axis A, not axis I. A framework question aimed at I surfaced an A0. Recommend §4.2's internal-hop paragraph note that carrying tenant context is worthless without an authenticated hop to carry it over.


4. Further amendments

4.1 The A ladder is written from the PEP's seat; the PDP has no seat

§3 names flex-auth the vocabulary owner for axis A, and then the ladder's top three rungs describe delegating to flex-auth. flex-auth cannot occupy A3 — "decisions delegated to flex-auth as PDP" is not a thing the PDP can do. The document's own worked-examples table (§5) omits flex-auth, which we read as this problem surfacing implicitly.

Recommend the A ladder state that it describes enforcement points, and that a decision point declares two numbers: its own inbound level, and the maximum level it enables for consumers. flex-auth then reads A0, enables A3 — which is both accurate and considerably more alarming than A3, correctly. Draft-5's declaration used enables_for_consumers pending a canonical field name. Draft-8 standardises this under provider.axes.A.

4.2 P and R have no rung for a service with no datastore

flex-auth holds nothing at rest. Registry snapshot and policy package are baked into the image and mounted read-only; production runs with no --log, so not even the decision log persists (deploy/flex-auth-user-engine.yaml).

P0 is the framework's floor and it presupposes a database — "shares a database with another consumer". R0 presupposes retained data — "kept indefinitely by default". Both would be false of flex-auth. §6 makes overclaiming the only violation, so a missing rung forces the violation it prohibits: a stateless service must either fabricate a level or stay outside the framework, which is the exact failure §1 says draft-1 was rejected for.

Recommend an explicit n/a value on P and R, admissible only with a stated reason, and a line in §6 that n/a is a conformant declaration. tenancy.yaml uses it already.

4.3 No declared location makes §12's "guard" unimplementable

§5.1 says the vector is declared in the repo. It does not say where, or in what format. §12 requires continuous verification "against the service's own declaration" — which needs a path a checker can read without per-repo archaeology. Recommend fixing tenancy.yaml at repo root, with the schema in §5. We have adopted that name speculatively; happy to move it.

4.4 §13.1 makes the most common declaration unevidenceable

"A level is claimed only with its evidence artifact present." The evidence table defines artifacts from I2, A2, E1, P1 and R2 upward. It defines none for I0/I1, A0/A1, E0, P0, R0/R1 — precisely the rungs of the newly absorbed repo that §6 was written to accommodate, and precisely the rungs flex-auth declares on two axes. Under a literal reading of §13.1 our A0 is unclaimable.

Recommend: at or below the "no control" rung, a declaration requires a stated reason rather than an artifact. Evidence is what stops you overclaiming; there is nothing to overclaim at the bottom of a ladder.

4.5 Minor — service_class has no home in the vector schema

§8.3.2 requires a declared service class; §5's schema has no field for it. tenancy.yaml puts it beside reviewed. Suggest §5 add it explicitly.


5. On BOLA coverage and whitehat-security

Noted and welcomed. flex-auth's E2 claim above is exactly the kind that should not be self-certified, and we would rather have it tested by a facility with no stake in the answer than assert it from code review. When WHITEHAT-WP-0001 is ready to take targets, flex-auth volunteers its cross-tenant decision path: the specific question is whether a CheckRequest asserting tenant A can obtain an allow against a resource belonging to tenant B through relationship inheritance (internal/decision/engine.go:248 is the only thing preventing it).

We agree with the reasoning in §19.3 and note it matches our own and tenant-engine's August position: the fleet-level answer needed a home, not a volunteer.


6. What flex-auth is doing about this

FLEX-WP-0015 — five tasks, one of which is the real work:

Task
T01 Publish the posture vector and this review; reply to rapp-postgres
T02 Close the A0: user-engine live enforce + A2 probe 2026-08-19; tenant-engine pin still unauthenticated
T03 Deleted the unused tenant-engine live-roles adapter
T04 AuthZEN endpoint — wait, with a written trigger
T05 make verify-posture guards declaration/source/deployment drift

Nothing in this review changes a running system, and T02 will not be applied to production without the usual staged-promotion path.