Record authenticated caller in the decision envelope.
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 1m0s

FLEX-WP-0023-T04: provenance.caller is additive (mode required;
principal/audience/not_after when a token was reviewed). TokenReview
keeps the JWT exp. request_digest is unchanged because the caller is
not binding material.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
tegwick 2026-09-14 04:44:07 +02:00
parent e62c0cfc36
commit ca070df32d
15 changed files with 344 additions and 35 deletions

View file

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"strings"
"time"
)
type Mode string
@ -26,6 +27,28 @@ var (
type Identity struct {
Username string
Audiences []string
NotAfter time.Time
}
// Record is the caller provenance stamped onto a decision envelope.
// Mode is always set. Principal, audience and expiry are present only when a
// token was actually reviewed.
type Record struct {
Mode Mode
Principal string
Audience string
NotAfter time.Time
}
type recordContextKey struct{}
func WithRecord(ctx context.Context, record Record) context.Context {
return context.WithValue(ctx, recordContextKey{}, record)
}
func RecordFromContext(ctx context.Context) (Record, bool) {
record, ok := ctx.Value(recordContextKey{}).(Record)
return record, ok
}
type TokenReviewer interface {
@ -77,45 +100,72 @@ func Disabled() *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 {
// The returned identity is populated whenever a token was reviewed, including
// warn-mode binding failures, so provenance can name an observed principal.
func (a *Authenticator) Authorize(ctx context.Context, authorization string, systems []string) (Identity, error) {
if a == nil || a.mode == ModeDisabled {
return nil
return Identity{}, nil
}
err := a.authorize(ctx, authorization, systems)
identity, 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 identity, nil
}
return err
return identity, err
}
func (a *Authenticator) authorize(ctx context.Context, authorization string, systems []string) error {
// Record returns the provenance object for a reviewed identity. Disabled mode
// is stated as {"mode":"disabled"} with no principal.
func (a *Authenticator) Record(identity Identity) Record {
mode := ModeDisabled
audience := ""
if a != nil {
mode = a.mode
audience = a.audience
}
record := Record{Mode: mode}
if mode == ModeDisabled || strings.TrimSpace(identity.Username) == "" {
return record
}
record.Principal = identity.Username
record.Audience = audience
record.NotAfter = identity.NotAfter
return record
}
func (a *Authenticator) authorize(ctx context.Context, authorization string, systems []string) (Identity, error) {
token, ok := strings.CutPrefix(authorization, "Bearer ")
if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), " \t\r\n") {
return ErrUnauthenticated
return Identity{}, ErrUnauthenticated
}
identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token))
token = strings.TrimSpace(token)
identity, err := a.reviewer.Review(ctx, token)
if err != nil {
if errors.Is(err, ErrUnauthenticated) {
return err
return Identity{}, err
}
return Identity{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if identity.NotAfter.IsZero() {
if exp, ok := tokenExpiry(token); ok {
identity.NotAfter = exp
}
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) {
return ErrUnauthenticated
return Identity{}, ErrUnauthenticated
}
if len(systems) == 0 {
return fmt.Errorf("%w: request has no resources", ErrForbidden)
return identity, 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 identity, fmt.Errorf("%w: principal %q cannot represent system %q", ErrForbidden, identity.Username, system)
}
}
return nil
return identity, nil
}
func contains(values []string, wanted string) bool {

View file

@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"testing"
"time"
)
type fakeReviewer struct {
@ -27,10 +28,10 @@ func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := authenticator.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"}); err != nil {
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) {
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)
}
@ -38,7 +39,7 @@ func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) {
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) {
if _, err := wrongAudience.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("audience error = %v; want unauthenticated", err)
}
}
@ -46,17 +47,17 @@ func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) {
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) {
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) {
if _, err := unavailable.Authorize(context.Background(), "Bearer token", []string{"tenant-engine"}); !errors.Is(err, ErrUnavailable) {
t.Fatalf("reviewer error = %v; want unavailable", err)
}
rejected, _ := New(ModeEnforce, fakeReviewer{err: fmt.Errorf("%w: invalid bearer token", ErrUnauthenticated)}, "flex-auth", bindings, nil)
if err := rejected.Authorize(context.Background(), "Bearer malformed", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
if _, err := rejected.Authorize(context.Background(), "Bearer malformed", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("rejected token error = %v; want unauthenticated", err)
}
}
@ -69,10 +70,38 @@ func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := authenticator.Authorize(context.Background(), "", []string{"tenant-engine"}); err != nil {
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)
}
}
func TestAuthenticatorWarnModeKeepsObservedPrincipal(t *testing.T) {
authenticator, err := New(ModeWarn, fakeReviewer{identity: Identity{
Username: "system:serviceaccount:other:caller",
Audiences: []string{"flex-auth"},
NotAfter: time.Unix(1788730498, 0).UTC(),
}}, "flex-auth", map[string]string{
"tenant-engine": "system:serviceaccount:tenant-engine:tenant-engine",
}, func(string, ...any) {})
if err != nil {
t.Fatal(err)
}
identity, err := authenticator.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"})
if err != nil {
t.Fatalf("warn mode returned error: %v", err)
}
record := authenticator.Record(identity)
if record.Mode != ModeWarn || record.Principal != "system:serviceaccount:other:caller" {
t.Fatalf("record = %+v; want warn with observed principal", record)
}
}
func TestDisabledRecordStatesAbsence(t *testing.T) {
record := Disabled().Record(Identity{Username: "ignored"})
if record.Mode != ModeDisabled || record.Principal != "" {
t.Fatalf("disabled record = %+v; want mode only", record)
}
}

