Compare commits

...

2 commits

Author SHA1 Message Date
3de72fe6f5 Record the warn-first promotion sequence and operator gate on T02
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:24:16 +02:00
1e1e077b27 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>
2026-08-18 15:22:52 +02:00
19 changed files with 803 additions and 359 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"

View file

@ -113,8 +113,41 @@ the choke point; missing/mismatched identity fails 401/403 and reviewer outage
fails 503. Unit and handler tests include the required unbound request. Desired
manifests select enforce mode and carry narrow TokenReview RBAC. The running
digest is unchanged, so `tenancy.current.A` honestly remains 0 while
`tenancy.implemented.A` is 2. Remaining: build/pin/promote through
FLEX-WP-0011, migrate both callers and capture the live negative probe.
`tenancy.implemented.A` is 2. Source committed as `1e1e077`.
**Remaining, in this order — the sequence is a constraint, not a preference.**
`ops-warden` ruled on rollout (2026-08-17) and `user-engine` asked for the same:
1. Build and pin a new immutable digest carrying the caller-auth code.
2. Promote it in **`warn` mode**, not `enforce`. The reviewed desired manifests
in `deploy/` select `enforce` because that is the end state — applying them
directly is the hazard. Warn authenticates and logs failures without
rejecting, so it is safe for an unmigrated caller.
3. Confirm the warn logs are clean of unauthenticated callers. `user-engine` is
migrated and deployed as of 2026-08-18 (image `sha256:c501aeb2…`, token at
`/var/run/secrets/flex-auth-caller/token`). `tenant-engine`'s caller status
is **unconfirmed — asked 2026-08-18**; promoting `enforce` while their
client sends no token 401s every check and blocks their write paths.
`ops-warden` adopts the calling side on its own schedule.
4. Flip to `enforce`, then capture the live negative probe. `user-engine` has
the three assertions written and ready in their
`docs/flex-auth-caller-identity.md`; they deliberately have not run it,
because against an unenforced digest "no token returns 401" comes back as a
normal decision and would record as false evidence.
5. Only then may `policy.enabled` flip anywhere — `ops-warden` names that the
real deadline, and it is the same gate as FLEX-WP-0007.
**Operator gate.** Steps 14 need cluster credentials this session does not
have: `kubectl` returns `Unauthorized` and the context is `default`, which
`tenant-engine` documented on 2026-08-16 as indistinguishable from a
wrong-cluster KUBECONFIG. Promotion follows FLEX-WP-0011 staged promotion and
the CI image build, not a hand-built image.
`RISK-F-0001` (risk-nexus) tracks this A0 externally. Their NetworkPolicy
question was answered 2026-08-18: both Deployments carry an ingress policy
restricted to one namespace *and* one pod label, predating this work — so the
reachable set was never "any pod in the cluster", and a sentence of mine that
said so was corrected to them.
```task
id: FLEX-WP-0015-T03