From 95d0bc6c9aa48db8da798a9dbfda453782c7087a Mon Sep 17 00:00:00 2001 From: AJ Beck Date: Sun, 27 Sep 2026 15:29:40 +0100 Subject: [PATCH] refactor!: rename identity.WithCaller to identity.NewContext Pair the context installer with FromContext, following the Go blog's NewContext/FromContext convention. Every other With function in the module is a functional option. The signature, conflict detection and nil-context handling are unchanged. BREAKING CHANGE: identity.WithCaller is now identity.NewContext. --- README.md | 2 +- adapter_invoke.go | 2 +- adapter_invoke_test.go | 2 +- authn/authn_test.go | 2 +- authn/handler.go | 2 +- authz/rule_test.go | 2 +- docs/authn.md | 2 +- docs/consumer-testing.md | 2 +- docs/decisions/0012-shared-identity.md | 3 +++ docs/iamproof.md | 2 +- docs/plan.md | 2 +- examples/dispatcher/internal/orders/app.go | 2 +- identity/caller_example_test.go | 6 +++--- identity/caller_test.go | 10 +++++----- identity/context.go | 4 ++-- invocation_test.go | 2 +- streaming_adapter_test.go | 2 +- 17 files changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4124e9c..7487a2d 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ JWT issuer, subject, client ID, audience, scopes and Cognito groups stay distinc Ambiguous gateway collections remain unavailable, while conflicting interpretable scope sets fail. IAM callers retain exact ARN paths and session names. -`identity.WithCaller` installs one caller in a derived context and rejects replacement, even by an apparently identical caller. +`identity.NewContext` installs one caller in a derived context and rejects replacement, even by an apparently identical caller. `identity.FromContext` returns anonymous when absent. See the [caller examples](identity/caller_example_test.go). diff --git a/adapter_invoke.go b/adapter_invoke.go index 4732abe..80d5493 100644 --- a/adapter_invoke.go +++ b/adapter_invoke.go @@ -133,7 +133,7 @@ func prepareRequest(ctx context.Context, inv *invocation, event decodedEvent, ra if err != nil { return nil, err } - ctx, err = identity.WithCaller(ctx, caller) + ctx, err = identity.NewContext(ctx, caller) if err != nil { return nil, invocationError("identity", ErrIdentity, "caller installation failed", identity.ErrConflict) } diff --git a/adapter_invoke_test.go b/adapter_invoke_test.go index 516f234..6da7368 100644 --- a/adapter_invoke_test.go +++ b/adapter_invoke_test.go @@ -137,7 +137,7 @@ func TestAdapterFailuresHaveNoResult(t *testing.T) { if err != nil { t.Fatal(err) } - parent, err := identity.WithCaller(t.Context(), caller) + parent, err := identity.NewContext(t.Context(), caller) if err != nil { t.Fatal(err) } diff --git a/authn/authn_test.go b/authn/authn_test.go index 4f5b2e1..586cf6b 100644 --- a/authn/authn_test.go +++ b/authn/authn_test.go @@ -229,7 +229,7 @@ func TestContextIsolationAndConflict(t *testing.T) { jwt := jwtCaller(t, identity.SourceLocallyVerifiedToken) var calls int a := authenticator(t, authn.Config{Bearer: func(context.Context, string) (identity.Caller, error) { calls++; return jwt, nil }}) - ctx, err := identity.WithCaller(t.Context(), jwt) + ctx, err := identity.NewContext(t.Context(), jwt) if err != nil { t.Fatal(err) } diff --git a/authn/handler.go b/authn/handler.go index 1ef17ea..061ccd5 100644 --- a/authn/handler.go +++ b/authn/handler.go @@ -37,7 +37,7 @@ func (a *Authenticator) Handler(next http.Handler) (http.Handler, error) { writeFailure(w, err) return } - ctx, err := identity.WithCaller(r.Context(), caller) + ctx, err := identity.NewContext(r.Context(), caller) if err != nil { writeFailure(w, a.failure(identity.ErrConflict, http.StatusInternalServerError, noScheme)) return diff --git a/authz/rule_test.go b/authz/rule_test.go index ecb784f..7d1792b 100644 --- a/authz/rule_test.go +++ b/authz/rule_test.go @@ -251,7 +251,7 @@ func TestCallbackPanicPropagates(t *testing.T) { func TestExplicitCallerAndCallerContext(t *testing.T) { facts := request(t) other := iamCaller(t, "arn:aws:iam::123456789012:user/Bob") - ctx, err := identity.WithCaller(t.Context(), other) + ctx, err := identity.NewContext(t.Context(), other) if err != nil { t.Fatal(err) } diff --git a/docs/authn.md b/docs/authn.md index 4c121f0..ae22fb2 100644 --- a/docs/authn.md +++ b/docs/authn.md @@ -125,7 +125,7 @@ Edge still rejects a usable response for a canceled invocation. For custom JSON responses, use Authenticate and the Error accessors in ordinary HTTP middleware. Serialize application envelopes directly with encoding/json/v2. -On success, install the returned caller with identity.WithCaller before dispatch. +On success, install the returned caller with identity.NewContext before dispatch. See the [runnable examples](../authn/example_test.go) for error mapping, a custom JSON failure response and a shared action dispatcher. Their synthetic Bearer fixture is explicitly not a production token verifier. diff --git a/docs/consumer-testing.md b/docs/consumer-testing.md index eb552e3..d4c782c 100644 --- a/docs/consumer-testing.md +++ b/docs/consumer-testing.md @@ -7,7 +7,7 @@ Gateway fixtures describe shapes, not deployed authorizers. | Boundary | Tools | Evidence | | --- | --- | --- | | Business rule | Identity constructors and `authz.Request` | Exact action/resource policy outcomes | -| Authenticated handler | `httptest`, `identity.WithCaller` | Behavior after admission | +| Authenticated handler | `httptest`, `identity.NewContext` | Behavior after admission | | Authentication | Real verifier with local JWKS/STS transport | Local signature/profile/proof processing | | Gateway conversion | Explicit SDK events or raw JSON v2 | Chosen payload format's behavior | | Stream lifecycle | Public reader, controlled producer, incremental reads | Delivery, backpressure, cancellation and errors | diff --git a/docs/decisions/0012-shared-identity.md b/docs/decisions/0012-shared-identity.md index deeb00b..e1df587 100644 --- a/docs/decisions/0012-shared-identity.md +++ b/docs/decisions/0012-shared-identity.md @@ -103,6 +103,9 @@ func FromContext(ctx context.Context) Caller func WithCaller(ctx context.Context, caller Caller) (context.Context, error) ``` +Note (2026-09-27): [decision 0026](0026-exported-api-review.md) E3 renamed `WithCaller` to `NewContext`, pairing it with `FromContext`; +its signature and semantics are unchanged. + Kind distinguishes anonymous, JWT, and IAM. Source distinguishes no identity, gateway assertion, locally verified token, and explicitly mapped custom assertion. JWT/IAM views have private fields and read-only accessors. diff --git a/docs/iamproof.md b/docs/iamproof.md index fd0f2a3..16bc38a 100644 --- a/docs/iamproof.md +++ b/docs/iamproof.md @@ -50,7 +50,7 @@ Successful callers have SourceVerifiedIAMProof, preserve the full ARN/session an Root, user, assumed-role session and federated-user identities are represented; representation grants no application permission. -The authentication layer must select exactly one credential, call exactly one verifier, install the returned caller using identity.WithCaller, then authorize the selected application action. +The authentication layer must select exactly one credential, call exactly one verifier, install the returned caller using identity.NewContext, then authorize the selected application action. The [authn guide](authn.md) shows this middleware and the explicit adapter mapping iamproof.ErrInvalidProof to authn.ErrInvalidCredentials. Never fall back to another verifier after rejection. diff --git a/docs/plan.md b/docs/plan.md index c1e84ed..10b05f7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -378,7 +378,7 @@ these are placement, naming, typing and documentation changes made before the fi - [x] E1: move the action-header selector to the standard-library-only `actionheader` package as `Selector`/`NewSelector`. - [x] E2: rename the moved sentinels to `ErrMissing`, `ErrAmbiguous` and `ErrInvalid`. -- [ ] E3: rename `identity.WithCaller` to `identity.NewContext`. +- [x] E3: rename `identity.WithCaller` to `identity.NewContext`. - [ ] E4: type the `InvocationError` operation and limit names with exported constants. - [ ] E5: add `String` methods to the `identity` enumerations. - [ ] E6: complete sentinel docs, doc links, callback parameter names and `Claim.Bool` result names. diff --git a/examples/dispatcher/internal/orders/app.go b/examples/dispatcher/internal/orders/app.go index 3d3d507..43a83f5 100644 --- a/examples/dispatcher/internal/orders/app.go +++ b/examples/dispatcher/internal/orders/app.go @@ -162,7 +162,7 @@ func New(cfg Config) (http.Handler, error) { writeAuthenticationError(w, err) return } - ctx, err := identity.WithCaller(r.Context(), caller) + ctx, err := identity.NewContext(r.Context(), caller) if err != nil { writeError(w, 500, "internal_error") return diff --git a/identity/caller_example_test.go b/identity/caller_example_test.go index a82dc5c..4f33975 100644 --- a/identity/caller_example_test.go +++ b/identity/caller_example_test.go @@ -27,20 +27,20 @@ func ExampleNewJWT() { // Output: batch-worker false [orders.read] true } -func ExampleWithCaller() { +func ExampleNewContext() { // This validates a synthetic identifier; // it does not authenticate it. caller, err := identity.NewIAM("arn:aws:sts::123456789012:assumed-role/Worker/batch-1", identity.SourceCustomAssertion) if err != nil { panic(err) } - ctx, err := identity.WithCaller(context.Background(), caller) + ctx, err := identity.NewContext(context.Background(), caller) if err != nil { panic(err) } iam, _ := identity.FromContext(ctx).IAM() fmt.Println(iam.PrincipalARN()) - _, err = identity.WithCaller(ctx, caller) + _, err = identity.NewContext(ctx, caller) fmt.Println(errors.Is(err, identity.ErrConflict)) // Output: // arn:aws:sts::123456789012:assumed-role/Worker/batch-1 diff --git a/identity/caller_test.go b/identity/caller_test.go index 8f36e5d..2a36f9f 100644 --- a/identity/caller_test.go +++ b/identity/caller_test.go @@ -252,20 +252,20 @@ func TestCallerContextAndDiagnostics(t *testing.T) { if got := identity.FromContext(parent); got.Kind() != identity.KindAnonymous || got.Source() != identity.SourceNone { t.Errorf("empty context = %v", got) } - unchanged, err := identity.WithCaller(parent, identity.Caller{}) + unchanged, err := identity.NewContext(parent, identity.Caller{}) if err != nil || unchanged != parent { t.Error("anonymous installation did not preserve empty context") } - ctx, err := identity.WithCaller(parent, caller) + ctx, err := identity.NewContext(parent, caller) if err != nil || ctx.Value(unrelatedKey{}) != "kept" || identity.FromContext(ctx).Kind() != identity.KindIAM { - t.Fatalf("WithCaller lost context/caller: %v", err) + t.Fatalf("NewContext lost context/caller: %v", err) } for _, next := range []identity.Caller{caller, {}} { - if ctx, err := identity.WithCaller(ctx, next); ctx != nil || !errors.Is(err, identity.ErrConflict) { + if ctx, err := identity.NewContext(ctx, next); ctx != nil || !errors.Is(err, identity.ErrConflict) { t.Errorf("reinstallation = %v, %v", ctx, err) } } - if ctx, err := identity.WithCaller(nil, caller); ctx != nil || !errors.Is(err, identity.ErrInvalidCaller) { + if ctx, err := identity.NewContext(nil, caller); ctx != nil || !errors.Is(err, identity.ErrInvalidCaller) { t.Errorf("nil context = %v, %v", ctx, err) } iam, _ := caller.IAM() diff --git a/identity/context.go b/identity/context.go index 8bf9597..1d1329d 100644 --- a/identity/context.go +++ b/identity/context.go @@ -11,12 +11,12 @@ func FromContext(ctx context.Context) Caller { return caller } -// WithCaller installs caller in a derived context. +// NewContext returns a derived context carrying caller, for retrieval with FromContext. // Anonymous on an empty context is a no-op. // Any attempted installation over a nonanonymous caller, including an identical caller or anonymous, returns ErrConflict and a nil context. // A nil context returns ErrInvalidCaller. // Unrelated values are preserved. -func WithCaller(ctx context.Context, caller Caller) (context.Context, error) { +func NewContext(ctx context.Context, caller Caller) (context.Context, error) { if ctx == nil { return nil, ErrInvalidCaller } diff --git a/invocation_test.go b/invocation_test.go index 11ad0fd..cd7ecd7 100644 --- a/invocation_test.go +++ b/invocation_test.go @@ -17,7 +17,7 @@ func TestInvocationRejectsInheritedCaller(t *testing.T) { if err != nil { t.Fatal(err) } - parent, err := identity.WithCaller(t.Context(), caller) + parent, err := identity.NewContext(t.Context(), caller) if err != nil { t.Fatal(err) } diff --git a/streaming_adapter_test.go b/streaming_adapter_test.go index 13e3de1..e206244 100644 --- a/streaming_adapter_test.go +++ b/streaming_adapter_test.go @@ -90,7 +90,7 @@ func TestStreamingConstructorsAndValidation(t *testing.T) { if err != nil { t.Fatal(err) } - ctx, err := identity.WithCaller(t.Context(), caller) + ctx, err := identity.NewContext(t.Context(), caller) if err != nil { t.Fatal(err) }