View file

@ -31,7 +31,7 @@ func TestOpenRouterNativeCallerBoundary(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = auth.Authorize(context.Background(), "Bearer synthetic-caller", tc.systems)
_, err = auth.Authorize(context.Background(), "Bearer synthetic-caller", tc.systems)
if tc.denied && !errors.Is(err, ErrForbidden) {
t.Fatalf("want forbidden, got %v", err)
}

View file

@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@ -106,5 +107,33 @@ func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string
if !review.Status.Authenticated {
return Identity{}, nil
}
return Identity{Username: review.Status.User.Username, Audiences: review.Status.Audiences}, nil
identity := Identity{Username: review.Status.User.Username, Audiences: review.Status.Audiences}
if exp, ok := tokenExpiry(callerToken); ok {
identity.NotAfter = exp
}
return identity, nil
}
// tokenExpiry reads exp from a JWT payload. TokenReview already validated the
// token, including expiry; the claim is captured here because the review
// response does not return it.
func tokenExpiry(token string) (time.Time, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
payload, err = base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, false
}
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp <= 0 {
return time.Time{}, false
}
return time.Unix(claims.Exp, 0).UTC(), true
}

View file

@ -2,12 +2,15 @@ package callerauth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *testing.T) {
@ -44,3 +47,62 @@ func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *test
t.Fatalf("Review error = %v; want unauthenticated", err)
}
}
func TestKubernetesTokenReviewerCapturesTokenExpiry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"apiVersion": "authentication.k8s.io/v1",
"kind": "TokenReview",
"status": {
"authenticated": true,
"audiences": ["flex-auth"],
"user": {"username": "system:serviceaccount:secrets-engine:secrets-engine"}
}
}`))
}))
defer server.Close()
tokenFile := filepath.Join(t.TempDir(), "reviewer-token")
if err := os.WriteFile(tokenFile, []byte("reviewer-token\n"), 0o600); err != nil {
t.Fatal(err)
}
reviewer := &KubernetesTokenReviewer{
endpoint: server.URL + "/apis/authentication.k8s.io/v1/tokenreviews",
audience: "flex-auth",
reviewerTokenFile: tokenFile,
client: server.Client(),
}
exp := time.Unix(1788730498, 0).UTC()
token := unsignedJWT(map[string]any{"exp": exp.Unix(), "sub": "system:serviceaccount:secrets-engine:secrets-engine"})
identity, err := reviewer.Review(context.Background(), token)
if err != nil {
t.Fatal(err)
}
if identity.Username != "system:serviceaccount:secrets-engine:secrets-engine" {
t.Fatalf("username = %q", identity.Username)
}
if !identity.NotAfter.Equal(exp) {
t.Fatalf("NotAfter = %s; want %s", identity.NotAfter, exp)
}
}
func TestTokenExpiryReadsJWTClaim(t *testing.T) {
exp := time.Unix(1788730498, 0).UTC()
got, ok := tokenExpiry(unsignedJWT(map[string]any{"exp": exp.Unix()}))
if !ok || !got.Equal(exp) {
t.Fatalf("tokenExpiry = %s, %v; want %s", got, ok, exp)
}
if _, ok := tokenExpiry("not-a-jwt"); ok {
t.Fatal("opaque token should not yield expiry")
}
}
func unsignedJWT(claims map[string]any) string {
header, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"})
payload, _ := json.Marshal(claims)
return base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(payload) + ".sig"
}

View file

@ -14,6 +14,7 @@ import (
"sync"
"time"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
@ -112,7 +113,7 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis
return api.DecisionEnvelope{}, err
}
decision := e.envelope(normalized, request, expectation, facts)
decision := e.envelope(ctx, normalized, request, expectation, facts)
if err := e.recordDecision(decision); err != nil {
return api.DecisionEnvelope{}, err
}
@ -345,7 +346,7 @@ func enrichResourceRef(ref api.ResourceRef, resource api.Resource, overridden *[
return out
}
func (e *Engine) envelope(request, submitted api.CheckRequest, expectation api.DecisionExpectation, facts registryFacts) api.DecisionEnvelope {
func (e *Engine) envelope(ctx context.Context, request, submitted api.CheckRequest, expectation api.DecisionExpectation, facts registryFacts) api.DecisionEnvelope {
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: expectation.Effect,
@ -372,6 +373,7 @@ func (e *Engine) envelope(request, submitted api.CheckRequest, expectation api.D
PolicyVersion: e.policy.Metadata.Version,
PolicyPackageDigest: e.policy.Digest(),
RegistrySnapshotDigest: e.store.Digest(),
Caller: callerProvenance(ctx),
},
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
}
@ -383,6 +385,22 @@ func (e *Engine) envelope(request, submitted api.CheckRequest, expectation api.D
return envelope
}
func callerProvenance(ctx context.Context) *api.CallerProvenance {
record, ok := callerauth.RecordFromContext(ctx)
if !ok {
record = callerauth.Record{Mode: callerauth.ModeDisabled}
}
out := &api.CallerProvenance{Mode: string(record.Mode)}
if record.Principal != "" {
out.Principal = record.Principal
out.Audience = record.Audience
if !record.NotAfter.IsZero() {
out.NotAfter = record.NotAfter.UTC().Format(time.RFC3339)
}
}
return out
}
func (e *Engine) recordDecision(decision api.DecisionEnvelope) error {
e.mu.Lock()
defer e.mu.Unlock()

View file

@ -11,6 +11,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/internal/decision"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
@ -68,6 +69,43 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) {
if len(got.Caring.RestrictionsEvaluated) != 1 || got.Caring.RestrictionsEvaluated[0] != api.RestrictionExportBlocked {
t.Errorf("got.Caring.RestrictionsEvaluated = %v; want [ExportBlocked]", got.Caring.RestrictionsEvaluated)
}
if got.Provenance.Caller == nil || got.Provenance.Caller.Mode != "disabled" || got.Provenance.Caller.Principal != "" {
t.Errorf("got.Provenance.Caller = %+v; want disabled with no principal", got.Provenance.Caller)
}
}
func TestCallerProvenanceDoesNotChangeRequestDigest(t *testing.T) {
engine := newTestEngine(t)
var request api.CheckRequest
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request)
without, err := engine.Check(context.Background(), request)
if err != nil {
t.Fatalf("Check: %v", err)
}
ctx := callerauth.WithRecord(context.Background(), callerauth.Record{
Mode: callerauth.ModeEnforce,
Principal: "system:serviceaccount:secrets-engine:secrets-engine",
Audience: "flex-auth",
NotAfter: time.Unix(1788730498, 0).UTC(),
})
with, err := engine.Check(ctx, request)
if err != nil {
t.Fatalf("Check with caller: %v", err)
}
if with.Binding.RequestDigest != without.Binding.RequestDigest {
t.Fatalf("request_digest moved from %s to %s", without.Binding.RequestDigest, with.Binding.RequestDigest)
}
if with.Provenance.Caller == nil || with.Provenance.Caller.Mode != "enforce" {
t.Fatalf("caller = %+v; want enforce", with.Provenance.Caller)
}
if with.Provenance.Caller.Principal != "system:serviceaccount:secrets-engine:secrets-engine" {
t.Fatalf("principal = %q", with.Provenance.Caller.Principal)
}
if with.Provenance.Caller.Audience != "flex-auth" || with.Provenance.Caller.NotAfter != "2026-09-06T21:34:58Z" {
t.Fatalf("audience/not_after = %+v", with.Provenance.Caller)
}
}
func TestCheckMatchesRegistryRelationshipDescriptor(t *testing.T) {