diff --git a/cmd/flex-auth/main.go b/cmd/flex-auth/main.go index 0c148e8..24ba555 100644 --- a/cmd/flex-auth/main.go +++ b/cmd/flex-auth/main.go @@ -388,11 +388,12 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle http.Error(w, err.Error(), http.StatusBadRequest) return } - if err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System}); err != nil { + identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System}) + if err != nil { writeCallerAuthError(w, err) return } - decision, err := engine.Check(r.Context(), request) + decision, err := engine.Check(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request) writeHTTP(w, decision, err) }) mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) { @@ -409,11 +410,12 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle for _, resource := range request.Resources { systems = append(systems, resource.System) } - if err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems); err != nil { + identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems) + if err != nil { writeCallerAuthError(w, err) return } - decisions, err := engine.BatchCheck(r.Context(), request) + decisions, err := engine.BatchCheck(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request) writeHTTP(w, decisions, err) }) return mux diff --git a/cmd/flex-auth/main_test.go b/cmd/flex-auth/main_test.go index f08ecf3..1dbf5e4 100644 --- a/cmd/flex-auth/main_test.go +++ b/cmd/flex-auth/main_test.go @@ -235,10 +235,25 @@ func TestServeCallerAuthenticationBindsSystemToPrincipal(t *testing.T) { if err != nil { t.Fatal(err) } - resp.Body.Close() if resp.StatusCode != http.StatusOK { + resp.Body.Close() t.Fatalf("bound caller status = %d; want 200", resp.StatusCode) } + var envelope api.DecisionEnvelope + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + resp.Body.Close() + t.Fatalf("decode decision: %v", err) + } + resp.Body.Close() + if envelope.Provenance.Caller == nil || envelope.Provenance.Caller.Mode != "enforce" { + t.Fatalf("caller = %+v; want enforce", envelope.Provenance.Caller) + } + if envelope.Provenance.Caller.Principal != "system:serviceaccount:ops-warden:ops-warden" { + t.Fatalf("principal = %q", envelope.Provenance.Caller.Principal) + } + if envelope.Provenance.Caller.Audience != "flex-auth" { + t.Fatalf("audience = %q", envelope.Provenance.Caller.Audience) + } wrong, _ := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ Username: "system:serviceaccount:another:caller", diff --git a/decisions/decisions.md b/decisions/decisions.md index e2c6b91..466faf1 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -1184,7 +1184,7 @@ cross-tenant scope from a missing rule. Carried as `FLEX-WP-0022`. ## FLEX-DEC-2026-009 — The decision record cannot show who called: caller identity is absent from `flex-auth.decision-record.v1` **Date:** 2026-09-06 -**Status:** accepted, gap open +**Status:** accepted, implemented 2026-09-14 **Workplan:** `FLEX-WP-0023` **Raised by:** flex-auth, while designing the operator access path `glas-harness` asked for diff --git a/docs/decision-record-contract.md b/docs/decision-record-contract.md index d7b7933..fb466ab 100644 --- a/docs/decision-record-contract.md +++ b/docs/decision-record-contract.md @@ -39,9 +39,17 @@ same shape. | `provenance.directory_etag` | Directory consistency token when a delegated directory was joined | | `provenance.input_claim_digests` | SHA-256 per request-time claim class (`context`, `caring_context`) | | `provenance.decision_time` | UTC timestamp used to compute `lifetime` | +| `provenance.caller` | How the request was authenticated to the PDP. Additive; **not** decision material. `mode` is required (`disabled` / `warn` / `enforce`). A `principal` recorded under `warn` was observed, not enforced. Under `disabled` the object is `{"mode":"disabled"}` with no principal. `not_after` is the reviewed token `exp`. **Does not affect `request_digest`.** See `FLEX-DEC-2026-009` | `reason`, `diagnostics`, and CARING prose are not an authorization contract. +## Caller provenance is not a digest input + +`provenance.caller` records who obtained the decision. The caller is +deliberately absent from `binding`, so two requests that differ only in the +authenticated principal produce the same `request_digest`. Consumers must not +re-pin replay joins because this field appeared (`FLEX-DEC-2026-009`). + ## Allow lifetime Every allow carries `lifetime.kind = ttl`. The duration comes from the policy diff --git a/examples/caring/decision_envelope.json b/examples/caring/decision_envelope.json index e40ec4f..920eea9 100644 --- a/examples/caring/decision_envelope.json +++ b/examples/caring/decision_envelope.json @@ -63,7 +63,10 @@ "input_claim_digests": { "context": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" }, - "decision_time": "2026-05-17T00:00:00Z" + "decision_time": "2026-05-17T00:00:00Z", + "caller": { + "mode": "disabled" + } }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/internal/callerauth/auth.go b/internal/callerauth/auth.go index ccf7b9c..dbd1b8f 100644 --- a/internal/callerauth/auth.go +++ b/internal/callerauth/auth.go @@ -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 { diff --git a/internal/callerauth/auth_test.go b/internal/callerauth/auth_test.go index d789695..9774cb9 100644 --- a/internal/callerauth/auth_test.go +++ b/internal/callerauth/auth_test.go @@ -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) + } +} diff --git a/internal/callerauth/openrouter_test.go b/internal/callerauth/openrouter_test.go index 1cdcea7..ae53f99 100644 --- a/internal/callerauth/openrouter_test.go +++ b/internal/callerauth/openrouter_test.go @@ -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) } diff --git a/internal/callerauth/tokenreview.go b/internal/callerauth/tokenreview.go index be6730e..4fad271 100644 --- a/internal/callerauth/tokenreview.go +++ b/internal/callerauth/tokenreview.go @@ -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 } diff --git a/internal/callerauth/tokenreview_test.go b/internal/callerauth/tokenreview_test.go index 6abf4a0..9c42a98 100644 --- a/internal/callerauth/tokenreview_test.go +++ b/internal/callerauth/tokenreview_test.go @@ -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" +} diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 2a54062..c8b6a73 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -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() diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go index fd1c8cf..ac6afa9 100644 --- a/internal/decision/engine_test.go +++ b/internal/decision/engine_test.go @@ -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) { diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index e3a0848..5a2b6bb 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -571,6 +571,18 @@ type DecisionProvenance struct { DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` + Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` +} + +// CallerProvenance records how the request was authenticated to flex-auth. +// It is not decision material: the same request from a different authenticated +// caller must decide identically. Mode is required. Under disabled the object +// is {"mode":"disabled"} with no principal (FLEX-DEC-2026-009). +type CallerProvenance struct { + Mode string `json:"mode" yaml:"mode"` + Principal string `json:"principal,omitempty" yaml:"principal,omitempty"` + Audience string `json:"audience,omitempty" yaml:"audience,omitempty"` + NotAfter string `json:"not_after,omitempty" yaml:"not_after,omitempty"` } // CaringDecisionMetadata carries CARING descriptor and conformance details in diff --git a/schemas/decision_envelope.schema.json b/schemas/decision_envelope.schema.json index f3dbc0a..3beed73 100644 --- a/schemas/decision_envelope.schema.json +++ b/schemas/decision_envelope.schema.json @@ -228,6 +228,39 @@ "decision_time": { "type": "string", "minLength": 1 + }, + "caller": { + "$ref": "#/$defs/caller_provenance", + "description": "Authenticated caller of the PDP, not decision material. Additive: request_digest is unaffected because the caller is not in binding (FLEX-DEC-2026-009)." + } + } + }, + "caller_provenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "enum": [ + "disabled", + "warn", + "enforce" + ] + }, + "principal": { + "type": "string", + "minLength": 1 + }, + "audience": { + "type": "string", + "minLength": 1 + }, + "not_after": { + "type": "string", + "minLength": 1, + "description": "Reviewed token exp, RFC3339 UTC. A principal recorded under warn was observed, not enforced." } } }, diff --git a/workplans/FLEX-WP-0023-operator-caller-access-path.md b/workplans/FLEX-WP-0023-operator-caller-access-path.md index 5a80ebc..9ccb5e4 100644 --- a/workplans/FLEX-WP-0023-operator-caller-access-path.md +++ b/workplans/FLEX-WP-0023-operator-caller-access-path.md @@ -4,7 +4,7 @@ type: workplan title: "Operator caller access path and caller identity in the decision record" domain: infotech repo: flex-auth -status: active +status: finished owner: claude topic_slug: netkingdom planning_priority: P1 @@ -15,7 +15,7 @@ related_workplans: - FLEX-WP-0016 - SECRETS-WP-0009 created: "2026-09-06" -updated: "2026-09-06" +updated: "2026-09-14" state_hub_workstream_id: "ad011f92-786c-51ad-b3f6-c06ad77e7af7" --- @@ -134,7 +134,7 @@ caller adoption is proved — is satisfied by `T02`, not bypassed by this task. ```task id: FLEX-WP-0023-T04 -status: todo +status: done priority: high state_hub_task_id: "c0e4f31a-cc42-5bd2-b938-d140ecd52e1a" ``` @@ -165,6 +165,16 @@ authenticated. Gate: a decision obtained under `enforce` names its caller and lifetime; the same request's `request_digest` is byte-identical to the pre-change value. +**Done 2026-09-14.** `provenance.caller` is additive on `flex-auth.decision-record.v1` +(`mode` required; `principal`/`audience`/`not_after` when a token was reviewed). +`internal/callerauth.Identity` now keeps the reviewed JWT `exp`. HTTP `/v1/check` +and `/v1/batch_check` stamp the record through request context so the decision +log matches the response. Disabled evaluation emits `{"mode":"disabled"}`. +`request_digest` is unchanged: the caller is not in `binding`. Tests: +`TestCallerProvenanceDoesNotChangeRequestDigest`, +`TestKubernetesTokenReviewerCapturesTokenExpiry`, +`TestServeCallerAuthenticationBindsSystemToPrincipal`. + ## 5. Report the gap to gate-house as a v0.8 finding ```task