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:
parent
6d82ef7f14
commit
1e1e077b27
18 changed files with 768 additions and 357 deletions
5
Makefile
5
Makefile
|
|
@ -4,7 +4,7 @@ PKG := ./...
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
|
||||
.PHONY: all build test vet lint fmt tidy sbom clean ci overlay-render overlay-dry-run
|
||||
.PHONY: all build test vet lint fmt tidy sbom clean ci overlay-render overlay-dry-run verify-posture
|
||||
|
||||
all: vet lint test build
|
||||
|
||||
|
|
@ -58,6 +58,9 @@ clean:
|
|||
|
||||
ci: vet lint test build overlay-render
|
||||
|
||||
verify-posture:
|
||||
@bash tools/verify-posture.sh
|
||||
|
||||
overlay-render:
|
||||
@tests/stage1.sh
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
51
deploy/caller-auth-rbac.yaml
Normal file
51
deploy/caller-auth-rbac.yaml
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
65
docs/adr/0004-inbound-caller-authentication.md
Normal file
65
docs/adr/0004-inbound-caller-authentication.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
125
internal/callerauth/auth.go
Normal 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
|
||||
}
|
||||
72
internal/callerauth/auth_test.go
Normal file
72
internal/callerauth/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
110
internal/callerauth/tokenreview.go
Normal file
110
internal/callerauth/tokenreview.go
Normal 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
|
||||
}
|
||||
61
tenancy.yaml
61
tenancy.yaml
|
|
@ -1,8 +1,10 @@
|
|||
# flex-auth tenancy posture declaration
|
||||
# Framework: net-kingdom/canon/standards/tenancy-posture_v0.1.md (§5)
|
||||
# Framework: net-kingdom/canon/standards/tenancy-posture_v0.1.md draft-8 (§5)
|
||||
# Conformance rule (§6): accuracy, not altitude. This file overclaims nothing.
|
||||
# Reasoning and evidence: docs/tenancy-posture-review.md
|
||||
|
||||
schema_version: "0.1"
|
||||
framework: netkingdom-tenancy-posture
|
||||
service: flex-auth
|
||||
role: policy-decision-point
|
||||
|
||||
|
|
@ -10,22 +12,24 @@ tenancy:
|
|||
current:
|
||||
I: 1
|
||||
A: 0
|
||||
E: 2
|
||||
E: 1
|
||||
P: "n/a"
|
||||
R: "n/a"
|
||||
V: 0
|
||||
implemented:
|
||||
A: 2
|
||||
E: 2
|
||||
target:
|
||||
I: 1
|
||||
A: 2
|
||||
E: 2
|
||||
P: "n/a"
|
||||
R: "n/a"
|
||||
reviewed: "2026-08-17"
|
||||
V: 1
|
||||
reviewed: "2026-08-18"
|
||||
review_due: "2027-02-17"
|
||||
service_class: latency-critical
|
||||
|
||||
# flex-auth is the PDP, not a PEP. The A ladder as written describes
|
||||
# enforcement points delegating outward; flex-auth is the thing delegated to.
|
||||
# Two numbers are therefore needed and only one has a slot (see review §3.1).
|
||||
enables_for_consumers: 3
|
||||
permanent: [I, P, R]
|
||||
|
||||
gap:
|
||||
I: >-
|
||||
|
|
@ -35,17 +39,17 @@ tenancy:
|
|||
decision point judges asserted claims, it cannot be the verifier of its
|
||||
own inputs. Not a defect and not a target for movement.
|
||||
A: >-
|
||||
POST /v1/check and /v1/batch_check authenticate no caller. Any workload
|
||||
with network reach to the ClusterIP Service can assert any subject and
|
||||
any tenant and receive an authoritative decision. Mitigated only by
|
||||
cluster-internal exposure. Target A2 (single inbound choke point binding
|
||||
caller identity) under FLEX-WP-0015-T02.
|
||||
The running immutable digest still authenticates no caller, so current
|
||||
remains A0. Source and reviewed desired manifests implement A2 with an
|
||||
audience-scoped Kubernetes TokenReview choke point and exact
|
||||
protected-system-to-ServiceAccount bindings. Promotion and a live
|
||||
unbound-request probe remain under FLEX-WP-0011/FLEX-WP-0015-T02.
|
||||
E: >-
|
||||
No tenant data at rest. Tenant scoping in decisions runs through one
|
||||
choke point (internal/decision/engine.go normalizeRequest and the
|
||||
relationship tenant match). E2 evidence is adversarial per §13.2 and is
|
||||
not yet produced; the claim rests on code review only, which §13
|
||||
does not accept. Treat E2 as asserted-pending-evidence.
|
||||
not yet produced. Draft-8 distinguishes implemented from evidenced, so
|
||||
current remains E1 until that review exists.
|
||||
P: >-
|
||||
No rung applies. flex-auth holds no datastore: registry snapshot and
|
||||
policy package are baked into the image and mounted read-only, and the
|
||||
|
|
@ -55,8 +59,31 @@ tenancy:
|
|||
No rung applies, same reason. No tenant data is persisted in production,
|
||||
so there is nothing to retain or erase. R0 ("kept indefinitely by
|
||||
default") would misdescribe a service that keeps nothing.
|
||||
V: >-
|
||||
No restart or failover exercise establishes an availability position for
|
||||
the complete decision path. The target is exercised V1 recovery, not an
|
||||
inferred claim from a Deployment manifest.
|
||||
|
||||
provider:
|
||||
capability: authorization.decision
|
||||
axes:
|
||||
A:
|
||||
available: 3
|
||||
maximum: 4
|
||||
conditions:
|
||||
- "A3 consumers authenticate flex-auth and observe a denial at their endpoint."
|
||||
- "A4 requires the AuthZEN interface and records decision differences between PDPs."
|
||||
evidence:
|
||||
- "docs/tenancy-posture-review.md"
|
||||
|
||||
evidence:
|
||||
A: "None. A0 is a declared absence, not a claim; see review §3.5 on low-rung evidence."
|
||||
E2: "Pending adversarial artifact. Code choke point: internal/decision/engine.go:188,248"
|
||||
E1:
|
||||
- "internal/decision/engine.go:188"
|
||||
- "internal/decision/engine.go:248"
|
||||
A2:
|
||||
- "internal/callerauth/auth.go"
|
||||
- "internal/callerauth/auth_test.go"
|
||||
- "cmd/flex-auth/main_test.go"
|
||||
- "deploy/caller-auth-rbac.yaml"
|
||||
- "docs/adr/0004-inbound-caller-authentication.md"
|
||||
deployment: "deploy/flex-auth-user-engine.yaml, deploy/flex-auth-tenant-engine.yaml"
|
||||
|
|
|
|||
54
tools/verify-posture.sh
Normal file
54
tools/verify-posture.sh
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue