From f2b3eeefd8fc373bb6526e4d98531f1ce9dcf57f Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 17 Aug 2026 18:09:59 +0200 Subject: [PATCH] Review NetKingdom Tenancy Posture v0.1 and declare flex-auth posture 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 --- docs/tenancy-posture-review.md | 270 ++++++++++++++++++ tenancy.yaml | 62 ++++ ...LEX-WP-0015-tenancy-posture-conformance.md | 153 ++++++++++ 3 files changed, 485 insertions(+) create mode 100644 docs/tenancy-posture-review.md create mode 100644 tenancy.yaml create mode 100644 workplans/FLEX-WP-0015-tenancy-posture-conformance.md diff --git a/docs/tenancy-posture-review.md b/docs/tenancy-posture-review.md new file mode 100644 index 0000000..872b957 --- /dev/null +++ b/docs/tenancy-posture-review.md @@ -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. diff --git a/tenancy.yaml b/tenancy.yaml new file mode 100644 index 0000000..e3f5cad --- /dev/null +++ b/tenancy.yaml @@ -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" diff --git a/workplans/FLEX-WP-0015-tenancy-posture-conformance.md b/workplans/FLEX-WP-0015-tenancy-posture-conformance.md new file mode 100644 index 0000000..d02f710 --- /dev/null +++ b/workplans/FLEX-WP-0015-tenancy-posture-conformance.md @@ -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.