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

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

View file

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

View file

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

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

View file

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

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

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

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"