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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-18 15:22:52 +02:00
parent 6d82ef7f14
commit 1e1e077b27
18 changed files with 768 additions and 357 deletions

View file

@ -4,7 +4,7 @@ PKG := ./...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)
LDFLAGS := -X main.version=$(VERSION) 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 all: vet lint test build
@ -58,6 +58,9 @@ clean:
ci: vet lint test build overlay-render ci: vet lint test build overlay-render
verify-posture:
@bash tools/verify-posture.sh
overlay-render: overlay-render:
@tests/stage1.sh @tests/stage1.sh

View file

@ -90,7 +90,7 @@
| task | FLEX-WP-0014-T03 | done | — | workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md | | 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-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-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-T02 | progress | — | 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-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-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 |

View file

@ -5,6 +5,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -15,6 +16,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/netkingdom/flex-auth/internal/audit" "github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/internal/callerauth"
decisioncore "github.com/netkingdom/flex-auth/internal/decision" decisioncore "github.com/netkingdom/flex-auth/internal/decision"
"github.com/netkingdom/flex-auth/internal/policy" "github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry" "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") registryPath := fs.String("registry", "", "registry snapshot JSON file")
policyPath := fs.String("policy", "", "policy package Markdown file") policyPath := fs.String("policy", "", "policy package Markdown file")
logPath := fs.String("log", "", "optional JSONL decision log path") 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 { if err := fs.Parse(args); err != nil {
return 64 return 64
} }
@ -337,7 +346,20 @@ func runServe(args []string, stdout, stderr io.Writer) int {
return fail(stderr, err) 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) fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr)
if err := http.ListenAndServe(*addr, mux); err != nil { 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 { 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 := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json") 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) http.Error(w, err.Error(), http.StatusBadRequest)
return 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) decision, err := engine.Check(r.Context(), request)
writeHTTP(w, decision, err) writeHTTP(w, decision, err)
}) })
@ -375,12 +405,44 @@ func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return 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) decisions, err := engine.BatchCheck(r.Context(), request)
writeHTTP(w, decisions, err) writeHTTP(w, decisions, err)
}) })
return mux 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) { func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) {
store, err := registry.LoadFile(registryPath) store, err := registry.LoadFile(registryPath)
if err != nil { if err != nil {
@ -503,3 +565,12 @@ func (f keyValueFlags) Map() map[string]any {
} }
return out 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
}

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/pkg/api" "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) { func TestRunLoadRegistryOpsWardenProduction(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr) code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr)

View file

@ -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

View file

@ -14,6 +14,7 @@ spec:
app.kubernetes.io/name: flex-auth-tenant-engine app.kubernetes.io/name: flex-auth-tenant-engine
spec: spec:
automountServiceAccountToken: false automountServiceAccountToken: false
serviceAccountName: flex-auth-tenant-engine
containers: containers:
- args: - args:
- serve - serve
@ -23,6 +24,12 @@ spec:
- /opt/flex-auth/examples/tenant-engine/registry_snapshot.json - /opt/flex-auth/examples/tenant-engine/registry_snapshot.json
- --policy - --policy
- /opt/flex-auth/examples/tenant-engine/policy_package.md - /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 image: forgejo.coulomb.social/coulomb/flex-auth@sha256:1bf060e61122693ce98359c167cc5fe8bdafc84e097e090eaa71af94d0f27cbc
livenessProbe: livenessProbe:
httpGet: httpGet:
@ -51,10 +58,28 @@ spec:
drop: drop:
- ALL - ALL
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /var/run/secrets/flex-auth-reviewer
name: flex-auth-reviewer
readOnly: true
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
seccompProfile: seccompProfile:
type: RuntimeDefault 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 apiVersion: v1
kind: Service kind: Service
@ -75,7 +100,12 @@ metadata:
name: flex-auth-tenant-engine name: flex-auth-tenant-engine
namespace: flex-auth namespace: flex-auth
spec: spec:
egress: [] egress:
- ports:
- port: 443
protocol: TCP
- port: 6443
protocol: TCP
ingress: ingress:
- from: - from:
- namespaceSelector: - namespaceSelector:

View file

@ -14,6 +14,7 @@ spec:
app.kubernetes.io/name: flex-auth-user-engine app.kubernetes.io/name: flex-auth-user-engine
spec: spec:
automountServiceAccountToken: false automountServiceAccountToken: false
serviceAccountName: flex-auth-user-engine
containers: containers:
- args: - args:
- serve - serve
@ -23,6 +24,12 @@ spec:
- /opt/flex-auth/examples/user-engine/registry_snapshot.json - /opt/flex-auth/examples/user-engine/registry_snapshot.json
- --policy - --policy
- /opt/flex-auth/examples/user-engine/policy_package.md - /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 image: forgejo.coulomb.social/coulomb/flex-auth@sha256:1f5290376dc5fcf456dc7a785e394d8b90949dabecd1d3e856f38557149bb5f4
livenessProbe: livenessProbe:
httpGet: httpGet:
@ -51,10 +58,28 @@ spec:
drop: drop:
- ALL - ALL
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /var/run/secrets/flex-auth-reviewer
name: flex-auth-reviewer
readOnly: true
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
seccompProfile: seccompProfile:
type: RuntimeDefault 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 apiVersion: v1
kind: Service kind: Service
@ -75,7 +100,12 @@ metadata:
name: flex-auth-user-engine name: flex-auth-user-engine
namespace: flex-auth namespace: flex-auth
spec: spec:
egress: [] egress:
- ports:
- port: 443
protocol: TCP
- port: 6443
protocol: TCP
ingress: ingress:
- from: - from:
- namespaceSelector: - namespaceSelector:

View file

@ -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.

View file

@ -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 **Reviewer:** flex-auth
**Date:** 2026-08-17 **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: 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 current: I1 A0 E1 P n/a R n/a V0
target: I1 A2 E2 P n/a R n/a implemented: A2 E2
target: I1 A2 E2 P n/a R n/a V1
provider: enables A3 for consumers
``` ```
Three of these need defending. 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 framework should be able to say that a permanent low rung is a decision rather
than a stalled trajectory. than a stalled trajectory.
**A0 is the finding this review actually produced.** `POST /v1/check` and **A0 is the finding this review actually produced.** The running digest's
`POST /v1/batch_check` authenticate no caller (`cmd/flex-auth/main.go:349`). `POST /v1/check` and `POST /v1/batch_check` authenticate no caller.
Any workload with network reach to the ClusterIP Service can assert any 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 subject and any tenant and receive an authoritative allow. flex-auth is the
estate's authorization oracle and it currently trusts its own callers estate's authorization oracle and it currently trusts its own callers
completely. That is A0 — "no authorization" — on its own inbound surface, and 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 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 point (`internal/decision/engine.go:188` normalisation, `:248` relationship
tenant match). But §13.2 rules E2 evidence adversarial and explicitly says a 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 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 adversarial probe. Draft-8 introduced `implemented` for exactly this state, so
claimed clean, which we read as what §6 requires. 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 §4.2 states as fact: "`flex-auth` calls `tenant-engine` synchronously on the
authorization path." It does not, as of this commit. authorization path." It does not, as of this commit.
`internal/adapters/tenantengine/` exists and is complete — an HTTP client for `internal/adapters/tenantengine/` existed as an HTTP client for
`GET /tenants/{id}/roles/live`, a `LiveRolesResult`, and `AttachToContext` to `GET /tenants/{id}/roles/live` and had **no non-test caller**. No current policy
fold live roles into decision context. It has **no non-test caller**. The uses `tenant_roles` for a privileged, destructive, credential-vending or
decision engine has no field for it and no hook that would invoke it `aal2` decision. It was therefore deleted on 2026-08-18 rather than turning
(`internal/decision/engine.go:21`). The IAM Profile's live re-query capability tenant-engine into an unused synchronous availability dependency. A future
is built and unwired. policy that needs live roles must introduce that dependency explicitly.
Two consequences for the framework: Two consequences for the framework:
- The internal-hop anti-pattern it worries about is not live in flex-auth, - The internal-hop anti-pattern it worries about is not live in flex-auth,
because the internal hop is not live at all. because the internal hop is not live at all.
- flex-auth's `I` cannot reach I3 today for the same reason. We had assumed - flex-auth remains I1 by design. The earlier I3 assumption is corrected in
otherwise before this review; that assumption is now corrected in `tenancy.yaml`, and deletion closes `FLEX-WP-0015-T03` without advertising a
`tenancy.yaml` and tracked as `FLEX-WP-0015-T03`, whose honest outcome is latent capability.
either wiring the adapter or deleting it. A built-and-unwired adapter is the
worst of the three states because it reads as capability.
### 3.2 The assertion itself — correct, with a scoping amendment ### 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 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` maximum level it enables for consumers. flex-auth then reads `A0, enables A3`
— which is both accurate and considerably more alarming than `A3`, correctly. — 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 ### 4.2 P and R have no rung for a service with no datastore
@ -261,10 +271,10 @@ volunteer.
| Task | | | Task | |
|---|---| |---|---|
| T01 | Publish the posture vector and this review; reply to `rapp-postgres` | | 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 | | T02 | **Close the A0**: TokenReview source/desired state done; immutable promotion pending |
| T03 | Wire or delete the tenant-engine live-roles adapter | | T03 | Deleted the unused tenant-engine live-roles adapter |
| T04 | AuthZEN endpoint — `wait`, with a written trigger | | 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 Nothing in this review changes a running system, and T02 will not be applied
to production without the usual staged-promotion path. to production without the usual staged-promotion path.

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

