Review NetKingdom Tenancy Posture v0.1 and declare flex-auth posture
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

Publishes tenancy.yaml (I1 A0 E2 P n/a R n/a, enables A3), the review
answering rapp-postgres's three asks, and FLEX-WP-0015 to close the gaps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 18:09:59 +02:00
parent c84f34cf5f
commit f2b3eeefd8
3 changed files with 485 additions and 0 deletions

View file

@ -0,0 +1,270 @@
# flex-auth review — NetKingdom Tenancy Posture v0.1 (draft-5)
**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 E2 P n/a R n/a enables A3 for consumers
target: I1 A2 E2 P n/a R n/a
```
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.** `POST /v1/check` and
`POST /v1/batch_check` authenticate no caller (`cmd/flex-auth/main.go:349`).
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. Target A2 under `FLEX-WP-0015-T02`.
**E2 is asserted, 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. E2 is therefore claimed with its gap stated rather than
claimed clean, which we read as what §6 requires.
---
## 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/` exists and is complete — an HTTP client for
`GET /tenants/{id}/roles/live`, a `LiveRolesResult`, and `AttachToContext` to
fold live roles into decision context. It has **no non-test caller**. The
decision engine has no field for it and no hook that would invoke it
(`internal/decision/engine.go:21`). The IAM Profile's live re-query capability
is built and unwired.
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's `I` cannot reach I3 today for the same reason. We had assumed
otherwise before this review; that assumption is now corrected in
`tenancy.yaml` and tracked as `FLEX-WP-0015-T03`, whose honest outcome is
either wiring the adapter or deleting it. A built-and-unwired adapter is the
worst of the three states because it reads as 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.
`tenancy.yaml` uses `enables_for_consumers` pending a canonical field name.
### 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**: decide and record how `/v1/check` authenticates its callers |
| T03 | Wire or delete the tenant-engine live-roles adapter |
| T04 | AuthZEN endpoint — `wait`, with a written trigger |
| T05 | Guard: mechanical check that `tenancy.yaml` still matches the code |
Nothing in this review changes a running system, and T02 will not be applied
to production without the usual staged-promotion path.

62
tenancy.yaml Normal file
View file

