Record authenticated caller in the decision envelope.
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:
parent
e62c0cfc36
commit
ca070df32d
15 changed files with 344 additions and 35 deletions
|
|
@ -388,11 +388,12 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
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)
|
writeCallerAuthError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
decision, err := engine.Check(r.Context(), request)
|
decision, err := engine.Check(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request)
|
||||||
writeHTTP(w, decision, err)
|
writeHTTP(w, decision, err)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
for _, resource := range request.Resources {
|
||||||
systems = append(systems, resource.System)
|
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)
|
writeCallerAuthError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
decisions, err := engine.BatchCheck(r.Context(), request)
|
decisions, err := engine.BatchCheck(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request)
|
||||||
writeHTTP(w, decisions, err)
|
writeHTTP(w, decisions, err)
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
|
|
|
||||||
|
|
@ -235,10 +235,25 @@ func TestServeCallerAuthenticationBindsSystemToPrincipal(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
t.Fatalf("bound caller status = %d; want 200", resp.StatusCode)
|
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{
|
wrong, _ := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||||
Username: "system:serviceaccount:another:caller",
|
Username: "system:serviceaccount:another:caller",
|
||||||
|
|
|
||||||
|
|
@ -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`
|
## 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
|
**Date:** 2026-09-06
|
||||||
**Status:** accepted, gap open
|
**Status:** accepted, implemented 2026-09-14
|
||||||
**Workplan:** `FLEX-WP-0023`
|
**Workplan:** `FLEX-WP-0023`
|
||||||
**Raised by:** flex-auth, while designing the operator access path `glas-harness` asked for
|
**Raised by:** flex-auth, while designing the operator access path `glas-harness` asked for
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,17 @@ same shape.
|
||||||
| `provenance.directory_etag` | Directory consistency token when a delegated directory was joined |
|
| `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.input_claim_digests` | SHA-256 per request-time claim class (`context`, `caring_context`) |
|
||||||
| `provenance.decision_time` | UTC timestamp used to compute `lifetime` |
|
| `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.
|
`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
|
## Allow lifetime
|
||||||
|
|
||||||
Every allow carries `lifetime.kind = ttl`. The duration comes from the policy
|
Every allow carries `lifetime.kind = ttl`. The duration comes from the policy
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,10 @@
|
||||||
"input_claim_digests": {
|
"input_claim_digests": {
|
||||||
"context": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
"context": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||||
},
|
},
|
||||||
"decision_time": "2026-05-17T00:00:00Z"
|
"decision_time": "2026-05-17T00:00:00Z",
|
||||||
|
"caller": {
|
||||||
|
"mode": "disabled"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"caring": {
|
"caring": {
|
||||||
"profile": "caring-0.4.0-rc2",
|
"profile": "caring-0.4.0-rc2",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Mode string
|
type Mode string
|
||||||
|
|
@ -26,6 +27,28 @@ var (
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
Username string
|
Username string
|
||||||
Audiences []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 {
|
type TokenReviewer interface {
|
||||||
|
|
@ -77,45 +100,72 @@ func Disabled() *Authenticator {
|
||||||
// Authorize verifies the bearer token and binds every resource.system value to
|
// Authorize verifies the bearer token and binds every resource.system value to
|
||||||
// the authenticated workload principal. Warn mode records the same failures but
|
// the authenticated workload principal. Warn mode records the same failures but
|
||||||
// permits the request so callers can be migrated before enforcement is enabled.
|
// 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 {
|
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 err != nil && a.mode == ModeWarn {
|
||||||
if a.warnf != nil {
|
if a.warnf != nil {
|
||||||
a.warnf("caller authentication warning: %v", err)
|
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 ")
|
token, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||||
if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), " \t\r\n") {
|
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 err != nil {
|
||||||
if errors.Is(err, ErrUnauthenticated) {
|
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) {
|
if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) {
|
||||||
return ErrUnauthenticated
|
return Identity{}, ErrUnauthenticated
|
||||||
}
|
}
|
||||||
if len(systems) == 0 {
|
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 {
|
for _, system := range systems {
|
||||||
expected, found := a.bindings[system]
|
expected, found := a.bindings[system]
|
||||||
if !found || expected != identity.Username {
|
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 {
|
func contains(values []string, wanted string) bool {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeReviewer struct {
|
type fakeReviewer struct {
|
||||||
|
|
@ -27,10 +28,10 @@ func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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)
|
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",
|
Username: "system:serviceaccount:tenant-engine:tenant-engine",
|
||||||
Audiences: []string{"kubernetes"},
|
Audiences: []string{"kubernetes"},
|
||||||
}}, "flex-auth", map[string]string{"tenant-engine": "system:serviceaccount:tenant-engine:tenant-engine"}, nil)
|
}}, "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)
|
t.Fatalf("audience error = %v; want unauthenticated", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,17 +47,17 @@ func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) {
|
||||||
func TestAuthenticatorRejectsMissingTokenAndReviewerFailure(t *testing.T) {
|
func TestAuthenticatorRejectsMissingTokenAndReviewerFailure(t *testing.T) {
|
||||||
bindings := map[string]string{"tenant-engine": "principal"}
|
bindings := map[string]string{"tenant-engine": "principal"}
|
||||||
authenticator, _ := New(ModeEnforce, fakeReviewer{identity: Identity{Username: "principal", Audiences: []string{"flex-auth"}}}, "flex-auth", bindings, nil)
|
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)
|
t.Fatalf("missing token error = %v; want unauthenticated", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unavailable, _ := New(ModeEnforce, fakeReviewer{err: errors.New("apiserver down")}, "flex-auth", bindings, nil)
|
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)
|
t.Fatalf("reviewer error = %v; want unavailable", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rejected, _ := New(ModeEnforce, fakeReviewer{err: fmt.Errorf("%w: invalid bearer token", ErrUnauthenticated)}, "flex-auth", bindings, nil)
|
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)
|
t.Fatalf("rejected token error = %v; want unauthenticated", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,10 +70,38 @@ func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("warn mode returned error: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(warning, "warning") {
|
if !strings.Contains(warning, "warning") {
|
||||||
t.Fatalf("warning = %q", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func TestOpenRouterNativeCallerBoundary(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if tc.denied && !errors.Is(err, ErrForbidden) {
|
||||||
t.Fatalf("want forbidden, got %v", err)
|
t.Fatalf("want forbidden, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -106,5 +107,33 @@ func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string
|
||||||
if !review.Status.Authenticated {
|
if !review.Status.Authenticated {
|
||||||
return Identity{}, nil
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ package callerauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *testing.T) {
|
func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *testing.T) {
|
||||||
|
|
@ -44,3 +47,62 @@ func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *test
|
||||||
t.Fatalf("Review error = %v; want unauthenticated", err)
|
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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||||
"github.com/netkingdom/flex-auth/internal/policy"
|
"github.com/netkingdom/flex-auth/internal/policy"
|
||||||
"github.com/netkingdom/flex-auth/internal/registry"
|
"github.com/netkingdom/flex-auth/internal/registry"
|
||||||
"github.com/netkingdom/flex-auth/pkg/api"
|
"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
|
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 {
|
if err := e.recordDecision(decision); err != nil {
|
||||||
return api.DecisionEnvelope{}, err
|
return api.DecisionEnvelope{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +346,7 @@ func enrichResourceRef(ref api.ResourceRef, resource api.Resource, overridden *[
|
||||||
return out
|
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{
|
envelope := api.DecisionEnvelope{
|
||||||
RequestID: request.ID,
|
RequestID: request.ID,
|
||||||
Effect: expectation.Effect,
|
Effect: expectation.Effect,
|
||||||
|
|
@ -372,6 +373,7 @@ func (e *Engine) envelope(request, submitted api.CheckRequest, expectation api.D
|
||||||
PolicyVersion: e.policy.Metadata.Version,
|
PolicyVersion: e.policy.Metadata.Version,
|
||||||
PolicyPackageDigest: e.policy.Digest(),
|
PolicyPackageDigest: e.policy.Digest(),
|
||||||
RegistrySnapshotDigest: e.store.Digest(),
|
RegistrySnapshotDigest: e.store.Digest(),
|
||||||
|
Caller: callerProvenance(ctx),
|
||||||
},
|
},
|
||||||
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
|
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
|
||||||
}
|
}
|
||||||
|
|
@ -383,6 +385,22 @@ func (e *Engine) envelope(request, submitted api.CheckRequest, expectation api.D
|
||||||
return envelope
|
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 {
|
func (e *Engine) recordDecision(decision api.DecisionEnvelope) error {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/netkingdom/flex-auth/internal/audit"
|
"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/decision"
|
||||||
"github.com/netkingdom/flex-auth/internal/policy"
|
"github.com/netkingdom/flex-auth/internal/policy"
|
||||||
"github.com/netkingdom/flex-auth/internal/registry"
|
"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 {
|
if len(got.Caring.RestrictionsEvaluated) != 1 || got.Caring.RestrictionsEvaluated[0] != api.RestrictionExportBlocked {
|
||||||
t.Errorf("got.Caring.RestrictionsEvaluated = %v; want [ExportBlocked]", got.Caring.RestrictionsEvaluated)
|
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) {
|
func TestCheckMatchesRegistryRelationshipDescriptor(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -571,6 +571,18 @@ type DecisionProvenance struct {
|
||||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,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"`
|
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
|
// CaringDecisionMetadata carries CARING descriptor and conformance details in
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,39 @@
|
||||||
"decision_time": {
|
"decision_time": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Operator caller access path and caller identity in the decision record"
|
title: "Operator caller access path and caller identity in the decision record"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: flex-auth
|
repo: flex-auth
|
||||||
status: active
|
status: finished
|
||||||
owner: claude
|
owner: claude
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: P1
|
planning_priority: P1
|
||||||
|
|
@ -15,7 +15,7 @@ related_workplans:
|
||||||
- FLEX-WP-0016
|
- FLEX-WP-0016
|
||||||
- SECRETS-WP-0009
|
- SECRETS-WP-0009
|
||||||
created: "2026-09-06"
|
created: "2026-09-06"
|
||||||
updated: "2026-09-06"
|
updated: "2026-09-14"
|
||||||
state_hub_workstream_id: "ad011f92-786c-51ad-b3f6-c06ad77e7af7"
|
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
|
```task
|
||||||
id: FLEX-WP-0023-T04
|
id: FLEX-WP-0023-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c0e4f31a-cc42-5bd2-b938-d140ecd52e1a"
|
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
|
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.
|
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
|
## 5. Report the gap to gate-house as a v0.8 finding
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue