Implement inbound caller authentication (ADR 0004); close T03 and T05

TokenReview-based caller identity with audience-scoped tokens and exact
resource.system to ServiceAccount bindings, per ops-warden's recommendation.
Deletes the unwired tenant-engine live-roles adapter (T03) and adds
make verify-posture (T05). Source implements A2; running digest is still A0
until promotion, so tenancy.current.A stays 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-18 15:22:52 +02:00
parent 6d82ef7f14
commit 1e1e077b27
18 changed files with 768 additions and 357 deletions

View file

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