From 1e1e077b27ec6d24cee85ca7ac7d721742d1dbb1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 15:22:52 +0200 Subject: [PATCH 1/2] Implement inbound caller authentication (ADR 0004); close T03 and T05 TokenReview-based caller identity with audience-scoped tokens and exact resource.system to ServiceAccount bindings, per ops-warden's recommendation. Deletes the unwired tenant-engine live-roles adapter (T03) and adds make verify-posture (T05). Source implements A2; running digest is still A0 until promotion, so tenancy.current.A stays 0. Co-Authored-By: Claude Opus 5 --- Makefile | 5 +- WORK-RECORDS.md | 6 +- cmd/flex-auth/main.go | 73 +++++++++- cmd/flex-auth/main_test.go | 72 ++++++++++ deploy/caller-auth-rbac.yaml | 51 +++++++ deploy/flex-auth-tenant-engine.yaml | 32 ++++- deploy/flex-auth-user-engine.yaml | 32 ++++- .../adr/0004-inbound-caller-authentication.md | 65 +++++++++ docs/tenancy-posture-review.md | 58 ++++---- .../adapters/tenantengine/context_test.go | 42 ------ internal/adapters/tenantengine/http_client.go | 65 --------- .../adapters/tenantengine/http_client_test.go | 117 ---------------- internal/adapters/tenantengine/types.go | 85 ------------ internal/callerauth/auth.go | 125 ++++++++++++++++++ internal/callerauth/auth_test.go | 72 ++++++++++ internal/callerauth/tokenreview.go | 110 +++++++++++++++ tenancy.yaml | 61 ++++++--- tools/verify-posture.sh | 54 ++++++++ 18 files changed, 768 insertions(+), 357 deletions(-) create mode 100644 deploy/caller-auth-rbac.yaml create mode 100644 docs/adr/0004-inbound-caller-authentication.md delete mode 100644 internal/adapters/tenantengine/context_test.go delete mode 100644 internal/adapters/tenantengine/http_client.go delete mode 100644 internal/adapters/tenantengine/http_client_test.go delete mode 100644 internal/adapters/tenantengine/types.go create mode 100644 internal/callerauth/auth.go create mode 100644 internal/callerauth/auth_test.go create mode 100644 internal/callerauth/tokenreview.go create mode 100644 tools/verify-posture.sh diff --git a/Makefile b/Makefile index 315720b..3b85ced 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ PKG := ./... VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev) LDFLAGS := -X main.version=$(VERSION) -.PHONY: all build test vet lint fmt tidy sbom clean ci overlay-render overlay-dry-run +.PHONY: all build test vet lint fmt tidy sbom clean ci overlay-render overlay-dry-run verify-posture all: vet lint test build @@ -58,6 +58,9 @@ clean: ci: vet lint test build overlay-render +verify-posture: + @bash tools/verify-posture.sh + overlay-render: @tests/stage1.sh diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index a0ab7fc..9bf8b73 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -90,7 +90,7 @@ | task | FLEX-WP-0014-T03 | done | — | workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md | | task | FLEX-WP-0014-T04 | done | — | workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md | | task | FLEX-WP-0015-T01 | done | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | -| task | FLEX-WP-0015-T02 | todo | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | -| task | FLEX-WP-0015-T03 | todo | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | +| task | FLEX-WP-0015-T02 | progress | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | +| task | FLEX-WP-0015-T03 | done | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | | task | FLEX-WP-0015-T04 | wait | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | -| task | FLEX-WP-0015-T05 | todo | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | +| task | FLEX-WP-0015-T05 | done | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | diff --git a/cmd/flex-auth/main.go b/cmd/flex-auth/main.go index 1dddab8..0c148e8 100644 --- a/cmd/flex-auth/main.go +++ b/cmd/flex-auth/main.go @@ -5,6 +5,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "io" @@ -15,6 +16,7 @@ import ( "gopkg.in/yaml.v3" "github.com/netkingdom/flex-auth/internal/audit" + "github.com/netkingdom/flex-auth/internal/callerauth" decisioncore "github.com/netkingdom/flex-auth/internal/decision" "github.com/netkingdom/flex-auth/internal/policy" "github.com/netkingdom/flex-auth/internal/registry" @@ -324,6 +326,13 @@ func runServe(args []string, stdout, stderr io.Writer) int { registryPath := fs.String("registry", "", "registry snapshot JSON file") policyPath := fs.String("policy", "", "policy package Markdown file") logPath := fs.String("log", "", "optional JSONL decision log path") + callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce") + callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience") + callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview") + callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential") + callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle") + var callerBindings keyValueFlags + fs.Var(&callerBindings, "caller-binding", "resource system=authenticated Kubernetes principal (repeatable)") if err := fs.Parse(args); err != nil { return 64 } @@ -337,7 +346,20 @@ func runServe(args []string, stdout, stderr io.Writer) int { return fail(stderr, err) } - mux := newServeMux(engine) + authenticator, err := buildCallerAuthenticator( + callerauth.Mode(*callerAuthMode), + *callerAudience, + *callerKubernetesURL, + *callerReviewerTokenFile, + *callerCAFile, + callerBindings.StringMap(), + stderr, + ) + if err != nil { + return fail(stderr, err) + } + + mux := newServeMuxWithCallerAuth(engine, authenticator) fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr) if err := http.ListenAndServe(*addr, mux); err != nil { @@ -347,6 +369,10 @@ func runServe(args []string, stdout, stderr io.Writer) int { } func newServeMux(engine *decisioncore.Engine) *http.ServeMux { + return newServeMuxWithCallerAuth(engine, callerauth.Disabled()) +} + +func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *callerauth.Authenticator) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("content-type", "application/json") @@ -362,6 +388,10 @@ func newServeMux(engine *decisioncore.Engine) *http.ServeMux { http.Error(w, err.Error(), http.StatusBadRequest) return } + if err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System}); err != nil { + writeCallerAuthError(w, err) + return + } decision, err := engine.Check(r.Context(), request) writeHTTP(w, decision, err) }) @@ -375,12 +405,44 @@ func newServeMux(engine *decisioncore.Engine) *http.ServeMux { http.Error(w, err.Error(), http.StatusBadRequest) return } + systems := make([]string, 0, len(request.Resources)) + for _, resource := range request.Resources { + systems = append(systems, resource.System) + } + if err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems); err != nil { + writeCallerAuthError(w, err) + return + } decisions, err := engine.BatchCheck(r.Context(), request) writeHTTP(w, decisions, err) }) return mux } +func buildCallerAuthenticator(mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string, stderr io.Writer) (*callerauth.Authenticator, error) { + if mode == callerauth.ModeDisabled { + return callerauth.New(mode, nil, "", nil, nil) + } + reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile) + if err != nil { + return nil, err + } + return callerauth.New(mode, reviewer, audience, bindings, func(format string, args ...any) { + fmt.Fprintf(stderr, format+"\n", args...) + }) +} + +func writeCallerAuthError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, callerauth.ErrUnauthenticated): + http.Error(w, "unauthenticated", http.StatusUnauthorized) + case errors.Is(err, callerauth.ErrForbidden): + http.Error(w, "forbidden", http.StatusForbidden) + default: + http.Error(w, "caller authentication unavailable", http.StatusServiceUnavailable) + } +} + func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) { store, err := registry.LoadFile(registryPath) if err != nil { @@ -503,3 +565,12 @@ func (f keyValueFlags) Map() map[string]any { } return out } + +func (f keyValueFlags) StringMap() map[string]string { + out := make(map[string]string, len(f)) + for _, item := range f { + key, value, _ := strings.Cut(item, "=") + out[key] = value + } + return out +} diff --git a/cmd/flex-auth/main_test.go b/cmd/flex-auth/main_test.go index a95d208..d210c5f 100644 --- a/cmd/flex-auth/main_test.go +++ b/cmd/flex-auth/main_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/netkingdom/flex-auth/internal/callerauth" "github.com/netkingdom/flex-auth/pkg/api" ) @@ -157,6 +158,77 @@ func TestServeOpsWardenCheckContract(t *testing.T) { } } +type fixedTokenReviewer struct { + identity callerauth.Identity +} + +func (r fixedTokenReviewer) Review(context.Context, string) (callerauth.Identity, error) { + return r.identity, nil +} + +func TestServeCallerAuthenticationBindsSystemToPrincipal(t *testing.T) { + engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "") + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ + Username: "system:serviceaccount:ops-warden:ops-warden", + Audiences: []string{"flex-auth"}, + }}, "flex-auth", map[string]string{ + "ops-warden": "system:serviceaccount:ops-warden:ops-warden", + }, nil) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator)) + defer server.Close() + body, err := os.ReadFile(opsPath("check_request_allow_adm.json")) + if err != nil { + t.Fatal(err) + } + + request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) + request.Header.Set("content-type", "application/json") + resp, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("missing token status = %d; want 401", resp.StatusCode) + } + + request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) + request.Header.Set("content-type", "application/json") + request.Header.Set("authorization", "Bearer workload-token") + resp, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("bound caller status = %d; want 200", resp.StatusCode) + } + + wrong, _ := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ + Username: "system:serviceaccount:another:caller", + Audiences: []string{"flex-auth"}, + }}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil) + wrongServer := httptest.NewServer(newServeMuxWithCallerAuth(engine, wrong)) + defer wrongServer.Close() + request, _ = http.NewRequest(http.MethodPost, wrongServer.URL+"/v1/check", bytes.NewReader(body)) + request.Header.Set("content-type", "application/json") + request.Header.Set("authorization", "Bearer workload-token") + resp, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("mismatched caller status = %d; want 403", resp.StatusCode) + } +} + func TestRunLoadRegistryOpsWardenProduction(t *testing.T) { var stdout, stderr bytes.Buffer code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr) diff --git a/deploy/caller-auth-rbac.yaml b/deploy/caller-auth-rbac.yaml new file mode 100644 index 0000000..85c4273 --- /dev/null +++ b/deploy/caller-auth-rbac.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: flex-auth-tenant-engine + namespace: flex-auth +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: flex-auth-user-engine + namespace: flex-auth +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: flex-auth-tokenreviewer +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: flex-auth-tenant-engine-tokenreviewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: flex-auth-tokenreviewer +subjects: +- kind: ServiceAccount + name: flex-auth-tenant-engine + namespace: flex-auth +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: flex-auth-user-engine-tokenreviewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: flex-auth-tokenreviewer +subjects: +- kind: ServiceAccount + name: flex-auth-user-engine + namespace: flex-auth diff --git a/deploy/flex-auth-tenant-engine.yaml b/deploy/flex-auth-tenant-engine.yaml index c1104f6..b1e0f64 100644 --- a/deploy/flex-auth-tenant-engine.yaml +++ b/deploy/flex-auth-tenant-engine.yaml @@ -14,6 +14,7 @@ spec: app.kubernetes.io/name: flex-auth-tenant-engine spec: automountServiceAccountToken: false + serviceAccountName: flex-auth-tenant-engine containers: - args: - serve @@ -23,6 +24,12 @@ spec: - /opt/flex-auth/examples/tenant-engine/registry_snapshot.json - --policy - /opt/flex-auth/examples/tenant-engine/policy_package.md + - --caller-auth-mode + - enforce + - --caller-kubernetes-url + - https://10.43.0.1 + - --caller-binding + - tenant-engine=system:serviceaccount:tenant-engine:tenant-engine image: forgejo.coulomb.social/coulomb/flex-auth@sha256:1bf060e61122693ce98359c167cc5fe8bdafc84e097e090eaa71af94d0f27cbc livenessProbe: httpGet: @@ -51,10 +58,28 @@ spec: drop: - ALL readOnlyRootFilesystem: true + volumeMounts: + - mountPath: /var/run/secrets/flex-auth-reviewer + name: flex-auth-reviewer + readOnly: true securityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault + volumes: + - name: flex-auth-reviewer + projected: + defaultMode: 0440 + sources: + - serviceAccountToken: + audience: https://kubernetes.default.svc + expirationSeconds: 3600 + path: token + - configMap: + items: + - key: ca.crt + path: ca.crt + name: kube-root-ca.crt --- apiVersion: v1 kind: Service @@ -75,7 +100,12 @@ metadata: name: flex-auth-tenant-engine namespace: flex-auth spec: - egress: [] + egress: + - ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP ingress: - from: - namespaceSelector: diff --git a/deploy/flex-auth-user-engine.yaml b/deploy/flex-auth-user-engine.yaml index 72cde33..da6595d 100644 --- a/deploy/flex-auth-user-engine.yaml +++ b/deploy/flex-auth-user-engine.yaml @@ -14,6 +14,7 @@ spec: app.kubernetes.io/name: flex-auth-user-engine spec: automountServiceAccountToken: false + serviceAccountName: flex-auth-user-engine containers: - args: - serve @@ -23,6 +24,12 @@ spec: - /opt/flex-auth/examples/user-engine/registry_snapshot.json - --policy - /opt/flex-auth/examples/user-engine/policy_package.md + - --caller-auth-mode + - enforce + - --caller-kubernetes-url + - https://10.43.0.1 + - --caller-binding + - user-engine=system:serviceaccount:user-engine:user-engine image: forgejo.coulomb.social/coulomb/flex-auth@sha256:1f5290376dc5fcf456dc7a785e394d8b90949dabecd1d3e856f38557149bb5f4 livenessProbe: httpGet: @@ -51,10 +58,28 @@ spec: drop: - ALL readOnlyRootFilesystem: true + volumeMounts: + - mountPath: /var/run/secrets/flex-auth-reviewer + name: flex-auth-reviewer + readOnly: true securityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault + volumes: + - name: flex-auth-reviewer + projected: + defaultMode: 0440 + sources: + - serviceAccountToken: + audience: https://kubernetes.default.svc + expirationSeconds: 3600 + path: token + - configMap: + items: + - key: ca.crt + path: ca.crt + name: kube-root-ca.crt --- apiVersion: v1 kind: Service @@ -75,7 +100,12 @@ metadata: name: flex-auth-user-engine namespace: flex-auth spec: - egress: [] + egress: + - ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP ingress: - from: - namespaceSelector: diff --git a/docs/adr/0004-inbound-caller-authentication.md b/docs/adr/0004-inbound-caller-authentication.md new file mode 100644 index 0000000..92b87ae --- /dev/null +++ b/docs/adr/0004-inbound-caller-authentication.md @@ -0,0 +1,65 @@ +# ADR 0004 — authenticate and bind authorization callers + +Status: accepted (source implemented; production promotion pending) + +Date: 2026-08-18 + +## Context + +`POST /v1/check` and `/v1/batch_check` accepted an asserted subject, tenant and +protected-system name from any workload with network reach. NetworkPolicy +limited reachability but did not establish caller identity. A caller could +therefore represent another protected system and obtain an authoritative +decision under the wrong policy package. + +The boundary must authenticate workloads without turning flex-auth into an +identity issuer, sharing a long-lived secret between services, or coupling +authorization availability to an unrelated identity-provider round trip. + +## Decision + +Use Kubernetes ServiceAccount tokens with audience `flex-auth`. flex-auth calls +the Kubernetes TokenReview API through a separately projected reviewer token +and binds the authenticated ServiceAccount principal to every +`resource.system` in the request. Both the single and batch endpoints use the +same choke point; health remains unauthenticated. + +Each deployed policy instance has an explicit, exact binding. For example: + +```text +tenant-engine=system:serviceaccount:tenant-engine:tenant-engine +user-engine=system:serviceaccount:user-engine:user-engine +``` + +Unknown systems, missing or invalid tokens, audience mismatch and principal +mismatch fail closed. TokenReview unavailability returns 503 rather than an +authorization answer. Tokens and reviewer credentials are re-read rather than +cached across rotation. + +Three modes support promotion: `disabled`, `warn`, and `enforce`. Warn mode +records the same authentication failures without logging credentials. It is a +bounded migration aid, not a conformant steady state. The reviewed desired +manifests select `enforce`; promotion still follows FLEX-WP-0011 and requires a +new immutable image digest plus caller rollout evidence. + +## Rejected alternatives + +- NetworkPolicy alone proves network position, not workload identity. +- A shared header secret has broad replay and rotation blast radius and cannot + bind a Kubernetes workload principal. +- Application mTLS would add a separate certificate lifecycle where the + cluster already has short-lived projected workload identity. +- Using the caller token itself to invoke TokenReview would grant callers an + unnecessary API permission. A narrow reviewer ServiceAccount holds only + `create` on `tokenreviews.authentication.k8s.io`. + +## Consequences + +- Caller identity is bound once at the flex-auth ingress and cannot be swapped + by changing request JSON. +- flex-auth depends on the Kubernetes authentication API for uncached checks; + an outage fails closed with 503. +- Each new protected system needs an explicit binding and projected caller + token. There is no wildcard binding. +- Source and desired state reach A2, while the live declaration remains A0 + until the immutable digest is promoted and probed. diff --git a/docs/tenancy-posture-review.md b/docs/tenancy-posture-review.md index 872b957..8919d28 100644 --- a/docs/tenancy-posture-review.md +++ b/docs/tenancy-posture-review.md @@ -1,4 +1,9 @@ -# flex-auth review — NetKingdom Tenancy Posture v0.1 (draft-5) +# 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 @@ -19,8 +24,10 @@ propose the smallest changes that fix them. 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 +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. @@ -35,21 +42,25 @@ 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`). +**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. Target A2 under `FLEX-WP-0015-T02`. +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 is asserted, not evidenced.** Tenant scoping runs through one choke +**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. E2 is therefore claimed with its gap stated rather than -claimed clean, which we read as what §6 requires. +adversarial probe. Draft-8 introduced `implemented` for exactly this state, so +the canonical declaration now reports current E1 and implemented E2. --- @@ -121,22 +132,20 @@ and the scope needs one amendment.** §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. +`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'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. +- 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 @@ -191,7 +200,8 @@ 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. +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 @@ -261,10 +271,10 @@ volunteer. | 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 | +| 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 | Guard: mechanical check that `tenancy.yaml` still matches the code | +| 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. diff --git a/internal/adapters/tenantengine/context_test.go b/internal/adapters/tenantengine/context_test.go deleted file mode 100644 index 6334f1b..0000000 --- a/internal/adapters/tenantengine/context_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package tenantengine_test - -import ( - "testing" - - "github.com/netkingdom/flex-auth/internal/adapters/tenantengine" -) - -func TestAttachToContextSetsRolesAndAvailability(t *testing.T) { - ctx := tenantengine.AttachToContext(nil, tenantengine.LiveRolesResult{ - Roles: []string{"CUS"}, - Available: true, - }) - - if ctx["tenant_roles_available"] != true { - t.Fatalf("tenant_roles_available = %v, want true", ctx["tenant_roles_available"]) - } - roles, ok := ctx["tenant_roles"].([]string) - if !ok || len(roles) != 1 || roles[0] != "CUS" { - t.Fatalf("tenant_roles = %v", ctx["tenant_roles"]) - } -} - -func TestAttachToContextMarksUnavailableOnFailure(t *testing.T) { - ctx := tenantengine.AttachToContext(map[string]any{"existing": "field"}, tenantengine.LiveRolesResult{ - Available: false, - }) - - if ctx["tenant_roles_available"] != false { - t.Fatalf("tenant_roles_available = %v, want false", ctx["tenant_roles_available"]) - } - if ctx["existing"] != "field" { - t.Fatal("AttachToContext must not clobber unrelated context fields") - } -} - -func TestAttachToContextHandlesNilContext(t *testing.T) { - ctx := tenantengine.AttachToContext(nil, tenantengine.LiveRolesResult{Available: true, Roles: []string{}}) - if ctx == nil { - t.Fatal("expected a non-nil map") - } -} diff --git a/internal/adapters/tenantengine/http_client.go b/internal/adapters/tenantengine/http_client.go deleted file mode 100644 index d852d6b..0000000 --- a/internal/adapters/tenantengine/http_client.go +++ /dev/null @@ -1,65 +0,0 @@ -package tenantengine - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" -) - -// HTTPClient calls tenant-engine's live-lookup endpoint -// (GET /tenants/{id}/roles/live). -type HTTPClient struct { - BaseURL string - Client *http.Client -} - -// NewHTTPClient creates an HTTP-backed tenant-engine client. -func NewHTTPClient(baseURL string) (*HTTPClient, error) { - if baseURL == "" { - return nil, fmt.Errorf("tenant-engine base URL is required") - } - return &HTTPClient{ - BaseURL: strings.TrimRight(baseURL, "/"), - Client: &http.Client{Timeout: 3 * time.Second}, - }, nil -} - -// LiveRoles calls GET /tenants/{tenantID}/roles/live. -// -// Fail-closed by construction: any transport error, non-200 response, or -// malformed body returns LiveRolesResult{Available: false} alongside a -// non-nil error. Nothing is inferred as "zero roles" from a failure -- -// callers must check Available, not just the length of Roles. -func (c *HTTPClient) LiveRoles(ctx context.Context, tenantID string) (LiveRolesResult, error) { - url := fmt.Sprintf("%s/tenants/%s/roles/live", c.BaseURL, tenantID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err) - } - - resp, err := c.Client.Do(req) - if err != nil { - return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return LiveRolesResult{Available: false}, NewBackendError( - FailureUnavailable, "live_roles", fmt.Errorf("status %d", resp.StatusCode), - ) - } - - var body struct { - TenantID string `json:"tenant_id"` - Roles []string `json:"roles"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return LiveRolesResult{Available: false}, NewBackendError(FailureInvalidResponse, "live_roles", err) - } - - return LiveRolesResult{Roles: body.Roles, Available: true}, nil -} diff --git a/internal/adapters/tenantengine/http_client_test.go b/internal/adapters/tenantengine/http_client_test.go deleted file mode 100644 index 6e5890d..0000000 --- a/internal/adapters/tenantengine/http_client_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package tenantengine_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/netkingdom/flex-auth/internal/adapters/tenantengine" -) - -func TestLiveRolesReturnsRolesOnSuccess(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/tenants/t-1/roles/live" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"tenant_id":"t-1","roles":["CUS","VEN"]}`)) - })) - defer server.Close() - - client, err := tenantengine.NewHTTPClient(server.URL) - if err != nil { - t.Fatalf("NewHTTPClient: %v", err) - } - - result, err := client.LiveRoles(context.Background(), "t-1") - if err != nil { - t.Fatalf("LiveRoles: %v", err) - } - if !result.Available { - t.Fatal("expected Available = true") - } - if len(result.Roles) != 2 || result.Roles[0] != "CUS" || result.Roles[1] != "VEN" { - t.Fatalf("unexpected roles: %v", result.Roles) - } -} - -func TestLiveRolesReturnsUnavailableOnNon200(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer server.Close() - - client, _ := tenantengine.NewHTTPClient(server.URL) - result, err := client.LiveRoles(context.Background(), "t-1") - - if err == nil { - t.Fatal("expected an error") - } - if result.Available { - t.Fatal("expected Available = false on a 503, not indistinguishable from zero roles") - } - if result.Roles != nil { - t.Fatalf("expected nil roles on failure, got %v", result.Roles) - } -} - -func TestLiveRolesReturnsUnavailableOnMalformedBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("not json")) - })) - defer server.Close() - - client, _ := tenantengine.NewHTTPClient(server.URL) - result, err := client.LiveRoles(context.Background(), "t-1") - - if err == nil { - t.Fatal("expected an error") - } - if result.Available { - t.Fatal("expected Available = false on malformed body") - } -} - -func TestLiveRolesReturnsUnavailableOnConnectionFailure(t *testing.T) { - client, _ := tenantengine.NewHTTPClient("http://127.0.0.1:1") - - result, err := client.LiveRoles(context.Background(), "t-1") - - if err == nil { - t.Fatal("expected an error") - } - if result.Available { - t.Fatal("expected Available = false on connection failure") - } -} - -func TestLiveRolesRespectsContextTimeout(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(200 * time.Millisecond) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"tenant_id":"t-1","roles":[]}`)) - })) - defer server.Close() - - client, _ := tenantengine.NewHTTPClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - result, err := client.LiveRoles(ctx, "t-1") - - if err == nil { - t.Fatal("expected a timeout error") - } - if result.Available { - t.Fatal("expected Available = false on timeout") - } -} - -func TestNewHTTPClientRequiresBaseURL(t *testing.T) { - if _, err := tenantengine.NewHTTPClient(""); err == nil { - t.Fatal("expected an error for empty base URL") - } -} diff --git a/internal/adapters/tenantengine/types.go b/internal/adapters/tenantengine/types.go deleted file mode 100644 index e1bfaf6..0000000 --- a/internal/adapters/tenantengine/types.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package tenantengine provides a context-enrichment adapter for -// tenant-engine's live-lookup endpoint (FLEX-WP-0008-T03). -// -// Unlike the topaz/relationship/rule adapters, this is not a delegated -// policy decision point -- Rego evaluation is stateless and cannot make an -// HTTP call mid-evaluation. This adapter is a request-preparation helper: -// whichever protected system's policy needs a tenant's capability roles -// (PLTF/IAM/VEN/CUS, ADR-0014) calls LiveRoles before building its -// CheckRequest, then attaches the result to request.Context via -// AttachToContext. tenant-engine's own write-API policy -// (examples/tenant-engine/policy_package.md) does NOT use this adapter -- -// it authorizes by operator/service identity, a different question from a -// tenant's own capability roles. -package tenantengine - -import "fmt" - -// FailureKind classifies fail-closed tenant-engine lookup failures. -type FailureKind string - -const ( - FailureUnavailable FailureKind = "unavailable" - FailureInvalidResponse FailureKind = "invalid_response" -) - -// BackendError wraps transport and backend failures with adapter semantics. -type BackendError struct { - Kind FailureKind - Op string - Err error -} - -func (e *BackendError) Error() string { - if e == nil { - return "" - } - if e.Err == nil { - return fmt.Sprintf("tenant-engine %s failed: %s", e.Op, e.Kind) - } - return fmt.Sprintf("tenant-engine %s failed: %s: %v", e.Op, e.Kind, e.Err) -} - -func (e *BackendError) Unwrap() error { - if e == nil { - return nil - } - return e.Err -} - -// NewBackendError classifies an adapter backend error. -func NewBackendError(kind FailureKind, op string, err error) error { - return &BackendError{Kind: kind, Op: op, Err: err} -} - -// LiveRolesResult is the outcome of a live-lookup call. -// -// Available is the load-bearing field: false means the lookup could not be -// completed for any reason (transport failure, non-200, malformed body) and -// MUST be treated as deny by any consuming policy -- never conflated with -// Available: true, Roles: [] (a tenant that legitimately holds no roles). -// This mirrors the exact rule tenant-engine's own read endpoints already -// enforce (GET /tenants/{id}/roles/live never returns 200 + [] on an -// outage) -- this adapter does not weaken it on the consuming side. -type LiveRolesResult struct { - Roles []string - Available bool -} - -// AttachToContext writes the live-lookup result into a CheckRequest's -// Context map under the "tenant_roles" / "tenant_roles_available" keys. -// Any Rego policy consuming tenant capability roles MUST check -// tenant_roles_available == true before trusting tenant_roles -- see -// examples/tenant-engine/README.md for the required Rego pattern. -func AttachToContext(context map[string]any, result LiveRolesResult) map[string]any { - if context == nil { - context = map[string]any{} - } - roles := result.Roles - if roles == nil { - roles = []string{} - } - context["tenant_roles"] = roles - context["tenant_roles_available"] = result.Available - return context -} diff --git a/internal/callerauth/auth.go b/internal/callerauth/auth.go new file mode 100644 index 0000000..e446df4 --- /dev/null +++ b/internal/callerauth/auth.go @@ -0,0 +1,125 @@ +// Package callerauth authenticates protected systems before flex-auth evaluates +// the authorization request they submit. +package callerauth + +import ( + "context" + "errors" + "fmt" + "strings" +) + +type Mode string + +const ( + ModeDisabled Mode = "disabled" + ModeWarn Mode = "warn" + ModeEnforce Mode = "enforce" +) + +var ( + ErrUnauthenticated = errors.New("caller is not authenticated") + ErrForbidden = errors.New("caller is not allowed to represent the requested system") + ErrUnavailable = errors.New("caller identity service is unavailable") +) + +type Identity struct { + Username string + Audiences []string +} + +type TokenReviewer interface { + Review(context.Context, string) (Identity, error) +} + +type WarningFunc func(string, ...any) + +type Authenticator struct { + mode Mode + reviewer TokenReviewer + audience string + bindings map[string]string + warnf WarningFunc +} + +func New(mode Mode, reviewer TokenReviewer, audience string, bindings map[string]string, warnf WarningFunc) (*Authenticator, error) { + switch mode { + case ModeDisabled: + return &Authenticator{mode: mode}, nil + case ModeWarn, ModeEnforce: + default: + return nil, fmt.Errorf("unsupported caller-auth mode %q", mode) + } + if reviewer == nil { + return nil, fmt.Errorf("token reviewer is required in %s mode", mode) + } + if strings.TrimSpace(audience) == "" { + return nil, fmt.Errorf("caller audience is required in %s mode", mode) + } + if len(bindings) == 0 { + return nil, fmt.Errorf("at least one caller binding is required in %s mode", mode) + } + copyBindings := make(map[string]string, len(bindings)) + for system, principal := range bindings { + if strings.TrimSpace(system) == "" || strings.TrimSpace(principal) == "" { + return nil, fmt.Errorf("caller bindings require non-empty system and principal") + } + copyBindings[system] = principal + } + return &Authenticator{mode: mode, reviewer: reviewer, audience: audience, bindings: copyBindings, warnf: warnf}, nil +} + +func Disabled() *Authenticator { + authenticator, _ := New(ModeDisabled, nil, "", nil, nil) + return authenticator +} + +// Authorize verifies the bearer token and binds every resource.system value to +// the authenticated workload principal. Warn mode records the same failures but +// permits the request so callers can be migrated before enforcement is enabled. +func (a *Authenticator) Authorize(ctx context.Context, authorization string, systems []string) error { + if a == nil || a.mode == ModeDisabled { + return nil + } + err := a.authorize(ctx, authorization, systems) + if err != nil && a.mode == ModeWarn { + if a.warnf != nil { + a.warnf("caller authentication warning: %v", err) + } + return nil + } + return err +} + +func (a *Authenticator) authorize(ctx context.Context, authorization string, systems []string) error { + token, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), " \t\r\n") { + return ErrUnauthenticated + } + identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token)) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) { + return ErrUnauthenticated + } + if len(systems) == 0 { + return fmt.Errorf("%w: request has no resources", ErrForbidden) + } + for _, system := range systems { + expected, found := a.bindings[system] + if !found || expected != identity.Username { + return fmt.Errorf("%w: principal %q cannot represent system %q", ErrForbidden, identity.Username, system) + } + } + return nil +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/internal/callerauth/auth_test.go b/internal/callerauth/auth_test.go new file mode 100644 index 0000000..2e40384 --- /dev/null +++ b/internal/callerauth/auth_test.go @@ -0,0 +1,72 @@ +package callerauth + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeReviewer struct { + identity Identity + err error +} + +func (f fakeReviewer) Review(context.Context, string) (Identity, error) { + return f.identity, f.err +} + +func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) { + authenticator, err := New(ModeEnforce, fakeReviewer{identity: Identity{ + Username: "system:serviceaccount:tenant-engine:tenant-engine", + Audiences: []string{"flex-auth"}, + }}, "flex-auth", map[string]string{ + "tenant-engine": "system:serviceaccount:tenant-engine:tenant-engine", + }, nil) + if err != nil { + t.Fatal(err) + } + if err := authenticator.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"}); err != nil { + t.Fatalf("Authorize: %v", err) + } + if err := authenticator.Authorize(context.Background(), "Bearer caller-token", []string{"user-engine"}); !errors.Is(err, ErrForbidden) { + t.Fatalf("system mismatch error = %v; want forbidden", err) + } + + wrongAudience, _ := New(ModeEnforce, fakeReviewer{identity: Identity{ + Username: "system:serviceaccount:tenant-engine:tenant-engine", + Audiences: []string{"kubernetes"}, + }}, "flex-auth", map[string]string{"tenant-engine": "system:serviceaccount:tenant-engine:tenant-engine"}, nil) + if err := wrongAudience.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("audience error = %v; want unauthenticated", err) + } +} + +func TestAuthenticatorRejectsMissingTokenAndReviewerFailure(t *testing.T) { + bindings := map[string]string{"tenant-engine": "principal"} + authenticator, _ := New(ModeEnforce, fakeReviewer{identity: Identity{Username: "principal", Audiences: []string{"flex-auth"}}}, "flex-auth", bindings, nil) + if err := authenticator.Authorize(context.Background(), "", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("missing token error = %v; want unauthenticated", err) + } + + unavailable, _ := New(ModeEnforce, fakeReviewer{err: errors.New("apiserver down")}, "flex-auth", bindings, nil) + if err := unavailable.Authorize(context.Background(), "Bearer token", []string{"tenant-engine"}); !errors.Is(err, ErrUnavailable) { + t.Fatalf("reviewer error = %v; want unavailable", err) + } +} + +func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) { + var warning string + authenticator, err := New(ModeWarn, fakeReviewer{}, "flex-auth", map[string]string{"tenant-engine": "principal"}, func(format string, _ ...any) { + warning = format + }) + if err != nil { + t.Fatal(err) + } + if err := authenticator.Authorize(context.Background(), "", []string{"tenant-engine"}); err != nil { + t.Fatalf("warn mode returned error: %v", err) + } + if !strings.Contains(warning, "warning") { + t.Fatalf("warning = %q", warning) + } +} diff --git a/internal/callerauth/tokenreview.go b/internal/callerauth/tokenreview.go new file mode 100644 index 0000000..48a3e56 --- /dev/null +++ b/internal/callerauth/tokenreview.go @@ -0,0 +1,110 @@ +package callerauth + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +type KubernetesTokenReviewer struct { + endpoint string + audience string + reviewerTokenFile string + client *http.Client +} + +func NewKubernetesTokenReviewer(endpoint, audience, reviewerTokenFile, caFile string) (*KubernetesTokenReviewer, error) { + ca, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read Kubernetes CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("Kubernetes CA file contains no certificates") + } + return &KubernetesTokenReviewer{ + endpoint: strings.TrimRight(endpoint, "/") + "/apis/authentication.k8s.io/v1/tokenreviews", + audience: audience, + reviewerTokenFile: reviewerTokenFile, + client: &http.Client{ + Timeout: 3 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: pool, + }}, + }, + }, nil +} + +type tokenReview struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Spec tokenReviewSpec `json:"spec"` + Status tokenReviewStatus `json:"status,omitempty"` +} + +type tokenReviewSpec struct { + Token string `json:"token"` + Audiences []string `json:"audiences"` +} + +type tokenReviewStatus struct { + Authenticated bool `json:"authenticated"` + Audiences []string `json:"audiences"` + Error string `json:"error"` + User struct { + Username string `json:"username"` + } `json:"user"` +} + +func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string) (Identity, error) { + reviewerToken, err := os.ReadFile(r.reviewerTokenFile) + if err != nil { + return Identity{}, fmt.Errorf("read reviewer credential: %w", err) + } + payload, err := json.Marshal(tokenReview{ + APIVersion: "authentication.k8s.io/v1", + Kind: "TokenReview", + Spec: tokenReviewSpec{ + Token: callerToken, + Audiences: []string{r.audience}, + }, + }) + if err != nil { + return Identity{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpoint, bytes.NewReader(payload)) + if err != nil { + return Identity{}, err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(reviewerToken))) + req.Header.Set("Content-Type", "application/json") + resp, err := r.client.Do(req) + if err != nil { + return Identity{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return Identity{}, fmt.Errorf("TokenReview returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var review tokenReview + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&review); err != nil { + return Identity{}, fmt.Errorf("decode TokenReview: %w", err) + } + if review.Status.Error != "" { + return Identity{}, fmt.Errorf("TokenReview: %s", review.Status.Error) + } + if !review.Status.Authenticated { + return Identity{}, nil + } + return Identity{Username: review.Status.User.Username, Audiences: review.Status.Audiences}, nil +} diff --git a/tenancy.yaml b/tenancy.yaml index e3f5cad..8b337cc 100644 --- a/tenancy.yaml +++ b/tenancy.yaml @@ -1,8 +1,10 @@ # flex-auth tenancy posture declaration -# Framework: net-kingdom/canon/standards/tenancy-posture_v0.1.md (§5) +# Framework: net-kingdom/canon/standards/tenancy-posture_v0.1.md draft-8 (§5) # Conformance rule (§6): accuracy, not altitude. This file overclaims nothing. # Reasoning and evidence: docs/tenancy-posture-review.md +schema_version: "0.1" +framework: netkingdom-tenancy-posture service: flex-auth role: policy-decision-point @@ -10,22 +12,24 @@ tenancy: current: I: 1 A: 0 - E: 2 + E: 1 P: "n/a" R: "n/a" + V: 0 + implemented: + A: 2 + E: 2 target: I: 1 A: 2 E: 2 P: "n/a" R: "n/a" - reviewed: "2026-08-17" + V: 1 + reviewed: "2026-08-18" + review_due: "2027-02-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 + permanent: [I, P, R] gap: I: >- @@ -35,17 +39,17 @@ tenancy: 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. + The running immutable digest still authenticates no caller, so current + remains A0. Source and reviewed desired manifests implement A2 with an + audience-scoped Kubernetes TokenReview choke point and exact + protected-system-to-ServiceAccount bindings. Promotion and a live + unbound-request probe remain under FLEX-WP-0011/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. + not yet produced. Draft-8 distinguishes implemented from evidenced, so + current remains E1 until that review exists. 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 @@ -55,8 +59,31 @@ tenancy: 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. + V: >- + No restart or failover exercise establishes an availability position for + the complete decision path. The target is exercised V1 recovery, not an + inferred claim from a Deployment manifest. + +provider: + capability: authorization.decision + axes: + A: + available: 3 + maximum: 4 + conditions: + - "A3 consumers authenticate flex-auth and observe a denial at their endpoint." + - "A4 requires the AuthZEN interface and records decision differences between PDPs." + evidence: + - "docs/tenancy-posture-review.md" 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" + E1: + - "internal/decision/engine.go:188" + - "internal/decision/engine.go:248" + A2: + - "internal/callerauth/auth.go" + - "internal/callerauth/auth_test.go" + - "cmd/flex-auth/main_test.go" + - "deploy/caller-auth-rbac.yaml" + - "docs/adr/0004-inbound-caller-authentication.md" deployment: "deploy/flex-auth-user-engine.yaml, deploy/flex-auth-tenant-engine.yaml" diff --git a/tools/verify-posture.sh b/tools/verify-posture.sh new file mode 100644 index 0000000..1333d2e --- /dev/null +++ b/tools/verify-posture.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +declaration="$root/tenancy.yaml" +main="$root/cmd/flex-auth/main.go" + +axis_value() { + local block="$1" axis="$2" + awk -v block="$block" -v axis="$axis" ' + $1 == block ":" { in_block=1; next } + in_block && $1 ~ /^(current|implemented|target):$/ { exit } + in_block && $1 == axis ":" { gsub(/[^0-9]/, "", $2); print $2; exit } + ' "$declaration" +} + +current_a="$(axis_value current A)" +implemented_a="$(axis_value implemented A)" +current_i="$(axis_value current I)" +implemented_i="$(axis_value implemented I)" +declared_a="${implemented_a:-$current_a}" +declared_i="${implemented_i:-$current_i}" + +fail() { + echo "posture drift: $*" >&2 + exit 1 +} + +if grep -q 'authenticator.Authorize' "$main"; then + [[ "${declared_a:-0}" -ge 2 ]] || fail "caller authentication exists but current/implemented A is below 2" + for manifest in deploy/flex-auth-user-engine.yaml deploy/flex-auth-tenant-engine.yaml; do + grep -q -- '--caller-auth-mode' "$root/$manifest" || fail "$manifest omits caller auth mode" + grep -q 'enforce' "$root/$manifest" || fail "$manifest does not select enforce mode" + grep -q -- '--caller-binding' "$root/$manifest" || fail "$manifest omits the exact system binding" + done + [[ -f "$root/deploy/caller-auth-rbac.yaml" ]] || fail "TokenReview RBAC manifest is absent" +else + [[ "${declared_a:-0}" -lt 2 ]] || fail "A2 is declared without a caller-authentication choke point" +fi + +if grep -R --include='*.go' --exclude='*_test.go' -q 'tenantengine\.' "$root"; then + [[ "${declared_i:-0}" -ge 3 ]] || fail "tenant-engine is called but I3 is not declared" +else + [[ "${declared_i:-0}" -lt 3 ]] || fail "I3 is declared without a non-test tenant-engine caller" +fi + +if grep -qE '^ R: "?n/a"?' "$declaration"; then + for manifest in deploy/flex-auth-user-engine.yaml deploy/flex-auth-tenant-engine.yaml; do + ! grep -q -- '--log' "$root/$manifest" || fail "$manifest persists a decision log while R is n/a" + ! grep -q 'persistentVolumeClaim:' "$root/$manifest" || fail "$manifest mounts persistent storage while R is n/a" + done +fi + +echo "posture declaration matches source and desired deployment controls" From 3de72fe6f59635b5cd877178d1c9dc69c14224b9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 15:24:16 +0200 Subject: [PATCH 2/2] Record the warn-first promotion sequence and operator gate on T02 Co-Authored-By: Claude Opus 5 --- ...LEX-WP-0015-tenancy-posture-conformance.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/workplans/FLEX-WP-0015-tenancy-posture-conformance.md b/workplans/FLEX-WP-0015-tenancy-posture-conformance.md index ece25d2..57fb81f 100644 --- a/workplans/FLEX-WP-0015-tenancy-posture-conformance.md +++ b/workplans/FLEX-WP-0015-tenancy-posture-conformance.md @@ -113,8 +113,41 @@ the choke point; missing/mismatched identity fails 401/403 and reviewer outage fails 503. Unit and handler tests include the required unbound request. Desired manifests select enforce mode and carry narrow TokenReview RBAC. The running digest is unchanged, so `tenancy.current.A` honestly remains 0 while -`tenancy.implemented.A` is 2. Remaining: build/pin/promote through -FLEX-WP-0011, migrate both callers and capture the live negative probe. +`tenancy.implemented.A` is 2. Source committed as `1e1e077`. + +**Remaining, in this order — the sequence is a constraint, not a preference.** +`ops-warden` ruled on rollout (2026-08-17) and `user-engine` asked for the same: + +1. Build and pin a new immutable digest carrying the caller-auth code. +2. Promote it in **`warn` mode**, not `enforce`. The reviewed desired manifests + in `deploy/` select `enforce` because that is the end state — applying them + directly is the hazard. Warn authenticates and logs failures without + rejecting, so it is safe for an unmigrated caller. +3. Confirm the warn logs are clean of unauthenticated callers. `user-engine` is + migrated and deployed as of 2026-08-18 (image `sha256:c501aeb2…`, token at + `/var/run/secrets/flex-auth-caller/token`). `tenant-engine`'s caller status + is **unconfirmed — asked 2026-08-18**; promoting `enforce` while their + client sends no token 401s every check and blocks their write paths. + `ops-warden` adopts the calling side on its own schedule. +4. Flip to `enforce`, then capture the live negative probe. `user-engine` has + the three assertions written and ready in their + `docs/flex-auth-caller-identity.md`; they deliberately have not run it, + because against an unenforced digest "no token returns 401" comes back as a + normal decision and would record as false evidence. +5. Only then may `policy.enabled` flip anywhere — `ops-warden` names that the + real deadline, and it is the same gate as FLEX-WP-0007. + +**Operator gate.** Steps 1–4 need cluster credentials this session does not +have: `kubectl` returns `Unauthorized` and the context is `default`, which +`tenant-engine` documented on 2026-08-16 as indistinguishable from a +wrong-cluster KUBECONFIG. Promotion follows FLEX-WP-0011 staged promotion and +the CI image build, not a hand-built image. + +`RISK-F-0001` (risk-nexus) tracks this A0 externally. Their NetworkPolicy +question was answered 2026-08-18: both Deployments carry an ingress policy +restricted to one namespace *and* one pod label, predating this work — so the +reachable set was never "any pod in the cluster", and a sentence of mine that +said so was corrected to them. ```task id: FLEX-WP-0015-T03