flex-auth/docs/tenancy-posture-review.md

281 lines
14 KiB
Markdown
Raw Normal View History

# 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**: TokenReview source/desired state done; immutable promotion pending |
| 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.