@ -0,0 +1,62 @@
# flex-auth tenancy posture declaration
# Framework: net-kingdom/canon/standards/tenancy-posture_v0.1.md (§5)
# Conformance rule (§6): accuracy, not altitude. This file overclaims nothing.
# Reasoning and evidence: docs/tenancy-posture-review.md
service: flex-auth
role: policy-decision-point
tenancy:
current:
I: 1
A: 0
E: 2
P: "n/a"
R: "n/a"
target:
I: 1
A: 2
E: 2
P: "n/a"
R: "n/a"
reviewed: "2026-08-17"
service_class: latency-critical
# flex-auth is the PDP, not a PEP. The A ladder as written describes
# enforcement points delegating outward; flex-auth is the thing delegated to.
# Two numbers are therefore needed and only one has a slot (see review §3.1).
enables_for_consumers: 3
gap:
I: >-
Subject and tenant arrive in the CheckRequest body, asserted by the
calling PEP. flex-auth verifies no token and holds no session. This is
I1 by §4.1's literal rule and is structurally correct for a PDP: the
decision point judges asserted claims, it cannot be the verifier of its
own inputs. Not a defect and not a target for movement.
A: >-
POST /v1/check and /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 decision. Mitigated only by
cluster-internal exposure. Target A2 (single inbound choke point binding
caller identity) under FLEX-WP-0015-T02.
E: >-
No tenant data at rest. Tenant scoping in decisions runs through one
choke point (internal/decision/engine.go normalizeRequest and the
relationship tenant match). E2 evidence is adversarial per §13.2 and is
not yet produced; the claim rests on code review only, which §13
does not accept. Treat E2 as asserted-pending-evidence.
P: >-
No rung applies. flex-auth holds no datastore: registry snapshot and
policy package are baked into the image and mounted read-only, and the
production Deployment runs with no --log flag. P0 is the framework's
floor and presupposes a database. Declaring P0 would be a fabrication.
R: >-
No rung applies, same reason. No tenant data is persisted in production,
so there is nothing to retain or erase. R0 ("kept indefinitely by
default") would misdescribe a service that keeps nothing.
evidence:
A: "None. A0 is a declared absence, not a claim; see review §3.5 on low-rung evidence."
E2: "Pending adversarial artifact. Code choke point: internal/decision/engine.go:188,248"
deployment: "deploy/flex-auth-user-engine.yaml, deploy/flex-auth-tenant-engine.yaml"

View file

@ -0,0 +1,153 @@
---
id: FLEX-WP-0015
type: workplan
title: "Tenancy posture declaration and inbound caller authentication"
domain: infotech
repo: flex-auth
status: active
owner: claude
topic_slug: netkingdom
planning_priority: P1
planning_order: 150
depends_on_workplans: []
related_workplans:
- FLEX-WP-0004
- FLEX-WP-0011
created: "2026-08-17"
updated: "2026-08-17"
---
# FLEX-WP-0015 - Tenancy posture declaration and inbound caller authentication
Answer `rapp-postgres`'s review request on **NetKingdom Tenancy Posture v0.1**
(message `82ec43ce`), publish flex-auth's posture vector, and close the one
real defect the review surfaced.
**Review output:** `docs/tenancy-posture-review.md`
**Declaration:** `tenancy.yaml` (repo root, per framework §5.1)
## Why this workplan exists
Three of the framework's five axes describe flex-auth cleanly. Assessing
against them turned up two things we did not know we were carrying:
1. **`/v1/check` authenticates 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
trusts its callers completely. Framework level `A0`.
2. **The tenant-engine live-roles adapter is built and unwired.** No non-test
caller exists. The IAM Profile live re-query reads as a capability we have
and is not one.
The framework itself needs six amendments, two of them corrections of
statements it makes about flex-auth. Those are in the review document and go
back to `rapp-postgres` as review output, not as work here.
**Scope discipline:** this workplan closes flex-auth's own gaps and answers
the review. It does not build fleet-level conformance tooling, does not adopt
AuthZEN, and does not touch production enforcement (`policy.enabled` stays off
per FLEX-WP-0007).
## Tasks
```task
id: FLEX-WP-0015-T01
status: done
priority: high
```
**Publish posture and answer the review.** Write `tenancy.yaml` and
`docs/tenancy-posture-review.md`; reply to `rapp-postgres` with the posture
vector, the A4 ruling, the internal-hop ruling, and the two factual
corrections. Done 2026-08-17.
```task
id: FLEX-WP-0015-T02
status: todo
priority: high
```
**Close the A0 — authenticate callers of `/v1/check`.** Decide first, build
second: this is a production-facing security boundary on a service two
consumers already depend on, and the wrong instrument is worse than the delay.
Record an ADR covering at minimum:
- What identifies a caller — Kubernetes ServiceAccount token review, mTLS via
the cluster's own CA, or a shared secret header. Note that the Deployment
currently sets `automountServiceAccountToken: false`, so option one changes
the pod spec.
- Whether caller identity only authenticates, or also constrains: may
`user-engine` ask questions about resources owned by `tenant-engine`? A
caller allowlist per `system` is the natural shape and flex-auth's registry
already keys on `system`.
- Fail-closed vs fail-open during rollout. Given `policy.enabled` is off in
production and both consumers are ours, a warn-only phase that logs
unauthenticated callers before rejecting them is available and probably
right.
- Whether NetworkPolicy is a sufficient interim control to declare `A1`
honestly before the real fix lands.
Target level `A2` — a single inbound choke point binding caller identity —
with the §13 evidence artifact: a test that an unbound request is refused.
Ship through the FLEX-WP-0011 staged-promotion path, not by direct apply.
```task
id: FLEX-WP-0015-T03
status: todo
priority: medium
```
**Wire or delete `internal/adapters/tenantengine`.** The adapter is complete
and has no non-test caller; `decision.Engine` has no field for it. Both
outcomes are conformant, silence is not.
Deciding factor is whether any live policy needs `tenant_roles` for a
privileged, destructive, credential-vending or `aal2`-class decision — the
IAM Profile's own trigger list. If yes, wire it into the decision path with a
timeout and an explicit unavailable-path decision (`AttachToContext` already
carries `tenant_roles_available: false` for exactly this), and note the new
synchronous dependency on tenant-engine's availability. If no, delete it and
say so in the review record rather than leaving it as latent capability.
Update `tenancy.yaml` `I` either way.
```task
id: FLEX-WP-0015-T04
status: wait
priority: low
```
**AuthZEN evaluation endpoint (framework `A4`).** Deliberately deferred, not
backlogged. `POST /access/v1/evaluation` translating onto the existing engine
beside `/v1/check`, non-breaking.
**Trigger to start:** either a PEP arrives that flex-auth's operator does not
control, or a delegated backend under FLEX-WP-0004 needs uniform addressing.
Until one of those, A4 buys swappability nobody can use.
Note when starting: AuthZEN standardises the envelope, not the action
vocabulary — it does not end action-string copying between repos. The cheaper
fix for that is serving `registry.Actions` read-only, which is independent of
this task and may be worth doing first.
```task
id: FLEX-WP-0015-T05
status: todo
priority: medium
```
**Guard the declaration.** Framework §12 requires verifying the declared
posture still holds — against our own declaration, not a universal maximum.
Small mechanical check, `make verify-posture`, in the shape tenant-engine used
for `verify-pin` (bash, no dependency tree). Assert what code can actually
prove: that the `/v1/check` handler has caller authentication iff
`tenancy.yaml` claims `A >= 2`; that `tenantengine` has a non-test caller iff
`I >= 3`; that the production Deployment carries no `--log` and no volume iff
`R` is `n/a`. Exit 1 on drift.
Designed for invisible failure per §12: posture regression returns
correct-looking responses, exactly like the two silent pin rollbacks the
estate has already been bitten by.