125
internal/callerauth/auth.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -1,8 +1,10 @@
# flex-auth tenancy posture declaration # 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. # Conformance rule (§6): accuracy, not altitude. This file overclaims nothing.
# Reasoning and evidence: docs/tenancy-posture-review.md # Reasoning and evidence: docs/tenancy-posture-review.md
schema_version: "0.1"
framework: netkingdom-tenancy-posture
service: flex-auth service: flex-auth
role: policy-decision-point role: policy-decision-point
@ -10,22 +12,24 @@ tenancy:
current: current:
I: 1 I: 1
A: 0 A: 0
E: 2 E: 1
P: "n/a" P: "n/a"
R: "n/a" R: "n/a"
V: 0
implemented:
A: 2
E: 2
target: target:
I: 1 I: 1
A: 2 A: 2
E: 2 E: 2
P: "n/a" P: "n/a"
R: "n/a" R: "n/a"
reviewed: "2026-08-17" V: 1
reviewed: "2026-08-18"
review_due: "2027-02-17"
service_class: latency-critical service_class: latency-critical
permanent: [I, P, R]
# flex-auth is the PDP, not a PEP. The A ladder as written describes
# enforcement points delegating outward; flex-auth is the thing delegated to.
# Two numbers are therefore needed and only one has a slot (see review §3.1).
enables_for_consumers: 3
gap: gap:
I: >- I: >-
@ -35,17 +39,17 @@ tenancy:
decision point judges asserted claims, it cannot be the verifier of its decision point judges asserted claims, it cannot be the verifier of its
own inputs. Not a defect and not a target for movement. own inputs. Not a defect and not a target for movement.
A: >- A: >-
POST /v1/check and /v1/batch_check authenticate no caller. Any workload The running immutable digest still authenticates no caller, so current
with network reach to the ClusterIP Service can assert any subject and remains A0. Source and reviewed desired manifests implement A2 with an
any tenant and receive an authoritative decision. Mitigated only by audience-scoped Kubernetes TokenReview choke point and exact
cluster-internal exposure. Target A2 (single inbound choke point binding protected-system-to-ServiceAccount bindings. Promotion and a live
caller identity) under FLEX-WP-0015-T02. unbound-request probe remain under FLEX-WP-0011/FLEX-WP-0015-T02.
E: >- E: >-
No tenant data at rest. Tenant scoping in decisions runs through one No tenant data at rest. Tenant scoping in decisions runs through one
choke point (internal/decision/engine.go normalizeRequest and the choke point (internal/decision/engine.go normalizeRequest and the
relationship tenant match). E2 evidence is adversarial per §13.2 and is relationship tenant match). E2 evidence is adversarial per §13.2 and is
not yet produced; the claim rests on code review only, which §13 not yet produced. Draft-8 distinguishes implemented from evidenced, so
does not accept. Treat E2 as asserted-pending-evidence. current remains E1 until that review exists.
P: >- P: >-
No rung applies. flex-auth holds no datastore: registry snapshot and No rung applies. flex-auth holds no datastore: registry snapshot and
policy package are baked into the image and mounted read-only, and the 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, No rung applies, same reason. No tenant data is persisted in production,
so there is nothing to retain or erase. R0 ("kept indefinitely by so there is nothing to retain or erase. R0 ("kept indefinitely by
default") would misdescribe a service that keeps nothing. 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: evidence:
A: "None. A0 is a declared absence, not a claim; see review §3.5 on low-rung evidence." E1:
E2: "Pending adversarial artifact. Code choke point: internal/decision/engine.go:188,248" - "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" deployment: "deploy/flex-auth-user-engine.yaml, deploy/flex-auth-tenant-engine.yaml"

54
tools/verify-posture.sh Normal file
View file

@ -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"