From 1e1e077b27ec6d24cee85ca7ac7d721742d1dbb1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 15:22:52 +0200 Subject: [PATCH] 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"