From a9b556d2945d44042fe79f5840f613f4296e6a8f Mon Sep 17 00:00:00 2001 From: AJ Beck Date: Sun, 27 Sep 2026 13:55:11 +0100 Subject: [PATCH] style: use semantic line breaks and remove soft wraps Reflow Markdown and Go comment prose to one sentence per line, with a break after semicolons joining independent clauses, matching AGENTS.md. Remove column-based wrapping from code: packed multi-line literals now have one element per line, short wrapped literals and calls are joined, and the RFC 7515 test vectors are single string constants. --- README.md | 214 +++--- action.go | 40 +- action_example_test.go | 4 +- action_gateway_test.go | 4 +- adapter.go | 63 +- adapter_example_test.go | 16 +- adapter_invoke.go | 31 +- adapter_invoke_test.go | 7 +- authn/authenticate.go | 43 +- authn/authn_test.go | 5 +- authn/authorization_test.go | 7 +- authn/cognito.go | 69 +- authn/cognito_cache.go | 17 +- authn/cognito_cache_test.go | 10 +- authn/cognito_example_test.go | 11 +- authn/cognito_jwks.go | 4 +- authn/cognito_jwks_test.go | 20 +- authn/cognito_test.go | 72 +- authn/cognito_token.go | 7 +- authn/cognito_vector_test.go | 23 +- authn/doc.go | 28 +- authn/errors.go | 6 +- authn/example_test.go | 25 +- authn/gateway_test.go | 24 +- authn/handler.go | 19 +- authz/doc.go | 21 +- authz/example_test.go | 38 +- authz/predicates.go | 63 +- authz/rule.go | 64 +- authz/rule_test.go | 3 +- buffered.go | 24 +- decode.go | 18 +- decode_test.go | 3 +- docs/authn.md | 176 +++-- docs/authz.md | 218 +++--- docs/benchmarks.md | 264 ++++--- docs/cognito.md | 197 ++--- docs/consumer-testing.md | 66 +- docs/decisions/0001-gateway-identity.md | 95 +-- docs/decisions/0002-adapter-boundary.md | 147 ++-- docs/decisions/0003-event-decoding.md | 224 +++--- docs/decisions/0004-typed-events.md | 104 ++- .../0005-http-request-translation.md | 254 +++---- docs/decisions/0006-buffered-responses.md | 363 +++++---- docs/decisions/0007-response-metadata.md | 113 ++- docs/decisions/0008-streaming-architecture.md | 192 +++-- docs/decisions/0009-streaming-boundaries.md | 302 ++++---- docs/decisions/0010-response-header-design.md | 401 +++++----- .../0011-request-header-processing.md | 321 ++++---- docs/decisions/0012-shared-identity.md | 385 +++++----- .../0013-public-invocation-errors.md | 225 +++--- docs/decisions/0014-claims-api.md | 324 ++++---- docs/decisions/0015-iam-caller-forms.md | 146 ++-- .../0016-mixed-iam-oauth-authentication.md | 147 ++-- docs/decisions/0017-iam-proof-protocol.md | 423 +++++------ .../0018-gateway-identity-recognition.md | 198 +++-- .../0019-http-authentication-selector.md | 366 +++++---- docs/decisions/0020-cognito-verification.md | 496 ++++++------- docs/decisions/0021-authorization-rules.md | 423 +++++------ .../decisions/0022-streaming-writer-review.md | 359 +++++---- docs/decisions/0023-consumer-readiness.md | 349 ++++----- docs/decisions/0024-release-automation.md | 258 +++---- docs/decisions/0025-main-branch-protection.md | 63 +- docs/decisions/README.md | 7 +- docs/gateway-fixtures.md | 61 +- docs/iamproof.md | 151 ++-- docs/plan.md | 700 ++++++++---------- docs/probes/iamproof/README.md | 42 +- docs/probes/iamproof/proof_test.go | 28 +- docs/release-readiness.md | 68 +- docs/releases.md | 127 ++-- docs/reviews/2026-09-14-plan-review.md | 182 ++--- .../2026-09-17-sdk-streaming-follow-up.md | 198 +++-- docs/streaming.md | 225 +++--- docs/support.md | 47 +- errors.go | 40 +- errors_test.go | 8 +- examples/dispatcher/README.md | 125 ++-- examples/dispatcher/buffered/main.go | 4 +- examples/dispatcher/internal/orders/app.go | 45 +- .../dispatcher/internal/orders/app_test.go | 7 +- .../dispatcher/internal/orders/environment.go | 21 +- .../internal/orders/environment_test.go | 8 +- .../internal/orders/fixtures_test.go | 37 +- .../dispatcher/internal/orders/stream_test.go | 4 +- examples/dispatcher/streaming/main.go | 7 +- gateway_identity.go | 13 +- gateway_identity_test.go | 18 +- iamproof/doc.go | 27 +- iamproof/example_test.go | 15 +- iamproof/generate.go | 31 +- iamproof/iamproof_test.go | 57 +- iamproof/protocol.go | 14 +- iamproof/verify.go | 41 +- iamproof/xml.go | 6 +- identity/caller.go | 17 +- identity/caller_example_test.go | 11 +- identity/caller_test.go | 49 +- identity/claims.go | 52 +- identity/claims_bench_test.go | 13 +- identity/claims_json.go | 11 +- identity/claims_test.go | 70 +- identity/claims_typed.go | 24 +- identity/context.go | 9 +- identity/errors.go | 7 +- identity/iam.go | 31 +- identity/jwt.go | 25 +- internal/claimprobe/probe_test.go | 33 +- internal/headerprobe/probe_test.go | 23 +- internal/streamprobe/http_test.go | 4 +- internal/streamprobe/runtime_test.go | 20 +- invocation.go | 21 +- request.go | 11 +- response_encoding.go | 20 +- response_encoding_test.go | 3 +- response_headers.go | 19 +- response_headers_test.go | 79 +- stream.go | 36 +- stream_encoding.go | 7 +- stream_encoding_test.go | 6 +- stream_test.go | 4 +- streaming_adapter.go | 67 +- streaming_bench_test.go | 6 +- streaming_example_test.go | 18 +- streaming_writer.go | 9 +- streaming_writer_test.go | 4 +- testdata/events/README.md | 11 +- 127 files changed, 5609 insertions(+), 6077 deletions(-) diff --git a/README.md b/README.md index 408381e..94653c7 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,127 @@ # go-lambda-edge -A Go 1.27 module being built to serve AWS API Gateway requests through ordinary -`net/http` handlers in AWS Lambda, with companion identity, authentication, and -authorization packages. - -The root package is `edge`. The buffered adapter is implemented for REST proxy, -HTTP API payload 1.0 and HTTP API payload 2.0. It uses AWS Lambda Go v1.55.0 types -and direct encoding/json/v2 processing. Raw and already typed entry points share -HTTP conversion, response capture, identity policy, cancellation and cleanup. - -Start with the [complete orders dispatcher](examples/dispatcher/README.md) for -verifier wiring, application grants, JSON errors and three transport entry points. -The [support matrix](docs/support.md) distinguishes implemented behavior, deferred -scope and deployment qualifications; the [testing guide](docs/consumer-testing.md) -shows how to test each consumer boundary. - -Create an adapter with `edge.New(handler, options...)`, handle the constructor -error, then register it with `lambda.Start(adapter)`. Pass the object, not -`adapter.Invoke`, to retain the raw JSON v2 boundary. For a fixed payload family, -use `lambda.Start(adapter.HandleV1)` or `lambda.Start(adapter.HandleV2)`; these -delegate envelope JSON to the SDK and do no envelope serialization inside edge. +A Go 1.27 module being built to serve AWS API Gateway requests through ordinary `net/http` handlers in AWS Lambda, with companion identity, authentication, and authorization packages. + +The root package is `edge`. +The buffered adapter is implemented for REST proxy, HTTP API payload 1.0 and HTTP API payload 2.0. +It uses AWS Lambda Go v1.55.0 types and direct encoding/json/v2 processing. +Raw and already typed entry points share HTTP conversion, response capture, identity policy, cancellation and cleanup. + +Start with the [complete orders dispatcher](examples/dispatcher/README.md) for verifier wiring, application grants, JSON errors and three transport entry points. +The [support matrix](docs/support.md) distinguishes implemented behavior, deferred scope and deployment qualifications; +the [testing guide](docs/consumer-testing.md) shows how to test each consumer boundary. + +Create an adapter with `edge.New(handler, options...)`, handle the constructor error, then register it with `lambda.Start(adapter)`. +Pass the object, not `adapter.Invoke`, to retain the raw JSON v2 boundary. +For a fixed payload family, use `lambda.Start(adapter.HandleV1)` or `lambda.Start(adapter.HandleV2)`; +these delegate envelope JSON to the SDK and do no envelope serialization inside edge. See the [runnable registration examples](adapter_example_test.go). -Gateway identity is disabled by default. `edge.WithGatewayIdentity(true)` enables -the reviewed native IAM and JWT/Cognito field mappings. Invalid, conflicting or -unsupported assertions fail before the handler; custom-authorizer mapping remains -unimplemented. Authorization headers are not independently authenticated. See the -[fixture coverage and fidelity qualifications](docs/gateway-fixtures.md). - -The shared invocation error contract is implemented across those private stages: -public categories support `errors.Is`, and `errors.AsType[*edge.InvocationError]` -exposes sanitized operation and resource-limit diagnostics. HTTP error responses -remain successful transport outcomes; operation, cleanup, cancellation, and late -stream failures retain their accepted ownership and precedence. The shared -claims API, caller/context construction, JWT normalization and resource limits -are implemented in `identity`. Every invocation rejects an inherited caller, -including when gateway identity is disabled. Constructors themselves do not -authenticate credentials or authorize application actions. - -The accepted authentication direction supports IAM credentials or an OAuth -bearer token on the same route without built-in `AWS_IAM` authorization. IAM -clients use `iamproof.NewGenerator` to generate a signed STS GetCallerIdentity -proof; `iamproof.NewVerifier` submits that client-signed request to AWS and returns -a verified IAM caller. Both are implemented using the latest stable SDK core -v1.47.0 and STS v1.51.0, checked on 2026-09-16. See the -[IAM proof guide](docs/iamproof.md) and [runnable examples](iamproof/example_test.go). +Gateway identity is disabled by default. +`edge.WithGatewayIdentity(true)` enables the reviewed native IAM and JWT/Cognito field mappings. +Invalid, conflicting or unsupported assertions fail before the handler; +custom-authorizer mapping remains unimplemented. +Authorization headers are not independently authenticated. +See the [fixture coverage and fidelity qualifications](docs/gateway-fixtures.md). + +The shared invocation error contract is implemented across those private stages: public categories support `errors.Is`, and `errors.AsType[*edge.InvocationError]` exposes sanitized operation and resource-limit diagnostics. +HTTP error responses remain successful transport outcomes; +operation, cleanup, cancellation, and late stream failures retain their accepted ownership and precedence. +The shared claims API, caller/context construction, JWT normalization and resource limits are implemented in `identity`. +Every invocation rejects an inherited caller, including when gateway identity is disabled. +Constructors themselves do not authenticate credentials or authorize application actions. + +The accepted authentication direction supports IAM credentials or an OAuth bearer token on the same route without built-in `AWS_IAM` authorization. +IAM clients use `iamproof.NewGenerator` to generate a signed STS GetCallerIdentity proof; +`iamproof.NewVerifier` submits that client-signed request to AWS and returns a verified IAM caller. +Both are implemented using the latest stable SDK core v1.47.0 and STS v1.51.0, checked on 2026-09-16. +See the [IAM proof guide](docs/iamproof.md) and [runnable examples](iamproof/example_test.go). The shared `authn` selector and required-authentication middleware are implemented. -Configure Bearer and/or IAM-proof verifier functions; the -[authentication guide](docs/authn.md) shows the required IAM error mapping and -the custom dispatcher/HTTP response boundaries. `authn.NewCognitoVerifier` now -provides RS256 access-token verification with direct JSON v2, explicit issuer/ -client/resource restrictions and bounded JWKS caching; see the -[Cognito guide](docs/cognito.md). Wire its Verify method as the Bearer verifier. -The `authz` package now provides exact IAM/JWT predicates, ordered All/Any rules -and context-aware application grant checks. Consumers own their action registry, -resource selection and HTTP responses; see the [authorization guide](docs/authz.md) -and [runnable dispatcher examples](authz/example_test.go). +Configure Bearer and/or IAM-proof verifier functions; +the [authentication guide](docs/authn.md) shows the required IAM error mapping and the custom dispatcher/HTTP response boundaries. +`authn.NewCognitoVerifier` now provides RS256 access-token verification with direct JSON v2, explicit issuer/client/resource restrictions and bounded JWKS caching; +see the [Cognito guide](docs/cognito.md). +Wire its Verify method as the Bearer verifier. +The `authz` package now provides exact IAM/JWT predicates, ordered All/Any rules and context-aware application grant checks. +Consumers own their action registry, resource selection and HTTP responses; +see the [authorization guide](docs/authz.md) and [runnable dispatcher examples](authz/example_test.go). No live Cognito/STS interoperability test has run. -REST response streaming is implemented through `edge.NewStreaming(handler)` and -`lambda.Start(adapter.Handle)` or `lambda.Start(adapter.HandleV1)`. It provides -bounded sniffing, Flush/FlushError, raw body delivery, backpressure, cancellation, -cleanup and sanitized terminal errors. Streaming never infers Content-Length. -See the [streaming guide](docs/streaming.md) and -[SSE, NDJSON, binary and gzip examples](streaming_example_test.go). -Local SDK tests pass, but deployment support remains unverified: SDK v1.55.0's -Runtime API mode header and connection handling differ from current AWS guidance. -The [deployment qualification](docs/decisions/0022-streaming-writer-review.md#s3-sdk-issue-retain-the-existing-deployment-release-gate) -remains open; no live API Gateway streaming test has run. +REST response streaming is implemented through `edge.NewStreaming(handler)` and `lambda.Start(adapter.Handle)` or `lambda.Start(adapter.HandleV1)`. +It provides bounded sniffing, Flush/FlushError, raw body delivery, backpressure, cancellation, cleanup and sanitized terminal errors. +Streaming never infers Content-Length. +See the [streaming guide](docs/streaming.md) and [SSE, NDJSON, binary and gzip examples](streaming_example_test.go). +Local SDK tests pass, but deployment support remains unverified: SDK v1.55.0's Runtime API mode header and connection handling differ from current AWS guidance. +The [deployment qualification](docs/decisions/0022-streaming-writer-review.md#s3-sdk-issue-retain-the-existing-deployment-release-gate) remains open; +no live API Gateway streaming test has run. ## Header-selected actions -`edge.NewActionHeader("Action")` creates an immutable selector whose -`Parse(r.Header)` method returns an action string or an error. Configure the -header name explicitly. Selection preserves case, trims outer spaces/tabs, and -requires a nonempty HTTP token. Repeated values and comma-bearing values are -rejected, including Gateway payload 2.0's comma-combined duplicates. Use -`errors.Is` with `edge.ErrActionMissing`, `edge.ErrActionAmbiguous`, or -`edge.ErrActionInvalid` to distinguish request-input failures. +`edge.NewActionHeader("Action")` creates an immutable selector whose `Parse(r.Header)` method returns an action string or an error. +Configure the header name explicitly. +Selection preserves case, trims outer spaces/tabs, and requires a nonempty HTTP token. +Repeated values and comma-bearing values are rejected, including Gateway payload 2.0's comma-combined duplicates. +Use `errors.Is` with `edge.ErrActionMissing`, `edge.ErrActionAmbiguous`, or `edge.ErrActionInvalid` to distinguish request-input failures. Use ordinary `http.Handler` middleware to process this and other custom headers. -The consumer owns its action registry, metadata, authorization and HTTP error -responses. Select once, authorize that selection, then execute it; do not select -again from mutable headers. An action header does not authenticate a caller. +The consumer owns its action registry, metadata, authorization and HTTP error responses. +Select once, authorize that selection, then execute it; +do not select again from mutable headers. +An action header does not authenticate a caller. The selector works with both ordinary Go HTTP servers and the Lambda adapter. -See the [runnable middleware example](action_example_test.go) and -[accepted header contract](docs/decisions/0011-request-header-processing.md). +See the [runnable middleware example](action_example_test.go) and [accepted header contract](docs/decisions/0011-request-header-processing.md). ## Response header budget -Response headers have a 256 KiB weighted resource budget, configurable with -`edge.WithResponseHeaderBudget(bytes)` from 1 byte through 6 MiB. Each original -value costs `len(name) + len(value) + 32`; nil/empty slices still cost a name plus -32, and generated headers consume remaining budget. This is separate from the -complete buffered-envelope limit and the 16,000-byte streaming metadata prefix -(including its delimiter). It is neither an AWS quota nor a total heap bound. -Both writers apply the allowance at commitment. Raw buffered invocation also -checks the complete serialized response against 6 MiB; typed callers own their -final envelope serialization and size. Streaming checks the complete metadata -prefix before handing off the reader; its body does not use the buffered limit. +Response headers have a 256 KiB weighted resource budget, configurable with `edge.WithResponseHeaderBudget(bytes)` from 1 byte through 6 MiB. +Each original value costs `len(name) + len(value) + 32`; +nil/empty slices still cost a name plus 32, and generated headers consume remaining budget. +This is separate from the complete buffered-envelope limit and the 16,000-byte streaming metadata prefix (including its delimiter). +It is neither an AWS quota nor a total heap bound. +Both writers apply the allowance at commitment. +Raw buffered invocation also checks the complete serialized response against 6 MiB; +typed callers own their final envelope serialization and size. +Streaming checks the complete metadata prefix before handing off the reader; +its body does not use the buffered limit. ## Owned claims -`identity.NewJWT` and `identity.NewIAM` construct immutable, mutually exclusive -caller views. Zero Caller is anonymous. Constructors validate facts and source -attribution; they do not authenticate credentials or grant permissions. +`identity.NewJWT` and `identity.NewIAM` construct immutable, mutually exclusive caller views. +Zero Caller is anonymous. +Constructors validate facts and source attribution; +they do not authenticate credentials or grant permissions. JWT issuer, subject, client ID, audience, scopes and Cognito groups stay distinct. -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.FromContext` -returns anonymous when absent. See the [caller examples](identity/caller_example_test.go). - -`identity.ParseClaims`, `identity.NewClaims`, and `identity.NewTextClaims` accept -raw JSON, decoded Go values, and gateway string maps respectively. Claims are -immutable snapshots with checked accessors. Exact numeric text remains available -where the input preserves it; a decoded float64 is never advertised as an exact -original JSON number, and gateway text is never reparsed as an embedded array. - -Processing uses `encoding/json/jsontext` directly. The only production -`encoding/json` import recognizes its `Number` data type, supporting typed SDK -handlers configured with `lambda.WithUseNumber(true)`; no v1 codec is called. +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.FromContext` returns anonymous when absent. +See the [caller examples](identity/caller_example_test.go). + +`identity.ParseClaims`, `identity.NewClaims`, and `identity.NewTextClaims` accept raw JSON, decoded Go values, and gateway string maps respectively. +Claims are immutable snapshots with checked accessors. +Exact numeric text remains available where the input preserves it; +a decoded float64 is never advertised as an exact original JSON number, and gateway text is never reparsed as an embedded array. + +Processing uses `encoding/json/jsontext` directly. +The only production `encoding/json` import recognizes its `Number` data type, supporting typed SDK handlers configured with `lambda.WithUseNumber(true)`; +no v1 codec is called. See the [runnable examples](identity/claims_example_test.go). -The default claim allowance is 256 KiB, configurable with -`identity.WithClaimsBudget(bytes)` up to 6 MiB. Each value costs 64 bytes plus -object-name, string and exact-number text bytes. Raw JSON also has a wire-length -limit of the same size. Nesting is limited to 64 containers. These are resource -policies, not heap caps; [measurements](docs/benchmarks.md) include allocation and -retained-memory costs. `edge.WithIdentityClaimsBudget(bytes)` applies the matching -allowance to gateway claims and dedicated scopes. +The default claim allowance is 256 KiB, configurable with `identity.WithClaimsBudget(bytes)` up to 6 MiB. +Each value costs 64 bytes plus object-name, string and exact-number text bytes. +Raw JSON also has a wire-length limit of the same size. +Nesting is limited to 64 containers. +These are resource policies, not heap caps; +[measurements](docs/benchmarks.md) include allocation and retained-memory costs. +`edge.WithIdentityClaimsBudget(bytes)` applies the matching allowance to gateway claims and dedicated scopes. Handlers receive an owned request body and canceled context after invocation. -Multipart files parsed on the served request are removed on success, error or -panic. Middleware that parses multipart on a separate request copy owns cleanup -of files attached only to that copy. Buffered and pre-handoff streaming panics -propagate after cleanup; post-handoff streaming panics become terminal errors. +Multipart files parsed on the served request are removed on success, error or panic. +Middleware that parses multipart on a separate request copy owns cleanup of files attached only to that copy. +Buffered and pre-handoff streaming panics propagate after cleanup; +post-handoff streaming panics become terminal errors. - [Implementation plan](docs/plan.md) - [Design decisions](docs/decisions/README.md) diff --git a/action.go b/action.go index d501bdc..a579f90 100644 --- a/action.go +++ b/action.go @@ -11,27 +11,28 @@ var ( // An absent header and a nil or empty value slice are all missing. ErrActionMissing = errors.New("edge: missing action") - // ErrActionAmbiguous means the header has multiple represented values or a - // value containing a comma. Gateway payload 2.0 combines duplicates with - // commas; a literal comma cannot be distinguished from combined values. + // ErrActionAmbiguous means the header has multiple represented values or a value containing a comma. + // Gateway payload 2.0 combines duplicates with commas; + // a literal comma cannot be distinguished from combined values. ErrActionAmbiguous = errors.New("edge: ambiguous action") - // ErrActionInvalid means the single action value is empty after trimming - // outer spaces and tabs, or does not satisfy the HTTP token grammar. + // ErrActionInvalid means the single action value is empty after trimming outer spaces and tabs, or does not satisfy the HTTP token grammar. ErrActionInvalid = errors.New("edge: invalid action") ) // ActionHeader selects an action from a configured request header. -// Construct it with [NewActionHeader]; its zero value is unconfigured. +// Construct it with [NewActionHeader]; +// its zero value is unconfigured. // An ActionHeader is immutable, safe to copy, and safe for concurrent use. type ActionHeader struct { name string } // NewActionHeader validates name as an HTTP field name and returns a selector. -// The name is required and matched case-insensitively; no default is inferred. -// On error, the returned selector is the zero value. Configuration errors are -// separate from [ErrActionMissing], [ErrActionAmbiguous] and [ErrActionInvalid]. +// The name is required and matched case-insensitively; +// no default is inferred. +// On error, the returned selector is the zero value. +// Configuration errors are separate from [ErrActionMissing], [ErrActionAmbiguous] and [ErrActionInvalid]. func NewActionHeader(name string) (ActionHeader, error) { if !validToken(name) { return ActionHeader{}, errors.New("edge: invalid action header name") @@ -39,18 +40,19 @@ func NewActionHeader(name string) (ActionHeader, error) { return ActionHeader{name: http.CanonicalHeaderKey(name)}, nil } -// Parse returns the single action in headers, trimming only outer SP/HTAB and -// preserving case. An action must be a nonempty HTTP token (RFC 9110 §5.6.2). +// Parse returns the single action in headers, trimming only outer SP/HTAB and preserving case. +// An action must be a nonempty HTTP token (RFC 9110 §5.6.2). // Parse inspects all case-insensitive aliases, including noncanonical map keys. -// Multiple values are ambiguous even if identical; a comma is also ambiguous. +// Multiple values are ambiguous even if identical; +// a comma is also ambiguous. // Multiplicity and comma checks take precedence over token validation. // Duplicates already lost by a gateway or earlier proxy cannot be detected. // -// On failure Parse returns an empty string and [ErrActionMissing], -// [ErrActionAmbiguous] or [ErrActionInvalid], matchable with [errors.Is]. An -// unconfigured selector instead returns a configuration error. Errors do not -// include supplied header values. Parse does not modify headers or write an -// HTTP response. Callers must not mutate headers concurrently with Parse. +// On failure Parse returns an empty string and [ErrActionMissing], [ErrActionAmbiguous] or [ErrActionInvalid], matchable with [errors.Is]. +// An unconfigured selector instead returns a configuration error. +// Errors do not include supplied header values. +// Parse does not modify headers or write an HTTP response. +// Callers must not mutate headers concurrently with Parse. // Select once and use that same action for authorization and execution. func (h ActionHeader) Parse(headers http.Header) (string, error) { if h.name == "" { @@ -59,8 +61,8 @@ func (h ActionHeader) Parse(headers http.Header) (string, error) { var value string found := false for name, values := range headers { - // CanonicalHeaderKey folds only valid ASCII field names. In particular, - // Unicode case equivalents must not alias the configured HTTP name. + // CanonicalHeaderKey folds only valid ASCII field names. + // In particular, Unicode case equivalents must not alias the configured HTTP name. if http.CanonicalHeaderKey(name) != h.name || len(values) == 0 { continue } diff --git a/action_example_test.go b/action_example_test.go index 4b92679..3e56ba4 100644 --- a/action_example_test.go +++ b/action_example_test.go @@ -38,8 +38,8 @@ func ExampleActionHeader() { http.Error(w, "missing action metadata", http.StatusInternalServerError) return } - // This example operation is public. Protected operations must authorize - // this same selection before executing or publishing streaming output. + // This example operation is public. + // Protected operations must authorize this same selection before executing or publishing streaming output. switch action { case "service.ping": fmt.Fprintln(w, "pong") diff --git a/action_gateway_test.go b/action_gateway_test.go index a7ac5a0..a89a6c0 100644 --- a/action_gateway_test.go +++ b/action_gateway_test.go @@ -94,8 +94,8 @@ func actionGatewayRequest(t *testing.T, format string, values []string) *http.Re v1.Headers = map[string]string{"Custom-Trace": "keep"} v2.Headers = map[string]string{"custom-trace": "keep"} if len(values) != 0 { - // A value mirrored by V1's single and multivalue maps is not an extra - // field line. Actual repeats in the multivalue list must survive. + // A value mirrored by V1's single and multivalue maps is not an extra field line. + // Actual repeats in the multivalue list must survive. v1.Headers["action"] = values[0] v1.MultiValueHeaders = map[string][]string{"ACTION": values} v2.Headers["action"] = strings.Join(values, ",") diff --git a/adapter.go b/adapter.go index 86716c2..289a5a4 100644 --- a/adapter.go +++ b/adapter.go @@ -1,7 +1,7 @@ // Package edge adapts AWS API Gateway Lambda proxy events to net/http handlers. -// It supports raw JSON v2 and already typed AWS events with optional native -// gateway identity. Buffered and REST streaming responses share request and -// identity policies; each has its own response and invocation boundary. +// It supports raw JSON v2 and already typed AWS events with optional native gateway identity. +// Buffered and REST streaming responses share request and identity policies; +// each has its own response and invocation boundary. package edge import ( @@ -13,8 +13,10 @@ import ( ) // Adapter holds an HTTP handler and its API Gateway adaptation configuration. -// Construct an Adapter with [New]; its zero value is not usable. -// Configuration is fixed at construction. Calls own separate request state; +// Construct an Adapter with [New]; +// its zero value is not usable. +// Configuration is fixed at construction. +// Calls own separate request state; // a shared handler must be safe for concurrent use. type Adapter struct { handler http.Handler @@ -30,14 +32,17 @@ type config struct { } // Option configures an [Adapter] or [StreamingAdapter] during construction. -// Use the option functions provided by this package. A nil Option is invalid. -// Options apply in argument order; the last assignment to a scalar setting wins. +// Use the option functions provided by this package. +// A nil Option is invalid. +// Options apply in argument order; +// the last assignment to a scalar setting wins. type Option func(*config) // WithGatewayIdentity configures whether gateway assertions establish identity. -// The default is false. When enabled, the policy covers native IAM and -// JWT/Cognito assertions; it does not configure authorization in AWS or enable -// local token verification. Custom-authorizer contexts need an explicit mapper. +// The default is false. +// When enabled, the policy covers native IAM and JWT/Cognito assertions; +// it does not configure authorization in AWS or enable local token verification. +// Custom-authorizer contexts need an explicit mapper. // Unsupported or conflicting assertions fail before running the HTTP handler. func WithGatewayIdentity(enabled bool) Option { return func(c *config) { @@ -46,11 +51,13 @@ func WithGatewayIdentity(enabled bool) Option { } // WithIdentityClaimsBudget sets the gateway identity claims allowance in bytes. -// The default is 256 KiB; New and NewStreaming reject settings outside 1..6 MiB. +// The default is 256 KiB; +// New and NewStreaming reject settings outside 1..6 MiB. // Each claim value costs 64 bytes plus name, string and exact-number text bytes. -// Dedicated gateway scopes consume the same allowance. Raw claim JSON is also -// limited to this byte length. This is a resource policy, not a heap cap or AWS -// quota. It does not enable gateway identity. +// Dedicated gateway scopes consume the same allowance. +// Raw claim JSON is also limited to this byte length. +// This is a resource policy, not a heap cap or AWS quota. +// It does not enable gateway identity. func WithIdentityClaimsBudget(bytes int) Option { return func(c *config) { c.identityClaimsBudget = bytes @@ -58,24 +65,26 @@ func WithIdentityClaimsBudget(bytes int) Option { } // WithResponseHeaderBudget sets the response header resource budget in bytes. -// The default is 256 KiB. Both constructors reject settings outside 1..6 MiB; -// zero does not disable the bound. Each original value costs its name length, -// value length and 32 bytes; nil/empty slices cost their name length and 32. -// Generated headers also consume the budget. This is a library resource policy, -// not an AWS quota or a bound on total heap usage. It does not change the separate -// buffered envelope or streaming metadata limits. +// The default is 256 KiB. +// Both constructors reject settings outside 1..6 MiB; +// zero does not disable the bound. +// Each original value costs its name length, value length and 32 bytes; +// nil/empty slices cost their name length and 32. +// Generated headers also consume the budget. +// This is a library resource policy, not an AWS quota or a bound on total heap usage. +// It does not change the separate buffered envelope or streaming metadata limits. func WithResponseHeaderBudget(bytes int) Option { return func(c *config) { c.responseHeaderBudget = bytes } } -// New validates handler and opts and returns an Adapter without performing I/O -// or invoking handler. Nil handlers, including typed nil handlers, and nil -// options are rejected. On error, the returned Adapter is nil. +// New validates handler and opts and returns an Adapter without performing I/O or invoking handler. +// Nil handlers, including typed nil handlers, and nil options are rejected. +// On error, the returned Adapter is nil. // -// The caller retains ownership of handler and is responsible for its concurrency -// safety when sharing it. New does not use [http.DefaultServeMux]. +// The caller retains ownership of handler and is responsible for its concurrency safety when sharing it. +// New does not use [http.DefaultServeMux]. func New(handler http.Handler, opts ...Option) (*Adapter, error) { cfg, err := configureAdapter(handler, opts) if err != nil { @@ -91,8 +100,8 @@ func configureAdapter(handler http.Handler, opts []Option) (config, error) { if handler == nil { return config{}, errors.New("edge: nil HTTP handler") } - // An interface containing a typed nil is non-nil. Check nil-capable kinds - // before accepting a handler that would defer a startup fault to invocation. + // An interface containing a typed nil is non-nil. + // Check nil-capable kinds before accepting a handler that would defer a startup fault to invocation. v := reflect.ValueOf(handler) switch v.Kind() { case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.Slice: diff --git a/adapter_example_test.go b/adapter_example_test.go index b2b4b77..edc7ca8 100644 --- a/adapter_example_test.go +++ b/adapter_example_test.go @@ -18,8 +18,8 @@ func ExampleAdapter_Invoke() { if err != nil { panic(err) } - // Production main calls lambda.Start(adapter). Passing adapter.Invoke as a - // reflected function would give []byte ordinary JSON/base64 semantics. + // Production main calls lambda.Start(adapter). + // Passing adapter.Invoke as a reflected function would give []byte ordinary JSON/base64 semantics. handler := lambda.NewHandler(adapter) response, err := handler.Invoke(context.Background(), []byte(`{"httpMethod":"GET","path":"/","requestContext":{"apiId":"example"}}`)) fmt.Println(len(response) > 0, err) @@ -33,13 +33,13 @@ func ExampleAdapter_HandleV2() { if err != nil { panic(err) } - // Production main may call lambda.Start(adapter.HandleV2). Its runtime then - // owns envelope JSON. This direct typed call performs no envelope JSON work. + // Production main may call lambda.Start(adapter.HandleV2). + // Its runtime then owns envelope JSON. + // This direct typed call performs no envelope JSON work. response, err := adapter.HandleV2(context.Background(), events.APIGatewayV2HTTPRequest{ - Version: "2.0", RawPath: "/", - RequestContext: events.APIGatewayV2HTTPRequestContext{ - APIID: "example", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "POST"}, - }, + Version: "2.0", + RawPath: "/", + RequestContext: events.APIGatewayV2HTTPRequestContext{APIID: "example", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "POST"}}, }) fmt.Println(response.StatusCode, err) // Output: 202 diff --git a/adapter_invoke.go b/adapter_invoke.go index 0b768d3..4732abe 100644 --- a/adapter_invoke.go +++ b/adapter_invoke.go @@ -9,11 +9,12 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Invoke adapts a REST proxy, HTTP API 1.0 or HTTP API 2.0 event using JSON v2 -// directly. Register the Adapter object with lambda.Start(adapter), not its -// Invoke method as a reflected function. Transport/identity failures return no -// usable payload; ordinary HTTP error responses remain successful invocations. -// Panics propagate after cleanup. The caller must not mutate payload during use. +// Invoke adapts a REST proxy, HTTP API 1.0 or HTTP API 2.0 event using JSON v2 directly. +// Register the Adapter object with lambda.Start(adapter), not its Invoke method as a reflected function. +// Transport/identity failures return no usable payload; +// ordinary HTTP error responses remain successful invocations. +// Panics propagate after cleanup. +// The caller must not mutate payload during use. func (a *Adapter) Invoke(ctx context.Context, payload []byte) ([]byte, error) { if err := a.valid(); err != nil { return nil, err @@ -46,11 +47,12 @@ func (a *Adapter) Invoke(ctx context.Context, payload []byte) ([]byte, error) { return result, nil } -// HandleV1 adapts an already decoded REST/HTTP API 1.0 event without serializing -// envelopes inside edge. It can be registered as lambda.Start(adapter.HandleV1). -// The upstream decoder owns wire validation and numeric fidelity; the caller or -// runtime owns final response serialization and its complete envelope size. -// On failure it returns a zero response. Inputs must not change during the call. +// HandleV1 adapts an already decoded REST/HTTP API 1.0 event without serializing envelopes inside edge. +// It can be registered as lambda.Start(adapter.HandleV1). +// The upstream decoder owns wire validation and numeric fidelity; +// the caller or runtime owns final response serialization and its complete envelope size. +// On failure it returns a zero response. +// Inputs must not change during the call. func (a *Adapter) HandleV1(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { if err := a.valid(); err != nil { return events.APIGatewayProxyResponse{}, err @@ -70,9 +72,9 @@ func (a *Adapter) HandleV1(ctx context.Context, event events.APIGatewayProxyRequ return result, nil } -// HandleV2 adapts an already decoded HTTP API 2.0 event. Its ownership, error and -// serialization contract matches HandleV1. V2 SDK string claims cannot recover -// arrays or exact numeric values discarded by an upstream decoder. +// HandleV2 adapts an already decoded HTTP API 2.0 event. +// Its ownership, error and serialization contract matches HandleV1. +// V2 SDK string claims cannot recover arrays or exact numeric values discarded by an upstream decoder. func (a *Adapter) HandleV2(ctx context.Context, event events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { if err := a.valid(); err != nil { return events.APIGatewayV2HTTPResponse{}, err @@ -136,8 +138,7 @@ func prepareRequest(ctx context.Context, inv *invocation, event decodedEvent, ra return nil, invocationError("identity", ErrIdentity, "caller installation failed", identity.ErrConflict) } request = request.WithContext(ctx) - // Track the actual served request for multipart cleanup, retaining the - // original transport body separately even if the handler replaces it. + // Track the actual served request for multipart cleanup, retaining the original transport body separately even if the handler replaces it. inv.request = request } return request, nil diff --git a/adapter_invoke_test.go b/adapter_invoke_test.go index 8b23dbd..516f234 100644 --- a/adapter_invoke_test.go +++ b/adapter_invoke_test.go @@ -288,7 +288,8 @@ func TestAdapterStandardHandlers(t *testing.T) { var body, location string if v2 { response, err := a.HandleV2(t.Context(), events.APIGatewayV2HTTPRequest{ - RawPath: path, Headers: map[string]string{"Range": "bytes=1-3"}, + RawPath: path, + Headers: map[string]string{"Range": "bytes=1-3"}, RequestContext: events.APIGatewayV2HTTPRequestContext{APIID: "api", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}}, }) if err != nil { @@ -304,7 +305,9 @@ func TestAdapterStandardHandlers(t *testing.T) { } } else { response, err := a.HandleV1(t.Context(), events.APIGatewayProxyRequest{ - Path: path, HTTPMethod: "GET", Headers: map[string]string{"Range": "bytes=1-3"}, + Path: path, + HTTPMethod: "GET", + Headers: map[string]string{"Range": "bytes=1-3"}, RequestContext: events.APIGatewayProxyRequestContext{APIID: "api"}, }) if err != nil { diff --git a/authn/authenticate.go b/authn/authenticate.go index f0782a5..a80b5db 100644 --- a/authn/authenticate.go +++ b/authn/authenticate.go @@ -9,37 +9,42 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// VerifyFunc authenticates a credential without its HTTP scheme. It must return -// ErrInvalidCredentials for a definite rejection; unknown errors are treated as -// unavailable. Success requires JWT/SourceLocallyVerifiedToken for Bearer or -// IAM/SourceVerifiedIAMProof for EdgeIAM. Constructors alone do not verify tokens. +// VerifyFunc authenticates a credential without its HTTP scheme. +// It must return ErrInvalidCredentials for a definite rejection; +// unknown errors are treated as unavailable. +// Success requires JWT/SourceLocallyVerifiedToken for Bearer or IAM/SourceVerifiedIAMProof for EdgeIAM. +// Constructors alone do not verify tokens. type VerifyFunc func(context.Context, string) (identity.Caller, error) // Config supplies explicit verifier dependencies and HTTP framing policy. type Config struct { - // Bearer and IAMProof are independently optional; at least one is required. + // Bearer and IAMProof are independently optional; + // at least one is required. // Each function must be safe for concurrent use when sharing an Authenticator. Bearer VerifyFunc IAMProof VerifyFunc // Realm is a challenge label, not a proof audience or issuer restriction. - // Empty defaults to "edge"; explicit values allow 1-256 printable ASCII bytes. + // Empty defaults to "edge"; + // explicit values allow 1-256 printable ASCII bytes. Realm string - // MaxAuthorizationBytes bounds represented Authorization value bytes before - // trimming. Zero defaults to 16 KiB; explicit values must be in 1..1 MiB. + // MaxAuthorizationBytes bounds represented Authorization value bytes before trimming. + // Zero defaults to 16 KiB; + // explicit values must be in 1..1 MiB. // This neither changes iamproof's 8 KiB proof limit nor bounds all headers. MaxAuthorizationBytes int } -// Authenticator is an immutable selector configured by New. Its zero value is -// unconfigured. Shared use requires concurrency-safe verifier functions. +// Authenticator is an immutable selector configured by New. +// Its zero value is unconfigured. +// Shared use requires concurrency-safe verifier functions. type Authenticator struct { cfg Config bearerChallenge string iamChallenge string } -// New validates and copies cfg without I/O or invoking a verifier. It returns a -// nil Authenticator and ErrInvalidConfiguration for invalid configuration. +// New validates and copies cfg without I/O or invoking a verifier. +// It returns a nil Authenticator and ErrInvalidConfiguration for invalid configuration. func New(cfg Config) (*Authenticator, error) { if cfg.Bearer == nil && cfg.IAMProof == nil || cfg.MaxAuthorizationBytes < 0 || cfg.MaxAuthorizationBytes > 1<<20 { return nil, ErrInvalidConfiguration @@ -69,14 +74,16 @@ func New(cfg Config) (*Authenticator, error) { return a, nil } -// Authenticate selects at most one verifier and returns a verified caller. It -// neither modifies headers nor installs context. Callers must not mutate headers -// concurrently. Every failure returns an anonymous zero caller. +// Authenticate selects at most one verifier and returns a verified caller. +// It neither modifies headers nor installs context. +// Callers must not mutate headers concurrently. +// Every failure returns an anonymous zero caller. // // An established caller fails with identity.ErrConflict before credential work. -// Runtime failures are *Error with sanitized categories; caller cancellation -// returns ctx.Err directly. Nil contexts and nil/zero Authenticators return -// ErrInvalidConfiguration. No provider diagnostic or context cause is exposed. +// Runtime failures are *Error with sanitized categories; +// caller cancellation returns ctx.Err directly. +// Nil contexts and nil/zero Authenticators return ErrInvalidConfiguration. +// No provider diagnostic or context cause is exposed. func (a *Authenticator) Authenticate(ctx context.Context, headers http.Header) (identity.Caller, error) { if a == nil || a.cfg.MaxAuthorizationBytes == 0 || ctx == nil { return identity.Caller{}, ErrInvalidConfiguration diff --git a/authn/authn_test.go b/authn/authn_test.go index 22f63f7..4f5b2e1 100644 --- a/authn/authn_test.go +++ b/authn/authn_test.go @@ -18,8 +18,9 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Synthetic callers stand in for already verified results. They do not prove -// that a token was authenticated; actual verification belongs to each producer. +// Synthetic callers stand in for already verified results. +// They do not prove that a token was authenticated; +// actual verification belongs to each producer. func jwtCaller(t testing.TB, source identity.Source) identity.Caller { t.Helper() claims, err := identity.NewClaims(map[string]any{"iss": "https://issuer.example", "sub": "alice", "scope": "orders.read"}) diff --git a/authn/authorization_test.go b/authn/authorization_test.go index fa6c9db..e1740e8 100644 --- a/authn/authorization_test.go +++ b/authn/authorization_test.go @@ -15,8 +15,8 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Exercise actual token verification and STS-proof parsing before application -// policy. Only JWKS/STS network responses and the grant service are synthetic. +// Exercise actual token verification and STS-proof parsing before application policy. +// Only JWKS/STS network responses and the grant service are synthetic. func TestAuthorizationAcrossTransports(t *testing.T) { key := signingKey(t) keys := keySet(t, publicJWK(key, "authorization")) @@ -148,7 +148,8 @@ func TestAuthorizationAcrossTransports(t *testing.T) { if err := selected.rule.Authorize(r.Context(), facts); err != nil { switch { case errors.Is(err, authz.ErrDenied): - // This route supports Bearer; no claim about the denial's cause. + // This route supports Bearer; + // no claim about the denial's cause. w.Header().Set("WWW-Authenticate", `Bearer realm="edge"`) authorizationResponse(t, w, 403, "forbidden") case errors.Is(err, authz.ErrUnavailable): diff --git a/authn/cognito.go b/authn/cognito.go index da4fbd5..06fcf23 100644 --- a/authn/cognito.go +++ b/authn/cognito.go @@ -14,9 +14,11 @@ import ( ) // CognitoClient restricts one app client independently of its resource audience. -// Audience requires an exact string aud. AllowUnbound additionally permits an -// absent aud; with an empty Audience it permits only absent aud. At least one -// of Audience or AllowUnbound is required. No policy accepts an arbitrary aud. +// Audience requires an exact string aud. +// AllowUnbound additionally permits an absent aud; +// with an empty Audience it permits only absent aud. +// At least one of Audience or AllowUnbound is required. +// No policy accepts an arbitrary aud. type CognitoClient struct { ClientID string Audience string @@ -25,34 +27,39 @@ type CognitoClient struct { // CognitoConfig supplies explicit trust and resource policy for access tokens. type CognitoConfig struct { - // Issuer is an exact HTTPS pool URL, without trailing slash, escaped path, - // dot segments, credentials, query or fragment. JWKS is fetched from this - // trusted endpoint plus /.well-known/jwks.json, never from token headers. + // Issuer is an exact HTTPS pool URL, without trailing slash, escaped path, dot segments, credentials, query or fragment. + // JWKS is fetched from this trusted endpoint plus /.well-known/jwks.json, never from token headers. Issuer string // Clients must contain at least one policy with a distinct nonempty ClientID. Clients []CognitoClient - // ClockSkew permits 0..5 minutes of leeway. Zero does not extend token life. + // ClockSkew permits 0..5 minutes of leeway. + // Zero does not extend token life. ClockSkew time.Duration - // CacheTTL defaults to 15 minutes; explicit values allow 1 minute..24 hours. - // Expired keys are not used during outages. No token results are cached. + // CacheTTL defaults to 15 minutes; + // explicit values allow 1 minute..24 hours. + // Expired keys are not used during outages. + // No token results are cached. CacheTTL time.Duration - // Timeout defaults to 5 seconds; explicit values must be positive and <=1 minute. + // Timeout defaults to 5 seconds; + // explicit values must be positive and <=1 minute. Timeout time.Duration - // Transport defaults to http.DefaultTransport. A supplied transport remains - // caller-owned, must support concurrency and must honor context cancellation. + // Transport defaults to http.DefaultTransport. + // A supplied transport remains caller-owned, must support concurrency and must honor context cancellation. Transport http.RoundTripper - // MaxTokenBytes defaults to 16 KiB; explicit values allow 1 byte..1 MiB. + // MaxTokenBytes defaults to 16 KiB; + // explicit values allow 1 byte..1 MiB. // The separate Authorization field bound also includes the scheme/framing. MaxTokenBytes int - // ClaimsBudget defaults to identity's 256 KiB weighted allowance. Explicit - // values allow 1 byte..6 MiB, independently of the compact token byte limit. + // ClaimsBudget defaults to identity's 256 KiB weighted allowance. + // Explicit values allow 1 byte..6 MiB, independently of the compact token byte limit. ClaimsBudget int } -// CognitoVerifier verifies a narrow RS256 Cognito access-token profile using -// JSON v2 and standard-library cryptography. It does not check revocation or -// grant permissions. It is safe for concurrent use, but must not be copied. -// Its zero value is unconfigured. Reuse one instance to share its bounded cache. +// CognitoVerifier verifies a narrow RS256 Cognito access-token profile using JSON v2 and standard-library cryptography. +// It does not check revocation or grant permissions. +// It is safe for concurrent use, but must not be copied. +// Its zero value is unconfigured. +// Reuse one instance to share its bounded cache. type CognitoVerifier struct { cfg CognitoConfig clients map[string]CognitoClient @@ -67,9 +74,10 @@ type CognitoVerifier struct { failed bool } -// NewCognitoVerifier validates and copies cfg without I/O. Invalid configuration -// returns a nil verifier and ErrInvalidConfiguration. It trusts the configured -// issuer endpoint; it does not establish that the operator supplied an AWS URL. +// NewCognitoVerifier validates and copies cfg without I/O. +// Invalid configuration returns a nil verifier and ErrInvalidConfiguration. +// It trusts the configured issuer endpoint; +// it does not establish that the operator supplied an AWS URL. func NewCognitoVerifier(cfg CognitoConfig) (*CognitoVerifier, error) { u, err := url.Parse(cfg.Issuer) if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Opaque != "" { @@ -130,10 +138,11 @@ func NewCognitoVerifier(cfg CognitoConfig) (*CognitoVerifier, error) { } // Verify returns one locally verified caller without installing it in context. -// It accepts access tokens only, with integer exp/iat and optional nbf, and a -// string aud when present. Rejections match ErrInvalidCredentials; unavailable -// JWKS matches ErrUnavailable. Caller cancellation returns ctx.Err, never its -// cause. Every failure returns a zero caller and sanitized diagnostics. +// It accepts access tokens only, with integer exp/iat and optional nbf, and a string aud when present. +// Rejections match ErrInvalidCredentials; +// unavailable JWKS matches ErrUnavailable. +// Caller cancellation returns ctx.Err, never its cause. +// Every failure returns a zero caller and sanitized diagnostics. func (v *CognitoVerifier) Verify(ctx context.Context, token string) (identity.Caller, error) { if v == nil || v.http == nil || ctx == nil { return identity.Caller{}, ErrInvalidConfiguration @@ -148,10 +157,10 @@ func (v *CognitoVerifier) Verify(ctx context.Context, token string) (identity.Ca return caller, err } -// Warm obtains a fresh usable JWKS snapshot, sharing any required fetch. It -// obeys refresh/backoff gates; it does not force refresh, validate a token or -// start a background refresh loop. Failures are ErrUnavailable, caller ctx.Err -// or ErrInvalidConfiguration for a nil context or unconfigured verifier. +// Warm obtains a fresh usable JWKS snapshot, sharing any required fetch. +// It obeys refresh/backoff gates; +// it does not force refresh, validate a token or start a background refresh loop. +// Failures are ErrUnavailable, caller ctx.Err or ErrInvalidConfiguration for a nil context or unconfigured verifier. func (v *CognitoVerifier) Warm(ctx context.Context) error { if v == nil || v.http == nil || ctx == nil { return ErrInvalidConfiguration diff --git a/authn/cognito_cache.go b/authn/cognito_cache.go index 61807a4..66db964 100644 --- a/authn/cognito_cache.go +++ b/authn/cognito_cache.go @@ -24,8 +24,9 @@ type jwksKey struct { expires time.Time } -// An empty kid means Warm. Every path performs at most one fetch/join. A -// successful fetch missing the requested kid must not start a refresh loop. +// An empty kid means Warm. +// Every path performs at most one fetch/join. +// A successful fetch missing the requested kid must not start a refresh loop. func (v *CognitoVerifier) key(ctx context.Context, kid string) (jwksKey, error) { v.mu.Lock() now := time.Now() @@ -58,10 +59,10 @@ func (v *CognitoVerifier) key(ctx context.Context, kid string) (jwksKey, error) case <-ctx.Done(): return jwksKey{}, ctx.Err() case <-flight.ctx.Done(): - // A misbehaving transport may still be running. Retain its flight so - // subsequent callers cannot accumulate replacement fetch goroutines. - // Completion also cancels the fetch context; prefer its published - // result when both signals are ready. + // A misbehaving transport may still be running. + // Retain its flight so subsequent callers cannot accumulate replacement fetch goroutines. + // Completion also cancels the fetch context; + // prefer its published result when both signals are ready. select { case <-flight.done: default: @@ -100,8 +101,8 @@ func (v *CognitoVerifier) refresh(flight *jwksFlight, cancel context.CancelFunc) v.retryAfter = now.Add(v.backoff) flight.err = ErrUnavailable } else { - // Publish only a complete validated set. Removed keys do not survive - // in a union, and failure never extends the old snapshot's lifetime. + // Publish only a complete validated set. + // Removed keys do not survive in a union, and failure never extends the old snapshot's lifetime. v.snapshot = jwksSnapshot{keys: keys, expires: now.Add(v.cfg.CacheTTL)} v.unknownAfter = now.Add(30 * time.Second) v.failed = false diff --git a/authn/cognito_cache_test.go b/authn/cognito_cache_test.go index d013517..9e2bc2f 100644 --- a/authn/cognito_cache_test.go +++ b/authn/cognito_cache_test.go @@ -311,10 +311,14 @@ func TestCognitoHTTPBoundAndOwnership(t *testing.T) { body string want error }{ - {200, good, nil}, {200, good + strings.Repeat(" ", 65536-len(good)), nil}, + {200, good, nil}, + {200, good + strings.Repeat(" ", 65536-len(good)), nil}, {200, strings.Repeat(" ", 1<<20), authn.ErrUnavailable}, - {200, "not JSON", authn.ErrUnavailable}, {302, good, authn.ErrUnavailable}, - {304, good, authn.ErrUnavailable}, {429, good, authn.ErrUnavailable}, {500, good, authn.ErrUnavailable}, + {200, "not JSON", authn.ErrUnavailable}, + {302, good, authn.ErrUnavailable}, + {304, good, authn.ErrUnavailable}, + {429, good, authn.ErrUnavailable}, + {500, good, authn.ErrUnavailable}, } { body := &jwksBody{reader: strings.NewReader(tc.body)} var calls atomic.Int64 diff --git a/authn/cognito_example_test.go b/authn/cognito_example_test.go index 289b94a..9a7b67b 100644 --- a/authn/cognito_example_test.go +++ b/authn/cognito_example_test.go @@ -13,8 +13,9 @@ func ExampleNewCognitoVerifier() { Issuer: "https://issuer-cognito-idp.eu-west-2.amazonaws.com/eu-west-2_example", Clients: []authn.CognitoClient{ {ClientID: "user-app-client", Audience: "https://orders.example"}, - // Cognito M2M grants do not provide resource binding. This permits - // absent aud only; any audience on this client's token is rejected. + // Cognito M2M grants do not provide resource binding. + // This permits absent aud only; + // any audience on this client's token is rejected. {ClientID: "machine-app-client", AllowUnbound: true}, }, }) @@ -27,8 +28,10 @@ func ExampleNewCognitoVerifier() { fmt.Println(err) return } - // Constructors perform no I/O. Verification is lazy; optional Warm(ctx) - // can fetch keys during application initialization. Reuse the verifier. + // Constructors perform no I/O. + // Verification is lazy; + // optional Warm(ctx) can fetch keys during application initialization. + // Reuse the verifier. // Missing credentials are rejected without contacting the issuer. _, err = a.Authenticate(context.Background(), make(http.Header)) fmt.Println(err) diff --git a/authn/cognito_jwks.go b/authn/cognito_jwks.go index ae517ad..ac1ef14 100644 --- a/authn/cognito_jwks.go +++ b/authn/cognito_jwks.go @@ -10,8 +10,8 @@ import ( "unicode/utf8" ) -// jwtObject validates all JSON, including ignored extension values, before -// allocating the member map. JSON v2 rejects duplicate names and invalid UTF-8. +// jwtObject validates all JSON, including ignored extension values, before allocating the member map. +// JSON v2 rejects duplicate names and invalid UTF-8. func jwtObject(data []byte) (map[string]jsontext.Value, error) { d := jsontext.NewDecoder(bytes.NewReader(data)) if d.PeekKind() != '{' { diff --git a/authn/cognito_jwks_test.go b/authn/cognito_jwks_test.go index cda2462..5b754a0 100644 --- a/authn/cognito_jwks_test.go +++ b/authn/cognito_jwks_test.go @@ -63,9 +63,18 @@ func TestCognitoJWKSValidation(t *testing.T) { good := publicJWK(key, "key") goodWire := keySet(t, good) for _, body := range []string{ - `null`, `[]`, `{}`, `{"keys":null}`, `{"keys":[]}`, `{"keys":[null]}`, `{"keys":[{}]}`, - keySet(t, good, good), keySet(t, good, map[string]any{"kty": "EC", "kid": "key"}), - keySet(t, good) + `{}`, `{"keys":[],"keys":[]}`, `{"keys":[],"ignored":{"x":1,"x":2}}`, + `null`, + `[]`, + `{}`, + `{"keys":null}`, + `{"keys":[]}`, + `{"keys":[null]}`, + `{"keys":[{}]}`, + keySet(t, good, good), + keySet(t, good, map[string]any{"kty": "EC", "kid": "key"}), + keySet(t, good) + `{}`, + `{"keys":[],"keys":[]}`, + `{"keys":[],"ignored":{"x":1,"x":2}}`, `{"keys":[],"ignored":` + strings.Repeat("[", 64) + "0" + strings.Repeat("]", 64) + `}`, goodWire[:len(goodWire)-1] + `,"ignored":{"x":1,"x":2}}`, goodWire[:len(goodWire)-1] + `,"ignored":` + strings.Repeat("[", 64) + "0" + strings.Repeat("]", 64) + `}`, @@ -75,8 +84,9 @@ func TestCognitoJWKSValidation(t *testing.T) { t.Fatalf("invalid key set accepted: %v", err) } } - // Unsupported keys do not participate in signature verification. Optional - // metadata may be absent; an opaque kid can contain '=' or URL-like text. + // Unsupported keys do not participate in signature verification. + // Optional metadata may be absent; + // an opaque kid can contain '=' or URL-like text. minimal := publicJWK(key, "https://untrusted.invalid/key=") delete(minimal, "use") delete(minimal, "alg") diff --git a/authn/cognito_test.go b/authn/cognito_test.go index a55ed9e..d3f2ade 100644 --- a/authn/cognito_test.go +++ b/authn/cognito_test.go @@ -44,9 +44,14 @@ func encodeJSON(t testing.TB, value any) []byte { } func publicJWK(key *rsa.PrivateKey, kid string) map[string]any { - return map[string]any{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": kid, - "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), - "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())} + return map[string]any{ + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": kid, + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + } } func keySet(t testing.TB, entries ...map[string]any) string { @@ -56,9 +61,18 @@ func keySet(t testing.TB, entries ...map[string]any) string { func accessClaims() map[string]any { now := time.Now().Unix() - return map[string]any{"iss": cognitoIssuer, "client_id": "app", "sub": "alice", "token_use": "access", - "aud": "https://orders.example", "iat": now, "exp": now + 3600, "scope": "orders.read", "cognito:groups": []string{"operators"}, - "precise": jsontext.Value("9007199254740993")} + return map[string]any{ + "iss": cognitoIssuer, + "client_id": "app", + "sub": "alice", + "token_use": "access", + "aud": "https://orders.example", + "iat": now, + "exp": now + 3600, + "scope": "orders.read", + "cognito:groups": []string{"operators"}, + "precise": jsontext.Value("9007199254740993"), + } } func signRawToken(t testing.TB, key *rsa.PrivateKey, header, payload []byte) string { @@ -227,9 +241,17 @@ func TestCognitoClientAudienceAndClaims(t *testing.T) { name string value any }{ - {"iss", "https://elsewhere.example/pool"}, {"iss", nil}, {"client_id", "other"}, {"client_id", nil}, - {"token_use", "id"}, {"token_use", nil}, {"sub", ""}, {"sub", 42}, {"scope", "read\twrite"}, - {"cognito:groups", "operators"}, {"scp", []string{"inconsistent"}}, + {"iss", "https://elsewhere.example/pool"}, + {"iss", nil}, + {"client_id", "other"}, + {"client_id", nil}, + {"token_use", "id"}, + {"token_use", nil}, + {"sub", ""}, + {"sub", 42}, + {"scope", "read\twrite"}, + {"cognito:groups", "operators"}, + {"scp", []string{"inconsistent"}}, } { claims := accessClaims() claims[tc.name] = tc.value @@ -246,11 +268,18 @@ func TestCognitoTokenSyntaxAndSignature(t *testing.T) { }))) payload := encodeJSON(t, accessClaims()) for _, header := range []string{ - `null`, `[]`, `{"alg":"RS256","kid":"key","alg":"RS256"}`, `{"alg":"none","kid":"key"}`, - `{"alg":"HS256","kid":"key"}`, `{"alg":"RS384","kid":"key"}`, `{"alg":"RS256","kid":""}`, - `{"alg":"RS256","kid":"key","typ":null}`, `{"alg":"RS256","kid":"key","typ":"at+jwt"}`, + `null`, + `[]`, + `{"alg":"RS256","kid":"key","alg":"RS256"}`, + `{"alg":"none","kid":"key"}`, + `{"alg":"HS256","kid":"key"}`, + `{"alg":"RS384","kid":"key"}`, + `{"alg":"RS256","kid":""}`, + `{"alg":"RS256","kid":"key","typ":null}`, + `{"alg":"RS256","kid":"key","typ":"at+jwt"}`, `{"alg":"RS256","kid":"key","extension":{"x":1,"x":2}}`, - `{"alg":"RS256","kid":"key"} {}`, `{"alg":"RS256","kid":"key","x":"` + string([]byte{0xff}) + `"}`, + `{"alg":"RS256","kid":"key"} {}`, + `{"alg":"RS256","kid":"key","x":"` + string([]byte{0xff}) + `"}`, } { verifyResult(t, v, t.Context(), signRawToken(t, key, []byte(header), payload), authn.ErrInvalidCredentials) } @@ -311,10 +340,19 @@ func TestCognitoDatesAndBounds(t *testing.T) { name string value any }{ - {"exp", now}, {"exp", nil}, {"exp", "9999999999"}, {"exp", jsontext.Value("1e10")}, - {"exp", jsontext.Value("9999999999.0")}, {"exp", jsontext.Value("253402300800")}, - {"exp", jsontext.Value("999999999999999999999999")}, {"iat", -1}, {"iat", now + 1}, - {"iat", nil}, {"nbf", now + 1}, {"nbf", nil}, {"nbf", now + 3600}, + {"exp", now}, + {"exp", nil}, + {"exp", "9999999999"}, + {"exp", jsontext.Value("1e10")}, + {"exp", jsontext.Value("9999999999.0")}, + {"exp", jsontext.Value("253402300800")}, + {"exp", jsontext.Value("999999999999999999999999")}, + {"iat", -1}, + {"iat", now + 1}, + {"iat", nil}, + {"nbf", now + 1}, + {"nbf", nil}, + {"nbf", now + 3600}, } { claims := accessClaims() claims[tc.name] = tc.value diff --git a/authn/cognito_token.go b/authn/cognito_token.go index ebc3690..3dbe537 100644 --- a/authn/cognito_token.go +++ b/authn/cognito_token.go @@ -84,8 +84,7 @@ func (v *CognitoVerifier) verify(ctx context.Context, token string) (identity.Ca if err != nil { return identity.Caller{}, ErrInvalidCredentials } - // A process pause after key lookup must not allow a now-expired key or - // token to establish an identity on resumption. + // A process pause after key lookup must not allow a now-expired key or token to establish an identity on resumption. now := time.Now() if !dates.valid(now, v.cfg.ClockSkew) { return identity.Caller{}, ErrInvalidCredentials @@ -108,8 +107,8 @@ func decodeJWTPart(text string, limit int) ([]byte, error) { if text == "" || len(text) > base64.RawURLEncoding.EncodedLen(limit) { return nil, ErrInvalidCredentials } - // Strict decoding checks unused bits but still ignores CR/LF. Check the - // alphabet first so whitespace, padding and extra segments cannot disappear. + // Strict decoding checks unused bits but still ignores CR/LF. + // Check the alphabet first so whitespace, padding and extra segments cannot disappear. for i := range len(text) { c := text[i] if !(c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') { diff --git a/authn/cognito_vector_test.go b/authn/cognito_vector_test.go index 0ea57ce..89056de 100644 --- a/authn/cognito_vector_test.go +++ b/authn/cognito_vector_test.go @@ -5,26 +5,11 @@ import ( "testing" ) -// Public key, encoded signing input and signature from RFC 7515 Appendix A.2: -// https://www.rfc-editor.org/rfc/rfc7515.html#appendix-A.2 -// This independently published vector tests the primitive, not our Cognito -// profile: the original RFC token has neither a Cognito issuer nor access claims. +// Public key, encoded signing input and signature from RFC 7515 Appendix A.2: https://www.rfc-editor.org/rfc/rfc7515.html#appendix-A.2 This independently published vector tests the primitive, not our Cognito profile: the original RFC token has neither a Cognito issuer nor access claims. func TestRS256RFC7515Vector(t *testing.T) { - const n = "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddx" + - "HmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMs" + - "D1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSH" + - "SXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdV" + - "MTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8" + - "NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ" - const input = "eyJhbGciOiJSUzI1NiJ9." + - "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt" + - "cGxlLmNvbS9pc19yb290Ijp0cnVlfQ" - const signature = "cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZmh7" + - "AAuHIm4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjbKBYNX4" + - "BAynRFdiuB--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHlb1L07Qe7K" + - "0GarZRmB_eSN9383LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZESc6BfI7noOPqv" + - "hJ1phCnvWh6IeYI2w9QOYEUipUTI8np6LbgGY9Fs98rqVt5AXLIhWkWywlVmtVrB" + - "p0igcN_IoypGlUPQGe77Rw" + const n = "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddxHmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMsD1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSHSXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ" + const input = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" + const signature = "cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZmh7AAuHIm4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjbKBYNX4BAynRFdiuB--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHlb1L07Qe7K0GarZRmB_eSN9383LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZESc6BfI7noOPqvhJ1phCnvWh6IeYI2w9QOYEUipUTI8np6LbgGY9Fs98rqVt5AXLIhWkWywlVmtVrBp0igcN_IoypGlUPQGe77Rw" keys, err := parseJWKS([]byte(`{"keys":[{"kty":"RSA","kid":"rfc","n":"` + n + `","e":"AQAB"}]}`)) if err != nil { t.Fatal(err) diff --git a/authn/doc.go b/authn/doc.go index 788e461..678fb62 100644 --- a/authn/doc.go +++ b/authn/doc.go @@ -1,18 +1,16 @@ -// Package authn selects one configured Bearer or EdgeIAM verifier from an HTTP -// Authorization header and transports the verified caller to protected handlers. -// NewCognitoVerifier provides local RS256 Cognito access-token verification with -// direct JSON v2 and bounded JWKS caching. It does not check token revocation, -// authorize actions, or infer trust from a gateway, a TLS field, an action header -// or forwarded headers. +// Package authn selects one configured Bearer or EdgeIAM verifier from an HTTP Authorization header and transports the verified caller to protected handlers. +// NewCognitoVerifier provides local RS256 Cognito access-token verification with direct JSON v2 and bounded JWKS caching. +// It does not check token revocation, authorize actions, or infer trust from a gateway, a TLS field, an action header or forwarded headers. // -// Use New to configure explicit verifier functions. Authenticate supports custom -// HTTP pipelines without writing responses or installing context. Handler adds -// required-authentication middleware. Public routes omit that middleware; CORS -// and preflight policy belong to the gateway or outer middleware. +// Use New to configure explicit verifier functions. +// Authenticate supports custom HTTP pipelines without writing responses or installing context. +// Handler adds required-authentication middleware. +// Public routes omit that middleware; +// CORS and preflight policy belong to the gateway or outer middleware. // -// Verifiers must authenticate credentials before returning a caller and classify -// rejection with ErrInvalidCredentials. Adapt iamproof.ErrInvalidProof explicitly -// when wiring an IAM proof verifier; this package does not import AWS packages. -// Never retry another verifier after rejection. Authenticate and authorize the -// selected action before committing a response or starting a stream. +// Verifiers must authenticate credentials before returning a caller and classify rejection with ErrInvalidCredentials. +// Adapt iamproof.ErrInvalidProof explicitly when wiring an IAM proof verifier; +// this package does not import AWS packages. +// Never retry another verifier after rejection. +// Authenticate and authorize the selected action before committing a response or starting a stream. package authn diff --git a/authn/errors.go b/authn/errors.go index 9590c95..2fe9d31 100644 --- a/authn/errors.go +++ b/authn/errors.go @@ -18,9 +18,9 @@ var ( ErrVerifierContract = errors.New("authn: invalid verifier result") ) -// Error describes a sanitized HTTP authentication failure. Inspect its category -// with errors.Is and its HTTP treatment with errors.AsType[*Error]. Its private -// state never retains a supplied credential or an underlying provider error. +// Error describes a sanitized HTTP authentication failure. +// Inspect its category with errors.Is and its HTTP treatment with errors.AsType[*Error]. +// Its private state never retains a supplied credential or an underlying provider error. // The zero value describes an internal failure, without a challenge. type Error struct { category error diff --git a/authn/example_test.go b/authn/example_test.go index cd646e6..b552f37 100644 --- a/authn/example_test.go +++ b/authn/example_test.go @@ -27,8 +27,9 @@ func ExampleAuthenticator_Handler() { } return caller, err } - // Synthetic fixture only. Replace this function with your actual issuer/key/ - // signature/expiry/client-policy verifier. Parsing claims is NOT verification. + // Synthetic fixture only. + // Replace this function with your actual issuer/key/signature/expiry/client-policy verifier. + // Parsing claims is NOT verification. verifyBearer := func(_ context.Context, token string) (identity.Caller, error) { if token != "synthetic-example-token" { return identity.Caller{}, authn.ErrInvalidCredentials @@ -55,8 +56,8 @@ func ExampleAuthenticator_Handler() { http.Error(w, "invalid action", http.StatusBadRequest) return } - // Consumer-owned authorization: deny by default. Neither successful - // token verification nor AWS account membership grants this action alone. + // Consumer-owned authorization: deny by default. + // Neither successful token verification nor AWS account membership grants this action alone. caller := identity.FromContext(r.Context()) allowed := false if jwt, ok := caller.JWT(); ok { @@ -70,8 +71,9 @@ func ExampleAuthenticator_Handler() { http.Error(w, "forbidden", http.StatusForbidden) return } - // Execute the exact selection just authorized. Extra consumer headers - // remain available through r.Header; authn does not interpret them. + // Execute the exact selection just authorized. + // Extra consumer headers remain available through r.Header; + // authn does not interpret them. w.WriteHeader(http.StatusNoContent) }) protected, err := a.Handler(dispatcher) @@ -80,7 +82,8 @@ func ExampleAuthenticator_Handler() { return } // protected also goes directly to edge.New for Lambda registration. - // This local example exercises only the synthetic Bearer path; no AWS calls. + // This local example exercises only the synthetic Bearer path; + // no AWS calls. r := httptest.NewRequest(http.MethodPost, "/", nil) r.Header.Set("Authorization", "Bearer synthetic-example-token") r.Header.Set("Action", "orders.read") @@ -100,8 +103,8 @@ func ExampleAuthenticator_Handler() { } func ExampleAuthenticator_Authenticate() { - // This example demonstrates a custom JSON error envelope. Its verifier - // rejects all credentials and is never called for the missing-header case. + // This example demonstrates a custom JSON error envelope. + // Its verifier rejects all credentials and is never called for the missing-header case. a, err := authn.New(authn.Config{Bearer: func(context.Context, string) (identity.Caller, error) { return identity.Caller{}, authn.ErrInvalidCredentials }}) @@ -136,8 +139,8 @@ func ExampleAuthenticator_Authenticate() { } return } - // A real custom pipeline would install caller, authorize its selected - // action, then invoke the application. No such success is possible here. + // A real custom pipeline would install caller, authorize its selected action, then invoke the application. + // No such success is possible here. _ = caller http.Error(w, "no action authorized", http.StatusForbidden) }) diff --git a/authn/gateway_test.go b/authn/gateway_test.go index 3d56051..c092f0b 100644 --- a/authn/gateway_test.go +++ b/authn/gateway_test.go @@ -46,8 +46,8 @@ func localProofVerifier(t testing.TB) (*iamproof.Verifier, iamproof.Token, *atom if r.URL.Host != "sts.eu-west-2.amazonaws.com" || r.Header.Get("X-Edge-IAM-Audience") != "orders.production" || r.URL.Query().Get("Action") != "GetCallerIdentity" || r.URL.Query().Get("X-Amz-Signature") == "" { t.Error("incorrect reconstructed STS request") } - // A synthetic STS response exercises production composition, not AWS - // acceptance. Signature reconstruction is separately tested by iamproof. + // A synthetic STS response exercises production composition, not AWS acceptance. + // Signature reconstruction is separately tested by iamproof. return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`arn:aws:iam::123456789012:user/Alice123456789012AIDAEXAMPLE`))}, nil }))) if err != nil { @@ -184,10 +184,18 @@ func serveFormatBody(t *testing.T, h http.Handler, format string, values []strin } return resp.StatusCode, resp.Header, body } - v1 := events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{APIID: "example"}, - Headers: map[string]string{"action": action, "custom-trace": "keep"}} - v2 := events.APIGatewayV2HTTPRequest{Version: "2.0", RawPath: "/", RequestContext: events.APIGatewayV2HTTPRequestContext{APIID: "example", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}}, - Headers: map[string]string{"action": action, "custom-trace": "keep"}} + v1 := events.APIGatewayProxyRequest{ + HTTPMethod: "GET", + Path: "/", + RequestContext: events.APIGatewayProxyRequestContext{APIID: "example"}, + Headers: map[string]string{"action": action, "custom-trace": "keep"}, + } + v2 := events.APIGatewayV2HTTPRequest{ + Version: "2.0", + RawPath: "/", + RequestContext: events.APIGatewayV2HTTPRequestContext{APIID: "example", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}}, + Headers: map[string]string{"action": action, "custom-trace": "keep"}, + } if len(values) != 0 { v1.Headers["authorization"] = values[0] v1.MultiValueHeaders = map[string][]string{"Authorization": values} @@ -314,9 +322,7 @@ func TestGatewayProducerConflictAndInvocationCancellation(t *testing.T) { if err != nil { t.Fatal(err) } - event := events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{ - APIID: "example", Identity: events.APIGatewayRequestIdentity{UserArn: "arn:aws:iam::123456789012:user/Alice", AccountID: "123456789012"}, - }} + event := events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{APIID: "example", Identity: events.APIGatewayRequestIdentity{UserArn: "arn:aws:iam::123456789012:user/Alice", AccountID: "123456789012"}}} resp, err := adapter.HandleV1(t.Context(), event) if err != nil || resp.StatusCode != 500 || len(resp.MultiValueHeaders["Www-Authenticate"]) != 0 { t.Fatalf("gateway/local conflict: response=%d err=%v; want HTTP 500", resp.StatusCode, err) diff --git a/authn/handler.go b/authn/handler.go index 19c034f..1ef17ea 100644 --- a/authn/handler.go +++ b/authn/handler.go @@ -9,14 +9,17 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Handler constructs required-authentication middleware. It validates next, -// including typed nils. Success installs one caller in a derived request context -// and invokes next exactly once. It leaves the original request and headers intact. +// Handler constructs required-authentication middleware. +// It validates next, including typed nils. +// Success installs one caller in a derived request context and invokes next exactly once. +// It leaves the original request and headers intact. // // Failure writes a generic text response, no-store and the applicable challenges; -// next is not invoked. Caller cancellation attempts 503 without a challenge, but -// delivery is not guaranteed. Provider/handler panics propagate. Public routes -// and CORS/preflight must be configured separately; there is no OPTIONS bypass. +// next is not invoked. +// Caller cancellation attempts 503 without a challenge, but delivery is not guaranteed. +// Provider/handler panics propagate. +// Public routes and CORS/preflight must be configured separately; +// there is no OPTIONS bypass. func (a *Authenticator) Handler(next http.Handler) (http.Handler, error) { if a == nil || a.cfg.MaxAuthorizationBytes == 0 || next == nil { return nil, ErrInvalidConfiguration @@ -56,8 +59,8 @@ func writeFailure(w http.ResponseWriter, err error) { status = http.StatusServiceUnavailable } headers := w.Header() - // Direct map users can leave noncanonical aliases. Own these failure fields - // without dropping unrelated outer middleware headers such as CORS. + // Direct map users can leave noncanonical aliases. + // Own these failure fields without dropping unrelated outer middleware headers such as CORS. for name := range headers { switch http.CanonicalHeaderKey(name) { case "Www-Authenticate", "Cache-Control": diff --git a/authz/doc.go b/authz/doc.go index 07217cf..e90ee50 100644 --- a/authz/doc.go +++ b/authz/doc.go @@ -1,16 +1,13 @@ -// Package authz authorizes explicit caller, action and resource facts using -// immutable rules. It does not authenticate, evaluate AWS IAM policies, resolve -// application grants automatically or write HTTP responses. +// Package authz authorizes explicit caller, action and resource facts using immutable rules. +// It does not authenticate, evaluate AWS IAM policies, resolve application grants automatically or write HTTP responses. // -// Construct rules once and share them across requests. Custom checks must be -// concurrency-safe, honor their context and return false, nil for ordinary -// denial. Every callback error becomes ErrUnavailable. All and Any evaluate -// left to right and stop on an observed error; keep mandatory application guards -// outside alternative permission branches. +// Construct rules once and share them across requests. +// Custom checks must be concurrency-safe, honor their context and return false, nil for ordinary denial. +// Every callback error becomes ErrUnavailable. +// All and Any evaluate left to right and stop on an observed error; +// keep mandatory application guards outside alternative permission branches. // // Authorize requires a nonanonymous caller even for a permissive custom check. -// Identity constructors validate facts but do not authenticate them: establish -// the caller through a trusted producer before authorization. Complete selection -// and authorization before executing the same action/resource or committing an -// HTTP response, including a stream. +// Identity constructors validate facts but do not authenticate them: establish the caller through a trusted producer before authorization. +// Complete selection and authorization before executing the same action/resource or committing an HTTP response, including a stream. package authz diff --git a/authz/example_test.go b/authz/example_test.go index f64a84b..233ce18 100644 --- a/authz/example_test.go +++ b/authz/example_test.go @@ -18,15 +18,14 @@ import ( const orderIssuer = "https://issuer.example/pool" // grantStore is an application interface, not a universal edge principal model. -// Its implementation chooses the authority and enrollment keys, resolves tenant -// membership/suspension and checks the exact action/resource in one operation. +// Its implementation chooses the authority and enrollment keys, resolves tenant membership/suspension and checks the exact action/resource in one operation. type grantStore interface { Allowed(context.Context, identity.Caller, string, string) (bool, error) } -// orderHandler owns an immutable registry. This example has one fixed resource; -// a real application validates/resolves its target before calling Authorize and -// keeps that same object/version for execution, using a transaction if needed. +// orderHandler owns an immutable registry. +// This example has one fixed resource; +// a real application validates/resolves its target before calling Authorize and keeps that same object/version for execution, using a transaction if needed. func orderHandler(authentication *authn.Authenticator, grants grantStore, issuer string) (http.Handler, error) { selector, err := edge.NewActionHeader("Action") if err != nil { @@ -92,8 +91,8 @@ func orderHandler(authentication *authn.Authenticator, grants grantStore, issuer case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, authz.ErrUnavailable): writePolicyError(w, 503, "unavailable") case errors.Is(err, authz.ErrDenied): - // This route accepts Bearer. A generic challenge reveals no policy - // reason and does not mislabel an IAM or grant denial as a scope error. + // This route accepts Bearer. + // A generic challenge reveals no policy reason and does not mislabel an IAM or grant denial as a scope error. w.Header().Set("WWW-Authenticate", `Bearer realm="orders"`) writePolicyError(w, 403, "forbidden") case errors.Is(err, authz.ErrUnauthenticated): @@ -119,9 +118,9 @@ func writePolicyError(w http.ResponseWriter, status int, code string) { }{code}) } -// exampleGrants is a server-owned, read-only fixture. A database implementation -// can return false,nil for suspended/unregistered principals and an error when -// its dependency cannot decide. These grants are never put in identity.Claims. +// exampleGrants is a server-owned, read-only fixture. +// A database implementation can return false,nil for suspended/unregistered principals and an error when its dependency cannot decide. +// These grants are never put in identity.Claims. type exampleGrants struct{} func (exampleGrants) Allowed(ctx context.Context, caller identity.Caller, action, resource string) (bool, error) { @@ -136,16 +135,17 @@ func (exampleGrants) Allowed(ctx context.Context, caller identity.Caller, action return present && j.Issuer() == orderIssuer && subject == "alice", nil } if i, ok := caller.IAM(); ok { - // This fixture explicitly enrolls a full session. Applications can use a - // different reviewed enrollment key; account or session name alone is not one. + // This fixture explicitly enrolls a full session. + // Applications can use a different reviewed enrollment key; + // account or session name alone is not one. return i.PrincipalARN() == "arn:aws:sts::123456789012:assumed-role/OrderReader/session-one", nil } return false, nil } func ExampleCheck() { - // These are validated fixture facts, not credential verification. A request - // handler obtains the caller from its configured authentication producer. + // These are validated fixture facts, not credential verification. + // A request handler obtains the caller from its configured authentication producer. claims, err := identity.NewClaims(map[string]any{"iss": orderIssuer, "sub": "alice"}) if err != nil { panic(err) @@ -171,9 +171,9 @@ func ExampleCheck() { } func Example_dispatcher() { - // Construction performs no network I/O. Configure real pool/client/resource - // and STS audience values for deployment. The example sends no credentials, - // so neither verifier contacts AWS when run. + // Construction performs no network I/O. + // Configure real pool/client/resource and STS audience values for deployment. + // The example sends no credentials, so neither verifier contacts AWS when run. bearer, err := authn.NewCognitoVerifier(authn.CognitoConfig{ Issuer: orderIssuer, Clients: []authn.CognitoClient{{ClientID: "orders-client", Audience: "https://orders.example"}}, @@ -203,8 +203,8 @@ func Example_dispatcher() { if err != nil { panic(err) } - // h is an ordinary http.Handler. For Lambda, edge.New(h) uses the same - // dispatcher, with gateway identity left disabled for these local verifiers. + // h is an ordinary http.Handler. + // For Lambda, edge.New(h) uses the same dispatcher, with gateway identity left disabled for these local verifiers. r := httptest.NewRequest(http.MethodGet, "/", nil) r.Header.Set("Action", "orders.read") w := httptest.NewRecorder() diff --git a/authz/predicates.go b/authz/predicates.go index 55d619c..ae855b3 100644 --- a/authz/predicates.go +++ b/authz/predicates.go @@ -9,10 +9,11 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Sources matches any supplied nonanonymous producer. Empty lists, SourceNone -// and unknown sources return ErrInvalidConfiguration. Source is trusted-code -// attribution, not an unforgeable credential. Combine this broad predicate with -// permission checks. The input slice is copied. +// Sources matches any supplied nonanonymous producer. +// Empty lists, SourceNone and unknown sources return ErrInvalidConfiguration. +// Source is trusted-code attribution, not an unforgeable credential. +// Combine this broad predicate with permission checks. +// The input slice is copied. func Sources(allowed ...identity.Source) (Rule, error) { if len(allowed) == 0 { return Rule{}, ErrInvalidConfiguration @@ -30,9 +31,10 @@ func Sources(allowed ...identity.Source) (Rule, error) { }) } -// JWTSubject matches an exact issuer and subject. Both must be nonempty UTF-8 -// or construction returns ErrInvalidConfiguration. A subject does not prove a -// human identity. Wrong kind or missing subject does not match. +// JWTSubject matches an exact issuer and subject. +// Both must be nonempty UTF-8 or construction returns ErrInvalidConfiguration. +// A subject does not prove a human identity. +// Wrong kind or missing subject does not match. func JWTSubject(issuer, subject string) (Rule, error) { if !validIdentifier(issuer) || !validIdentifier(subject) { return Rule{}, ErrInvalidConfiguration @@ -44,9 +46,8 @@ func JWTSubject(issuer, subject string) (Rule, error) { }) } -// JWTClient matches an exact issuer and client_id, without interpreting audience -// or subject as a client ID or inferring a machine grant. Both arguments must -// be nonempty UTF-8 or construction returns ErrInvalidConfiguration. +// JWTClient matches an exact issuer and client_id, without interpreting audience or subject as a client ID or inferring a machine grant. +// Both arguments must be nonempty UTF-8 or construction returns ErrInvalidConfiguration. func JWTClient(issuer, clientID string) (Rule, error) { if !validIdentifier(issuer) || !validIdentifier(clientID) { return Rule{}, ErrInvalidConfiguration @@ -59,19 +60,21 @@ func JWTClient(issuer, clientID string) (Rule, error) { } // JWTScopes requires all supplied exact OAuth scope tokens from the exact issuer. -// It does not interpret wildcards. Missing or unavailable scopes do not match. -// Issuer must be nonempty UTF-8 and required must contain at least one valid -// RFC 6749 scope token, otherwise construction returns ErrInvalidConfiguration. -// The input slice is copied. Use Any for alternative scope requirements. +// It does not interpret wildcards. +// Missing or unavailable scopes do not match. +// Issuer must be nonempty UTF-8 and required must contain at least one valid RFC 6749 scope token, otherwise construction returns ErrInvalidConfiguration. +// The input slice is copied. +// Use Any for alternative scope requirements. func JWTScopes(issuer string, required ...string) (Rule, error) { return jwtSet(issuer, required, identity.JWT.Scopes, validScope) } -// CognitoGroups requires all supplied exact cognito:groups values from the exact -// issuer. Flattened gateway strings remain unavailable and never match. Groups -// are distinct from scopes and application grants. Issuer and each required -// value must be nonempty UTF-8; an empty list or invalid value returns -// ErrInvalidConfiguration. The input slice is copied. +// CognitoGroups requires all supplied exact cognito:groups values from the exact issuer. +// Flattened gateway strings remain unavailable and never match. +// Groups are distinct from scopes and application grants. +// Issuer and each required value must be nonempty UTF-8; +// an empty list or invalid value returns ErrInvalidConfiguration. +// The input slice is copied. func CognitoGroups(issuer string, required ...string) (Rule, error) { return jwtSet(issuer, required, identity.JWT.CognitoGroups, validIdentifier) } @@ -123,11 +126,10 @@ func validScope(s string) bool { } // IAMPrincipal matches the full caller ARN literally, including path and session. -// A root ARN matches only that root caller, not the account. Valid literal * and -// ? in user paths retain their meaning as characters. Unsupported caller forms -// (including bare role ARNs) return ErrInvalidConfiguration, following NewIAM's -// grammar. This does not evaluate AWS IAM permissions or bind a unique identity -// across deletion and recreation of the principal. +// A root ARN matches only that root caller, not the account. +// Valid literal * and ? in user paths retain their meaning as characters. +// Unsupported caller forms (including bare role ARNs) return ErrInvalidConfiguration, following NewIAM's grammar. +// This does not evaluate AWS IAM permissions or bind a unique identity across deletion and recreation of the principal. func IAMPrincipal(principalARN string) (Rule, error) { if _, err := identity.NewIAM(principalARN, identity.SourceCustomAssertion); err != nil { return Rule{}, ErrInvalidConfiguration @@ -138,15 +140,14 @@ func IAMPrincipal(principalARN string) (Rule, error) { }) } -// IAMRoleSessions matches assumed-role callers with the exact partition, account -// and role name, allowing any valid session name. Invalid components, wildcards -// and role paths return ErrInvalidConfiguration. This is a role-name policy: -// recreating the same role name can match again. It does not bind a unique role -// ID, identify the original actor, fabricate a role ARN or evaluate IAM policies. +// IAMRoleSessions matches assumed-role callers with the exact partition, account and role name, allowing any valid session name. +// Invalid components, wildcards and role paths return ErrInvalidConfiguration. +// This is a role-name policy: recreating the same role name can match again. +// It does not bind a unique role ID, identify the original actor, fabricate a role ARN or evaluate IAM policies. func IAMRoleSessions(partition, accountID, roleName string) (Rule, error) { prefix := "arn:" + partition + ":sts::" + accountID + ":assumed-role/" + roleName + "/" - // Validate a sample session with the identity grammar rather than maintaining - // a second ARN parser. Cross-check components to exclude delimiter injection. + // Validate a sample session with the identity grammar rather than maintaining a second ARN parser. + // Cross-check components to exclude delimiter injection. caller, err := identity.NewIAM(prefix+"validation", identity.SourceCustomAssertion) i, ok := caller.IAM() if err != nil || !ok || i.PrincipalType() != identity.IAMPrincipalAssumedRole || i.Partition() != partition || i.AccountID() != accountID { diff --git a/authz/rule.go b/authz/rule.go index 87553db..1f42a44 100644 --- a/authz/rule.go +++ b/authz/rule.go @@ -18,26 +18,31 @@ var ( ErrUnavailable = errors.New("authz: unavailable") ) -// Request supplies facts passed by value to each check. Action must -// be nonempty UTF-8; Resource is optional UTF-8. Both compare exactly without -// normalization and have application-defined meaning. A resource-aware check -// must reject an absent Resource. Caller is explicit, not read from context. +// Request supplies facts passed by value to each check. +// Action must be nonempty UTF-8; +// Resource is optional UTF-8. +// Both compare exactly without normalization and have application-defined meaning. +// A resource-aware check must reject an absent Resource. +// Caller is explicit, not read from context. type Request struct { Caller identity.Caller Action string Resource string } -// CheckFunc decides whether a request matches. Return false, nil for denial; -// any error becomes ErrUnavailable, even when accompanied by true. Checks run -// synchronously, must honor ctx and must be safe for concurrent use when shared. -// The caller owns dependency deadlines. Panics propagate. +// CheckFunc decides whether a request matches. +// Return false, nil for denial; +// any error becomes ErrUnavailable, even when accompanied by true. +// Checks run synchronously, must honor ctx and must be safe for concurrent use when shared. +// The caller owns dependency deadlines. +// Panics propagate. type CheckFunc func(context.Context, Request) (bool, error) -// Rule holds immutable private configuration. Copies may be shared concurrently. -// The zero value is unconfigured and never allows. Construction performs no I/O -// and never invokes callbacks. A rule is limited to depth 32 (a leaf has depth 1) -// and 1,024 expanded nodes, counting each occurrence of a shared subtree. +// Rule holds immutable private configuration. +// Copies may be shared concurrently. +// The zero value is unconfigured and never allows. +// Construction performs no I/O and never invokes callbacks. +// A rule is limited to depth 32 (a leaf has depth 1) and 1,024 expanded nodes, counting each occurrence of a shared subtree. type Rule struct { node *node } @@ -60,8 +65,9 @@ type node struct { nodes int } -// Check constructs a rule from a nonnil callback. A nil callback returns -// ErrInvalidConfiguration. Reusing a check does not deduplicate its evaluation. +// Check constructs a rule from a nonnil callback. +// A nil callback returns ErrInvalidConfiguration. +// Reusing a check does not deduplicate its evaluation. func Check(check CheckFunc) (Rule, error) { if check == nil { return Rule{}, ErrInvalidConfiguration @@ -69,15 +75,18 @@ func Check(check CheckFunc) (Rule, error) { return Rule{node: &node{op: opCheck, check: check, depth: 1, nodes: 1}}, nil } -// All requires every child to match, evaluating left to right. It stops on the -// first nonmatch or error. Empty, unconfigured or oversized trees return -// ErrInvalidConfiguration. The input slice is copied. +// All requires every child to match, evaluating left to right. +// It stops on the first nonmatch or error. +// Empty, unconfigured or oversized trees return ErrInvalidConfiguration. +// The input slice is copied. func All(rules ...Rule) (Rule, error) { return combine(opAll, rules) } -// Any requires one child to match, evaluating left to right. It stops on the -// first match or error; an error never falls through to another permission path. -// Empty, unconfigured or oversized trees return ErrInvalidConfiguration. The -// input slice is copied. Mandatory guards belong in an outer All. +// Any requires one child to match, evaluating left to right. +// It stops on the first match or error; +// an error never falls through to another permission path. +// Empty, unconfigured or oversized trees return ErrInvalidConfiguration. +// The input slice is copied. +// Mandatory guards belong in an outer All. func Any(rules ...Rule) (Rule, error) { return combine(opAny, rules) } func combine(op operation, rules []Rule) (Rule, error) { @@ -97,12 +106,13 @@ func combine(op operation, rules []Rule) (Rule, error) { return Rule{node: n}, nil } -// Authorize returns nil only for an explicit allow. It checks rule/context -// configuration, cancellation, request validity and a nonanonymous caller in -// that order. No check runs for an anonymous or invalid request. Nonmatches -// return ErrDenied; all callback errors are sanitized to ErrUnavailable. -// Cancellation is checked between nodes and before returning, and returns -// ctx.Err(), never context.Cause. No results are cached or installed in context. +// Authorize returns nil only for an explicit allow. +// It checks rule/context configuration, cancellation, request validity and a nonanonymous caller in that order. +// No check runs for an anonymous or invalid request. +// Nonmatches return ErrDenied; +// all callback errors are sanitized to ErrUnavailable. +// Cancellation is checked between nodes and before returning, and returns ctx.Err(), never context.Cause. +// No results are cached or installed in context. func (r Rule) Authorize(ctx context.Context, request Request) error { if r.node == nil || ctx == nil { return ErrInvalidConfiguration diff --git a/authz/rule_test.go b/authz/rule_test.go index e9ef3ab..ecb784f 100644 --- a/authz/rule_test.go +++ b/authz/rule_test.go @@ -72,7 +72,8 @@ func TestAuthorizeValidationOrder(t *testing.T) { if calls != 0 { t.Fatalf("invalid requests invoked callback %d times", calls) } - // authz preserves application strings; it does not impose the action header grammar. + // authz preserves application strings; + // it does not impose the action header grammar. valid.Action, valid.Resource = " Read 日本語 ", "" if err := r.Authorize(t.Context(), valid); err != nil || calls != 1 { t.Fatalf("valid UTF-8 request = %v, calls=%d", err, calls) diff --git a/buffered.go b/buffered.go index 95208f2..6ae367c 100644 --- a/buffered.go +++ b/buffered.go @@ -10,9 +10,10 @@ import ( var errInformationalResponse = invocationError("response", ErrResponse, "informational response is unsupported", http.ErrNotSupported) -// bufferedWriter belongs to one handler invocation. Only Header, WriteHeader -// and Write form its HTTP surface; it deliberately exposes no network or -// streaming capabilities. The owning call must finish it after ServeHTTP returns. +// bufferedWriter belongs to one handler invocation. +// Only Header, WriteHeader and Write form its HTTP surface; +// it deliberately exposes no network or streaming capabilities. +// The owning call must finish it after ServeHTTP returns. type bufferedWriter struct { header http.Header headers *responseHeaders @@ -101,10 +102,9 @@ func responseBodyAllowed(status int) bool { return status != http.StatusNoContent && status != http.StatusResetContent && status != http.StatusNotModified } -// finish freezes transport output without exposing the mutable application -// header map. Errors discard the response, even if the handler ignored Write's -// failure. Header-map changes after commitment are ignored except for trailers, -// which net/http would otherwise send after the committed response. +// finish freezes transport output without exposing the mutable application header map. +// Errors discard the response, even if the handler ignored Write's failure. +// Header-map changes after commitment are ignored except for trailers, which net/http would otherwise send after the committed response. func (w *bufferedWriter) finish() (*bufferedResponse, error) { if w.status == 0 { w.WriteHeader(http.StatusOK) @@ -138,8 +138,10 @@ func (w *bufferedWriter) finish() (*bufferedResponse, error) { return &bufferedResponse{status: w.status, headers: w.headers, body: body}, nil } -// bufferedResponse owns the finalized response. The writer is no longer usable. -// Projections retain the JSON-free typed boundary; marshalResponse is separate. +// bufferedResponse owns the finalized response. +// The writer is no longer usable. +// Projections retain the JSON-free typed boundary; +// marshalResponse is separate. type bufferedResponse struct { status int headers *responseHeaders @@ -166,8 +168,8 @@ func (r *bufferedResponse) v2() (events.APIGatewayV2HTTPResponse, error) { return events.APIGatewayV2HTTPResponse{StatusCode: r.status, Headers: headers, Cookies: cookies, Body: body, IsBase64Encoded: binary}, nil } -// serveBuffered runs inside the caller's invocation scope. Capturing the method -// first prevents a handler's Request mutation from changing HEAD semantics. +// serveBuffered runs inside the caller's invocation scope. +// Capturing the method first prevents a handler's Request mutation from changing HEAD semantics. // Panics propagate to that scope so cleanup precedes runtime panic handling. func serveBuffered(handler http.Handler, r *http.Request, headerBudget int) (*bufferedResponse, error) { w := newBufferedWriter(r.Method, headerBudget) diff --git a/decode.go b/decode.go index 4350019..eb31bd8 100644 --- a/decode.go +++ b/decode.go @@ -8,9 +8,10 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// decodedEvent contains exactly one request on success. Authorizer JSON belongs -// to this value, independently of the input buffer; it is not decoded into the -// SDK's string/float64 claim representations. No identity is established here. +// decodedEvent contains exactly one request on success. +// Authorizer JSON belongs to this value, independently of the input buffer; +// it is not decoded into the SDK's string/float64 claim representations. +// No identity is established here. type decodedEvent struct { version string v1 *events.APIGatewayProxyRequest @@ -19,15 +20,14 @@ type decodedEvent struct { } func decodeEvent(payload []byte) (decodedEvent, error) { - // This small first pass validates all JSON syntax, including unknown and - // opaque fields, and retains presence where SDK scalar zero values cannot. + // This small first pass validates all JSON syntax, including unknown and opaque fields, and retains presence where SDK scalar zero values cannot. var envelope *struct { Version jsontext.Value `json:"version"` IsBase64Encoded jsontext.Value `json:"isBase64Encoded"` } if err := json.Unmarshal(payload, &envelope); err != nil { - // JSON errors can contain object names and input values. Do not expose - // them, even through an unwrap chain, at this credential-bearing boundary. + // JSON errors can contain object names and input values. + // Do not expose them, even through an unwrap chain, at this credential-bearing boundary. return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "invalid event JSON") } if envelope == nil { @@ -113,8 +113,8 @@ func validateV2(event events.APIGatewayV2HTTPRequest) error { } // Optional collections may be null, but their string elements must be strings. -// Scope this rule to collections: AWS also sends null optional scalar metadata -// and body fields. Options and unmarshaler lists are immutable after creation. +// Scope this rule to collections: AWS also sends null optional scalar metadata and body fields. +// Options and unmarshaler lists are immutable after creation. var eventStringOptions = json.WithUnmarshalers(json.JoinUnmarshalers( json.UnmarshalFunc(func(data []byte, out *map[string]string) error { return json.Unmarshal(data, out, nonNullStringOptions) diff --git a/decode_test.go b/decode_test.go index 6ac3356..b4012f9 100644 --- a/decode_test.go +++ b/decode_test.go @@ -12,8 +12,7 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// Decoder tests exercise the accepted wire contract directly while the public -// Invoke and typed HTTP entry points await their translation contract. +// Decoder tests exercise the accepted wire contract directly while the public Invoke and typed HTTP entry points await their translation contract. const minimalV1 = `{"httpMethod":"GET","path":"/","requestContext":{"apiId":"example"}}` const minimalV2 = `{"version":"2.0","rawPath":"/","requestContext":{"apiId":"example","http":{"method":"GET"}}}` diff --git a/docs/authn.md b/docs/authn.md index 2d96319..18a200d 100644 --- a/docs/authn.md +++ b/docs/authn.md @@ -1,16 +1,14 @@ # HTTP authentication -`authn` is an implemented, SDK-independent HTTP authentication selector. It -chooses exactly one configured Bearer or EdgeIAM verifier, checks the returned -caller and, when used as middleware, installs it in the request context. It works -with ordinary Go HTTP servers and the root edge adapter's raw/typed entry points. - -`authn.NewCognitoVerifier` implements local RS256 Cognito access-token verification -with direct JSON v2 and bounded JWKS caching. Supply its Verify method, another -fully verifying Bearer function, IAM proofs alone, or both mechanisms. See the -[Cognito guide](cognito.md) for issuer/client/audience restrictions and cache -ownership. The implemented [authz rules](authz.md) authorize explicit caller, -action and resource facts; applications own their dispatch and grant resolution. +`authn` is an implemented, SDK-independent HTTP authentication selector. +It chooses exactly one configured Bearer or EdgeIAM verifier, checks the returned caller and, when used as middleware, installs it in the request context. +It works with ordinary Go HTTP servers and the root edge adapter's raw/typed entry points. + +`authn.NewCognitoVerifier` implements local RS256 Cognito access-token verification with direct JSON v2 and bounded JWKS caching. +Supply its Verify method, another fully verifying Bearer function, IAM proofs alone, or both mechanisms. +See the [Cognito guide](cognito.md) for issuer/client/audience restrictions and cache ownership. +The implemented [authz rules](authz.md) authorize explicit caller, action and resource facts; +applications own their dispatch and grant resolution. Default gateway identity remains disabled. ## Configure both mechanisms @@ -46,60 +44,63 @@ if err != nil { lambda.Start(adapter) ``` -The IAM error adapter above is required: passing `proofVerifier.Verify` directly -leaves its package-specific rejection unclassified and results in 503 instead -of 401. This explicit mapping lets JWT-only applications use authn without -importing the AWS SDK. No dependency versions changed for this package. - -`verifyBearer` has signature -`func(context.Context, string) (identity.Caller, error)`. It receives the exact -credential without the scheme. It must validate the intended issuer, signature, -time and client/audience policy before constructing a JWT caller with -SourceLocallyVerifiedToken. Parsing a JWT or calling identity.NewJWT is not -verification. Return authn.ErrInvalidCredentials only for a definite credential -rejection; authn.ErrUnavailable or unclassified errors become 503. No raw provider -diagnostics are retained in the selector's returned error. - -The IAM function must return KindIAM/SourceVerifiedIAMProof. Bearer must return -KindJWT/SourceLocallyVerifiedToken. Anonymous, gateway/custom, or wrong-kind -success is a verifier contract failure, not client rejection. All functions must -be concurrency-safe when their Authenticator is shared. - -Omitting either function disables that scheme. Both omitted is a configuration -error. New copies configuration and performs no I/O. Realm defaults to `edge` -and is only a challenge label; it does not configure proof audience or JWT trust. +The IAM error adapter above is required: passing `proofVerifier.Verify` directly leaves its package-specific rejection unclassified and results in 503 instead of 401. +This explicit mapping lets JWT-only applications use authn without importing the AWS SDK. +No dependency versions changed for this package. + +`verifyBearer` has signature `func(context.Context, string) (identity.Caller, error)`. +It receives the exact credential without the scheme. +It must validate the intended issuer, signature, time and client/audience policy before constructing a JWT caller with SourceLocallyVerifiedToken. +Parsing a JWT or calling identity.NewJWT is not verification. +Return authn.ErrInvalidCredentials only for a definite credential rejection; +authn.ErrUnavailable or unclassified errors become 503. +No raw provider diagnostics are retained in the selector's returned error. + +The IAM function must return KindIAM/SourceVerifiedIAMProof. +Bearer must return KindJWT/SourceLocallyVerifiedToken. +Anonymous, gateway/custom, or wrong-kind success is a verifier contract failure, not client rejection. +All functions must be concurrency-safe when their Authenticator is shared. + +Omitting either function disables that scheme. +Both omitted is a configuration error. +New copies configuration and performs no I/O. +Realm defaults to `edge` and is only a challenge label; +it does not configure proof audience or JWT trust. Explicit realms allow up to 256 printable ASCII bytes and are escaped safely. ## Request selection -Only Authorization establishes credentials. Cookies, query parameters, bodies -and alternate headers are not examined as authentication sources. The parser -checks all case-equivalent Authorization map keys, rejects repeated values even -if identical, and rejects comma-combined credentials. It does not try a second -verifier after any failure. - -Schemes are case-insensitive. Outer spaces/tabs are trimmed; the separator must -be one or more spaces. Token bytes and case are preserved. Configured schemes -require token68-shaped header credentials. Bearer contents are not decoded to -select a verifier. The IAM verifier applies its stricter proof format afterward. - -MaxAuthorizationBytes defaults to 16 KiB, counting represented field-value bytes -before trimming. An explicit value must be 1 byte through 1 MiB; zero requests the -default. This is separate from the IAM proof's fixed 8 KiB credential limit and -from gateway/server limits on the whole request. Earlier proxies can discard -duplicate information; no middleware can reconstruct it afterward. - -An already established caller produces identity.ErrConflict before verifier -work. Do not place this local authentication middleware behind an enabled gateway -producer for the same route. There is no implicit skip, merge or replacement. +Only Authorization establishes credentials. +Cookies, query parameters, bodies and alternate headers are not examined as authentication sources. +The parser checks all case-equivalent Authorization map keys, rejects repeated values even if identical, and rejects comma-combined credentials. +It does not try a second verifier after any failure. + +Schemes are case-insensitive. +Outer spaces/tabs are trimmed; +the separator must be one or more spaces. +Token bytes and case are preserved. +Configured schemes require token68-shaped header credentials. +Bearer contents are not decoded to select a verifier. +The IAM verifier applies its stricter proof format afterward. + +MaxAuthorizationBytes defaults to 16 KiB, counting represented field-value bytes before trimming. +An explicit value must be 1 byte through 1 MiB; +zero requests the default. +This is separate from the IAM proof's fixed 8 KiB credential limit and from gateway/server limits on the whole request. +Earlier proxies can discard duplicate information; +no middleware can reconstruct it afterward. + +An already established caller produces identity.ErrConflict before verifier work. +Do not place this local authentication middleware behind an enabled gateway producer for the same route. +There is no implicit skip, merge or replacement. ## Errors and HTTP customization -Authenticate(ctx, headers) returns one caller without installing it or writing a -response. Failures return an anonymous zero caller. Inspect categories with -errors.Is and HTTP details with errors.AsType[*authn.Error]. Challenges returns -an owned copy. Caller cancellation/deadline returns ctx.Err directly, without -exposing context.Cause. +Authenticate(ctx, headers) returns one caller without installing it or writing a response. +Failures return an anonymous zero caller. +Inspect categories with errors.Is and HTTP details with errors.AsType[*authn.Error]. +Challenges returns an owned copy. +Caller cancellation/deadline returns ctx.Err directly, without exposing context.Cause. | Category | Default HTTP response | | --- | --- | @@ -111,47 +112,44 @@ exposing context.Cause. | ErrVerifierContract / identity.ErrConflict | 500; no challenge | | Caller cancellation/deadline | Best-effort 503; no challenge | -An Error chain contains only its sanitized category. A nonnil verifier error -discards any returned caller. Unavailable takes precedence over invalid -credentials when an error matches both. An internal timeout with a live request -context is unavailable, not caller cancellation. +An Error chain contains only its sanitized category. +A nonnil verifier error discards any returned caller. +Unavailable takes precedence over invalid credentials when an error matches both. +An internal timeout with a live request context is unavailable, not caller cancellation. -Handler returns fixed generic text and Cache-Control: no-store on failure, -replaces WWW-Authenticate and preserves unrelated response headers such as CORS. -It does not log, add Retry-After or include detailed error descriptions. Request -cancellation prevents the next handler from running; delivery of the 503 is not -guaranteed. Edge still rejects a usable response for a canceled invocation. +Handler returns fixed generic text and Cache-Control: no-store on failure, replaces WWW-Authenticate and preserves unrelated response headers such as CORS. +It does not log, add Retry-After or include detailed error descriptions. +Request cancellation prevents the next handler from running; +delivery of the 503 is not guaranteed. +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. +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. -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. +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. ## Action dispatch and deployment Select an action once using edge.ActionHeader or your own header processor. -Authorize that caller for that exact selection, then execute it. Both IAM and -JWT callers can reach the same dispatcher. Successful IAM authentication does -not permit every AWS identity to execute your actions; deny by default. +Authorize that caller for that exact selection, then execute it. +Both IAM and JWT callers can reach the same dispatcher. +Successful IAM authentication does not permit every AWS identity to execute your actions; +deny by default. -The [authorization dispatcher example](../authz/example_test.go) pairs each rule -with its handler, keeps the selected resource consistent and maps 403 versus 503 -without exposing policy diagnostics. It configures production verifier types. +The [authorization dispatcher example](../authz/example_test.go) pairs each rule with its handler, keeps the selected resource consistent and maps 403 versus 503 without exposing policy diagnostics. +It configures production verifier types. Complete authentication and authorization before writing or flushing a response. The caller is a snapshot and is not automatically refreshed during a stream. Application-specific ongoing authorization remains explicit. -Protect only intended routes. Public routes omit Handler. There is no automatic -OPTIONS bypass: configure CORS/preflight at API Gateway or in outer middleware. -Deploy over HTTPS; r.TLS need not be populated behind API Gateway, and forwarded -scheme headers do not themselves establish trust. - -Local tests cover native in-memory HTTP, raw REST/HTTP API payloads and typed -V1/V2 methods, including challenge combination and actual IAM helper/verifier -composition against synthetic STS responses, plus actual RS256 verification -against local JWKS fixtures. No live AWS service or deployment -was used. [Decision 0019](decisions/0019-http-authentication-selector.md) records -the accepted contract and its official documentation sources. +Protect only intended routes. +Public routes omit Handler. +There is no automatic OPTIONS bypass: configure CORS/preflight at API Gateway or in outer middleware. +Deploy over HTTPS; +r.TLS need not be populated behind API Gateway, and forwarded scheme headers do not themselves establish trust. + +Local tests cover native in-memory HTTP, raw REST/HTTP API payloads and typed V1/V2 methods, including challenge combination and actual IAM helper/verifier composition against synthetic STS responses, plus actual RS256 verification against local JWKS fixtures. +No live AWS service or deployment was used. +[Decision 0019](decisions/0019-http-authentication-selector.md) records the accepted contract and its official documentation sources. diff --git a/docs/authz.md b/docs/authz.md index 025c0c1..da2d818 100644 --- a/docs/authz.md +++ b/docs/authz.md @@ -1,23 +1,22 @@ # Application authorization -`authz` checks an authenticated caller's permission to perform an application -action on an optional resource. It is implemented with only standard-library and -`identity` dependencies. It does not authenticate credentials, evaluate AWS IAM -policies, resolve grants automatically or write HTTP responses. - -The [runnable examples](../authz/example_test.go) show an application grant check -and a header-selected HTTP dispatcher with Cognito and IAM-proof authentication. -The dispatcher example configures real verifiers but sends no credentials, so -running it makes no network calls. Integration tests separately exercise signed -Cognito tokens and production IAM-proof verification against synthetic JWKS/STS -responses, then authorize requests across native HTTP and raw/typed Gateway -entry points. No live AWS interoperability test has run. +`authz` checks an authenticated caller's permission to perform an application action on an optional resource. +It is implemented with only standard-library and `identity` dependencies. +It does not authenticate credentials, evaluate AWS IAM policies, resolve grants automatically or write HTTP responses. + +The [runnable examples](../authz/example_test.go) show an application grant check and a header-selected HTTP dispatcher with Cognito and IAM-proof authentication. +The dispatcher example configures real verifiers but sends no credentials, so running it makes no network calls. +Integration tests separately exercise signed Cognito tokens and production IAM-proof verification against synthetic JWKS/STS responses, then authorize requests across native HTTP and raw/typed Gateway entry points. +No live AWS interoperability test has run. ## Construct rules once -Every constructor returns `(authz.Rule, error)`. Handle configuration errors -before serving requests. Rules are immutable; value copies share private nodes. -Constructor slices are copied. Custom callbacks must be safe for concurrent use. +Every constructor returns `(authz.Rule, error)`. +Handle configuration errors before serving requests. +Rules are immutable; +value copies share private nodes. +Constructor slices are copied. +Custom callbacks must be safe for concurrent use. ```go scopes, err := authz.JWTScopes(issuer, "orders.read") @@ -45,11 +44,13 @@ facts := authz.Request{ err := permission.Authorize(r.Context(), facts) ``` -Only nil means allow. Action must be nonempty UTF-8; Resource is optional UTF-8. -Neither is trimmed, case-folded or interpreted as a path, URL or ARN. The action -header parser has its own stricter HTTP-token grammar. A resource-aware check -must reject an absent resource. The core uses `Request.Caller` and does not read -a second identity from context or install authorization state there. +Only nil means allow. +Action must be nonempty UTF-8; +Resource is optional UTF-8. +Neither is trimmed, case-folded or interpreted as a path, URL or ARN. +The action header parser has its own stricter HTTP-token grammar. +A resource-aware check must reject an absent resource. +The core uses `Request.Caller` and does not read a second identity from context or install authorization state there. ## Exact predicates @@ -63,47 +64,52 @@ a second identity from context or install authorization state there. | `IAMPrincipal(arn)` | One full, supported caller ARN | | `IAMRoleSessions(partition, accountID, roleName)` | Any valid session of that exact role name/account/partition | -Identifiers must be nonempty UTF-8. Source and collection lists must be nonempty; -SourceNone and unknown sources are invalid. Scopes follow RFC 6749's scope-token -grammar. Duplicate required values do not add requirements. All comparisons are -case-sensitive. Use `Any` of separate rules for alternative scope/group choices. +Identifiers must be nonempty UTF-8. +Source and collection lists must be nonempty; +SourceNone and unknown sources are invalid. +Scopes follow RFC 6749's scope-token grammar. +Duplicate required values do not add requirements. +All comparisons are case-sensitive. +Use `Any` of separate rules for alternative scope/group choices. Wrong caller kinds, missing claims and unavailable collections do not match. -Flattened gateway groups or `scp` text are never split or reparsed. Dedicated -gateway scopes can be used when supplied through the identity producer. A scope -named `orders/*` matches that literal value, with no wildcard expansion. Cognito -groups, OAuth scopes and server-resolved application grants remain separate facts. - -JWT predicates are always issuer-qualified. Subject does not imply a human; -client_id does not imply a machine grant and is not an audience. Predicates do -not check signature, expiry, audience, token_use or revocation themselves. The -configured authentication producer must establish those guarantees. - -Predicates accept every supported producer of their matching kind. An outer -`All(Sources(...), permission)` can restrict provenance. Sources alone is a broad -allow for those producers. Source is trusted-code attribution: constructing an -identity with a source label does not establish credential possession. - -`IAMPrincipal` uses `identity.NewIAM`'s caller grammar. A root ARN allows only -that root caller, not its whole account. An assumed-role ARN includes its exact -session name. Literal `*` and `?` in valid IAM user paths remain literal. Bare -IAM role ARNs are invalid caller forms. - -`IAMRoleSessions` deliberately broadens matching to a role's sessions. It rejects -role paths and wildcard configuration. It preserves the caller's full session -ARN without inventing an IAM role ARN. This is a **role-name policy**: deleting -and recreating the role with the same name can match again. It does not bind a -unique role ID, identify the original actor, evaluate session policies or prove -execute-api permission. Lifecycle-sensitive enrollment needs a consumer-owned -stable identifier and resolution policy; PrincipalID remains opaque. +Flattened gateway groups or `scp` text are never split or reparsed. +Dedicated gateway scopes can be used when supplied through the identity producer. +A scope named `orders/*` matches that literal value, with no wildcard expansion. +Cognito groups, OAuth scopes and server-resolved application grants remain separate facts. + +JWT predicates are always issuer-qualified. +Subject does not imply a human; +client_id does not imply a machine grant and is not an audience. +Predicates do not check signature, expiry, audience, token_use or revocation themselves. +The configured authentication producer must establish those guarantees. + +Predicates accept every supported producer of their matching kind. +An outer `All(Sources(...), permission)` can restrict provenance. +Sources alone is a broad allow for those producers. +Source is trusted-code attribution: constructing an identity with a source label does not establish credential possession. + +`IAMPrincipal` uses `identity.NewIAM`'s caller grammar. +A root ARN allows only that root caller, not its whole account. +An assumed-role ARN includes its exact session name. +Literal `*` and `?` in valid IAM user paths remain literal. +Bare IAM role ARNs are invalid caller forms. + +`IAMRoleSessions` deliberately broadens matching to a role's sessions. +It rejects role paths and wildcard configuration. +It preserves the caller's full session ARN without inventing an IAM role ARN. +This is a **role-name policy**: deleting and recreating the role with the same name can match again. +It does not bind a unique role ID, identify the original actor, evaluate session policies or prove execute-api permission. +Lifecycle-sensitive enrollment needs a consumer-owned stable identifier and resolution policy; +PrincipalID remains opaque. ## Ordered composition and application grants -`All` stops at the first nonmatch or error. `Any` stops at the first match or -error. An error never becomes a nonmatch followed by another permission path. -Thus `Any(allow, unavailable)` allows without running the second check, while -`Any(unavailable, allow)` fails without running the second check. Ordering can -change dependency work and outcomes. +`All` stops at the first nonmatch or error. +`Any` stops at the first match or error. +An error never becomes a nonmatch followed by another permission path. +Thus `Any(allow, unavailable)` allows without running the second check, while `Any(unavailable, allow)` fails without running the second check. +Ordering can change dependency work and outcomes. Put mandatory application guards outside alternative paths: @@ -111,8 +117,8 @@ Put mandatory application guards outside alternative paths: All(mandatoryApplicationGuard, Any(jwtPermissionPath, iamPermissionPath)) ``` -Within a branch, put cheap identity predicates before its provider lookup. A -custom check receives the caller context and explicit action/resource: +Within a branch, put cheap identity predicates before its provider lookup. +A custom check receives the caller context and explicit action/resource: ```go grant, err := authz.Check(func(ctx context.Context, req authz.Request) (bool, error) { @@ -123,41 +129,41 @@ grant, err := authz.Check(func(ctx context.Context, req authz.Request) (bool, er }) ``` -The application chooses that store and its enrollment keys. Use issuer plus -subject for subject identity, or explicitly issuer plus client_id for a supported -client identity. Do not silently fall back from missing subject to a user named -by client_id, or use email, account or session name as an inferred global key. +The application chooses that store and its enrollment keys. +Use issuer plus subject for subject identity, or explicitly issuer plus client_id for a supported client identity. +Do not silently fall back from missing subject to a user named by client_id, or use email, account or session name as an inferred global key. Resolve tenant membership, suspension and resource grants in one logical check. -Known unregistered/suspended/unauthorized callers return false, nil. A dependency -that cannot decide returns an error. Never obtain grants from client-supplied -headers or inject them into authenticated claims. - -If execution needs a resolved object, resolve it once before the policy and keep -the immutable result in the application's own execution plan. The core provides -no automatic grant model, cache or deduplication. A reused check can run once per -evaluated occurrence. It is not an audit callback. Checks run synchronously with -the caller context; applications own dependency timeouts and must honor -cancellation. Programmer panics propagate. - -Empty All/Any and zero child rules are invalid. The maximum depth is 32 with a -leaf at depth 1. A rule can contain at most 1,024 expanded nodes, counting every -occurrence of a shared subtree. Constructors reject excess work before evaluation. +Known unregistered/suspended/unauthorized callers return false, nil. +A dependency that cannot decide returns an error. +Never obtain grants from client-supplied headers or inject them into authenticated claims. + +If execution needs a resolved object, resolve it once before the policy and keep the immutable result in the application's own execution plan. +The core provides no automatic grant model, cache or deduplication. +A reused check can run once per evaluated occurrence. +It is not an audit callback. +Checks run synchronously with the caller context; +applications own dependency timeouts and must honor cancellation. +Programmer panics propagate. + +Empty All/Any and zero child rules are invalid. +The maximum depth is 32 with a leaf at depth 1. +A rule can contain at most 1,024 expanded nodes, counting every occurrence of a shared subtree. +Constructors reject excess work before evaluation. These are policy configuration bounds, not AWS quotas or limits on callback work. -There is no Not, anonymous allow rule, retry or parallel evaluation. Public routes -intentionally bypass protected-route authorization. +There is no Not, anonymous allow rule, retry or parallel evaluation. +Public routes intentionally bypass protected-route authorization. ## Outcomes and HTTP dispatch -Authorize checks rule/non-nil context configuration, cancellation, request -validity and nonanonymous identity in that order, then evaluates checks. Anonymous -callers never reach a custom callback, even a permissive one. Cancellation is -checked between nodes and before returning; it returns `ctx.Err()`, never the -private cancellation cause. It cannot preempt arbitrary callback code. +Authorize checks rule/non-nil context configuration, cancellation, request validity and nonanonymous identity in that order, then evaluates checks. +Anonymous callers never reach a custom callback, even a permissive one. +Cancellation is checked between nodes and before returning; +it returns `ctx.Err()`, never the private cancellation cause. +It cannot preempt arbitrary callback code. -Every nonnil callback error becomes `ErrUnavailable`, even if the callback returns -true, a package sentinel such as ErrDenied, or an internal context error while -the caller context remains live. Return false, nil for denial. Public errors -contain no callback error chain or caller/action/resource diagnostics. +Every nonnil callback error becomes `ErrUnavailable`, even if the callback returns true, a package sentinel such as ErrDenied, or an internal context error while the caller context remains live. +Return false, nil for denial. +Public errors contain no callback error chain or caller/action/resource diagnostics. | Result (`errors.Is`) | Consumer HTTP treatment | | --- | --- | @@ -168,25 +174,23 @@ contain no callback error chain or caller/action/resource diagnostics. | `ErrInvalidConfiguration`, `ErrInvalidRequest` | 500; reject malformed client input earlier with an appropriate 4xx | | Caller cancellation | Never execute; best-effort 503 if transport still permits a response | -For denial on a route configured to accept Bearer, a generic -`WWW-Authenticate: Bearer realm="orders"` avoids exposing the reason. Only use -`insufficient_scope` and a scope hint when that is accurately the requirement. -Do not describe every group, tenant or IAM denial as an OAuth scope error. The -example includes a generic Bearer challenge because its route accepts Bearer, -including when the current caller used IAM. The core does not infer a wire -authentication scheme from Caller.Kind. Gateway-only routes own their configured -challenge policy. Preserve CORS, use safe error codes and no-store responses. +For denial on a route configured to accept Bearer, a generic `WWW-Authenticate: Bearer realm="orders"` avoids exposing the reason. +Only use `insufficient_scope` and a scope hint when that is accurately the requirement. +Do not describe every group, tenant or IAM denial as an OAuth scope error. +The example includes a generic Bearer challenge because its route accepts Bearer, including when the current caller used IAM. +The core does not infer a wire authentication scheme from Caller.Kind. +Gateway-only routes own their configured challenge policy. +Preserve CORS, use safe error codes and no-store responses. The consumer owns an immutable registry entry pairing a rule with its handler. -Parse the action header once, select once, resolve and validate the target, then -authorize and execute that same entry/target. Unknown actions have no unprotected -fallback. Do not reread mutable headers for policy or execution. Resource version -and transaction consistency remain the application's responsibility. - -The same handler works through ordinary net/http, `edge.New(handler)` or -`edge.NewStreaming(handler)`. Complete authentication, target resolution and -authorization before response commitment or streaming flush. Ongoing stream -reauthorization remains application-owned; see the [streaming guide](streaming.md). - -See [decision 0021](decisions/0021-authorization-rules.md) for the approved design, -alternatives and official AWS/IETF evidence. +Parse the action header once, select once, resolve and validate the target, then authorize and execute that same entry/target. +Unknown actions have no unprotected fallback. +Do not reread mutable headers for policy or execution. +Resource version and transaction consistency remain the application's responsibility. + +The same handler works through ordinary net/http, `edge.New(handler)` or `edge.NewStreaming(handler)`. +Complete authentication, target resolution and authorization before response commitment or streaming flush. +Ongoing stream reauthorization remains application-owned; +see the [streaming guide](streaming.md). + +See [decision 0021](decisions/0021-authorization-rules.md) for the approved design, alternatives and official AWS/IETF evidence. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 1c6dc4c..b9dcaa2 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,8 +1,7 @@ # Performance baselines -Initial response-codec measurements on 2026-09-14: Go 1.27.1, darwin/arm64, -Apple M2. Command: `go test -run '^$' -bench '^BenchmarkMarshalResponse$' --benchmem -benchtime=200ms`. +Initial response-codec measurements on 2026-09-14: Go 1.27.1, darwin/arm64, Apple M2. +Command: `go test -run '^$' -bench '^BenchmarkMarshalResponse$' -benchmem -benchtime=200ms`. | Case | Time/op | Allocated bytes/op | Allocations/op | | --- | ---: | ---: | ---: | @@ -10,22 +9,20 @@ Apple M2. Command: `go test -run '^$' -bench '^BenchmarkMarshalResponse$' | Text body 1 KiB below 6 MiB | 5.71 ms | 18,875,672 | 11 | | NUL body whose JSON escaping exceeds envelope limit | 5.25 ms | 27,559,434 | 19 | -These are exploratory local samples, not CI thresholds or Lambda performance -claims. Allocated bytes/op measures cumulative allocations, not peak live memory. -The bounded destination retains at most 6 MiB, but JSON codec temporaries still -allocate. Do not advertise a 6 MiB total-process or peak-memory guarantee. +These are exploratory local samples, not CI thresholds or Lambda performance claims. +Allocated bytes/op measures cumulative allocations, not peak live memory. +The bounded destination retains at most 6 MiB, but JSON codec temporaries still allocate. +Do not advertise a 6 MiB total-process or peak-memory guarantee. -Raw/typed adapter comparisons, binary response benchmarks, and peak-memory -measurements follow when the complete response and invocation paths are wired. +Raw/typed adapter comparisons, binary response benchmarks, and peak-memory measurements follow when the complete response and invocation paths are wired. ## Response-header allocation probe -2026-09-15, Go 1.27.1, darwin/arm64, Apple M2. This is an isolated candidate -algorithm in [internal/headerprobe](../internal/headerprobe/probe_test.go), not -production edge behavior. It performs weighted preflight, preallocated key -sorting, canonicalization, merging and outer SP/HTAB trimming. It deliberately -omits syntax validation, hop-header filtering, automatic headers and transport -projection; the real writer will have additional costs. +2026-09-15, Go 1.27.1, darwin/arm64, Apple M2. +This is an isolated candidate algorithm in [internal/headerprobe](../internal/headerprobe/probe_test.go), not production edge behavior. +It performs weighted preflight, preallocated key sorting, canonicalization, merging and outer SP/HTAB trimming. +It deliberately omits syntax validation, hop-header filtering, automatic headers and transport projection; +the real writer will have additional costs. Commands (three samples each): @@ -48,50 +45,46 @@ Median benchmark results and approximate retained heap after GC: | One quote-filled value, near 6 MiB charge | 191 ns | 416 B | 416 B | | Empty values exceeding budget by one entry | 390 µs | 0 B | 0–112 B noise | -The smaller fixtures use the same algorithm and allocate inputs whose weighted -charge fits the named candidate budget. The prototype still enforces the original -6 MiB ceiling; it does not implement the proposed configurable production option. -Over-budget preflight rejection allocates no snapshot. The quote-filled value -shares immutable string storage with the still-live input; the tiny snapshot -allocation does not include a copy of its bytes. Unbounded JSON v2 encoding of -that snapshot alone produces 12,582,856 bytes, demonstrating why exact encoded -limits remain independent of the weighted charge. - -Memory samples keep both the original header map and snapshot alive across -measurement. Two garbage collections before and after clear previous cases' -sync.Pool buffers. Small deltas remain noisy. Reported retained heap excludes the -application's existing input and is not peak memory, total process memory or RSS. -Encoding happens outside the snapshot memory sample. The suppressed-name case -retains all suppression entries; production may eliminate some once automatic -header decisions finish. Timings are local samples, not Lambda latency claims. - -Preallocating the sorted key slice reduced cumulative allocation for the largest -distinct-name case from roughly 29.35 MB to 17.63 MB; retained map/slice costs -still dominate. This optimization alone does not make a 6 MiB accounting budget -an equivalent heap bound. - -Recommendation for review: use a 256 KiB weighted default and an explicit override -up to 6 MiB, preserving separate encoded limits. The lower default targets modest -additional metadata storage; its exact number is an engineering choice informed -by the measured 0.48 MiB retained/0.58 MiB cumulative high-cardinality case, not -an AWS quota or a proven universal memory bound. See -[decision 0010](decisions/0010-response-header-design.md). - -The isolated probe passes race detection, vet and formatting/diff checks. No -production adapter code changed, and no live AWS services were invoked. +The smaller fixtures use the same algorithm and allocate inputs whose weighted charge fits the named candidate budget. +The prototype still enforces the original 6 MiB ceiling; +it does not implement the proposed configurable production option. +Over-budget preflight rejection allocates no snapshot. +The quote-filled value shares immutable string storage with the still-live input; +the tiny snapshot allocation does not include a copy of its bytes. +Unbounded JSON v2 encoding of that snapshot alone produces 12,582,856 bytes, demonstrating why exact encoded limits remain independent of the weighted charge. + +Memory samples keep both the original header map and snapshot alive across measurement. +Two garbage collections before and after clear previous cases' sync.Pool buffers. +Small deltas remain noisy. +Reported retained heap excludes the application's existing input and is not peak memory, total process memory or RSS. +Encoding happens outside the snapshot memory sample. +The suppressed-name case retains all suppression entries; +production may eliminate some once automatic header decisions finish. +Timings are local samples, not Lambda latency claims. + +Preallocating the sorted key slice reduced cumulative allocation for the largest distinct-name case from roughly 29.35 MB to 17.63 MB; +retained map/slice costs still dominate. +This optimization alone does not make a 6 MiB accounting budget an equivalent heap bound. + +Recommendation for review: use a 256 KiB weighted default and an explicit override up to 6 MiB, preserving separate encoded limits. +The lower default targets modest additional metadata storage; +its exact number is an engineering choice informed by the measured 0.48 MiB retained/0.58 MiB cumulative high-cardinality case, not an AWS quota or a proven universal memory bound. +See [decision 0010](decisions/0010-response-header-design.md). + +The isolated probe passes race detection, vet and formatting/diff checks. +No production adapter code changed, and no live AWS services were invoked. ## Implemented response-header layer -After approval of decision 0010, repeated measurements against the actual -snapshot and projection functions on the same Go 1.27.1 / Apple M2 environment: +After approval of decision 0010, repeated measurements against the actual snapshot and projection functions on the same Go 1.27.1 / Apple M2 environment: ```sh go test -run '^TestResponseHeaderRetainedMemory$' -v -count=3 go test -run '^$' -bench '^BenchmarkResponseHeaders$' -benchmem -benchtime=200ms -count=3 ``` -Median cumulative allocations in bytes per operation; projection columns include -snapshot construction and independently owned projection storage: +Median cumulative allocations in bytes per operation; +projection columns include snapshot construction and independently owned projection storage: | Fixture | Snapshot only | Snapshot + V1 | Snapshot + V2 | Retained snapshot heap | | --- | ---: | ---: | ---: | ---: | @@ -99,23 +92,24 @@ snapshot construction and independently owned projection storage: | 6,553 distinct names near default 256 KiB charge | 607,557 | 1,106,130 | 937,527 | 498,648–498,760 | | 157,286 distinct names near explicit 6 MiB charge | 17,633,192 | 32,743,176 | 28,129,496 | 15,110,248–15,126,208 | -The default's high-cardinality retained snapshot remains about 0.48 MiB, matching -the candidate probe. Projections add allocations, especially with the largest -opt-in budget. Suppressed entries are omitted from output; projection maps are -sized for represented fields, not the number of suppression markers. +The default's high-cardinality retained snapshot remains about 0.48 MiB, matching the candidate probe. +Projections add allocations, especially with the largest opt-in budget. +Suppressed entries are omitted from output; +projection maps are sized for represented fields, not the number of suppression markers. -These helper benchmarks exclude the HTTP writer, body handling and final JSON -encoding. Compiler escape analysis can also differ once they are called through -the future public invocation path. Retained heap excludes the application input -and projections; it is not peak memory or RSS. Median snapshot timings were -approximately 1.36 µs, 1.27 ms and 52.17 ms respectively, with substantial timing -variation in the largest case. These are exploratory samples, not CI thresholds -or Lambda latency claims. They do not warrant changing the approved default. +These helper benchmarks exclude the HTTP writer, body handling and final JSON encoding. +Compiler escape analysis can also differ once they are called through the future public invocation path. +Retained heap excludes the application input and projections; +it is not peak memory or RSS. +Median snapshot timings were approximately 1.36 µs, 1.27 ms and 52.17 ms respectively, with substantial timing variation in the largest case. +These are exploratory samples, not CI thresholds or Lambda latency claims. +They do not warrant changing the approved default. ## Candidate claims storage (2026-09-16) -The isolated `internal/claimprobe` test package measures a candidate owned tree, -not an implemented identity API. Go 1.27.1, darwin/arm64, Apple M2; three samples: +The isolated `internal/claimprobe` test package measures a candidate owned tree, not an implemented identity API. +Go 1.27.1, darwin/arm64, Apple M2; +three samples: ```sh go test ./internal/claimprobe -run '^TestSnapshotMemory$' -v -count=3 @@ -135,37 +129,34 @@ Median snapshot results, with retained heap measured separately: | Large string | 262,144 | 23.3 µs | 262,902 | 4 | 262,912 | | 64 nested objects | 4,417 | 16.5 µs | 48,388 | 193 | 48,400 | -Depth 65, a cyclic map, an exponentially expanded shared subtree and an oversized -array all reject before snapshot allocation: 0 B/op and 0 allocs/op in these -samples. This excludes any eventual public error construction. Shared subtrees -are charged per occurrence, not per distinct pointer. Rejection of that fixture -took about 20.6 µs; the container-length fast rejection took about 46 ns. - -The separate lexical JSON scan used direct jsontext, including strict duplicate -name validation, a wire-length check, an object root and a depth bound. Median -cumulative allocations were 1,200 bytes for ordinary claims, 692,717 for distinct -names, 524,696 for a large string and 12,904 for depth 64. These are lexical scan -costs only: no full raw-JSON-to-owned-tree implementation exists yet. Do not add -the timings to predict production latency or call lexical allocation a heap cap. - -The tree copies map keys, strings, maps and slices. Inputs remain live across -memory samples. Two garbage collections before and after each snapshot reduce -pool retention; small deltas remain noisy. Retained heap excludes input storage, -transient allocation, normalized JWT views, event decoding, and process RSS. The -singleton-object case shows why a 256 KiB accounting limit can retain 1.58 MiB. +Depth 65, a cyclic map, an exponentially expanded shared subtree and an oversized array all reject before snapshot allocation: 0 B/op and 0 allocs/op in these samples. +This excludes any eventual public error construction. +Shared subtrees are charged per occurrence, not per distinct pointer. +Rejection of that fixture took about 20.6 µs; +the container-length fast rejection took about 46 ns. + +The separate lexical JSON scan used direct jsontext, including strict duplicate name validation, a wire-length check, an object root and a depth bound. +Median cumulative allocations were 1,200 bytes for ordinary claims, 692,717 for distinct names, 524,696 for a large string and 12,904 for depth 64. +These are lexical scan costs only: no full raw-JSON-to-owned-tree implementation exists yet. +Do not add the timings to predict production latency or call lexical allocation a heap cap. + +The tree copies map keys, strings, maps and slices. +Inputs remain live across memory samples. +Two garbage collections before and after each snapshot reduce pool retention; +small deltas remain noisy. +Retained heap excludes input storage, transient allocation, normalized JWT views, event decoding, and process RSS. +The singleton-object case shows why a 256 KiB accounting limit can retain 1.58 MiB. No sample establishes the worst possible amplification across all shapes. -The prototype covers a subset of the proposed typed inputs, using float64 for -decoded numbers. It does not implement exact numeric nodes, provenance, the -public getters, JWT validation, error translation, or dedicated scope accounting. +The prototype covers a subset of the proposed typed inputs, using float64 for decoded numbers. +It does not implement exact numeric nodes, provenance, the public getters, JWT validation, error translation, or dedicated scope accounting. Those require production measurements and contract tests after approval. -The SDK probe invokes a real local lambda.NewHandlerWithOptions around a typed -APIGatewayProxyRequest. For the literal 9007199254740993, default decoding yields -float64 9007199254740992; WithUseNumber(true) yields json.Number with the original -digits. This is evidence for data-type compatibility in -[decision 0014](decisions/0014-claims-api.md), not a proposal to use the v1 codec -inside edge. No live Lambda or API Gateway request was made. +The SDK probe invokes a real local lambda.NewHandlerWithOptions around a typed APIGatewayProxyRequest. +For the literal 9007199254740993, default decoding yields float64 9007199254740992; +WithUseNumber(true) yields json.Number with the original digits. +This is evidence for data-type compatibility in [decision 0014](decisions/0014-claims-api.md), not a proposal to use the v1 codec inside edge. +No live Lambda or API Gateway request was made. ## Implemented claims constructors (2026-09-16) @@ -186,32 +177,32 @@ Median bytes per operation and independently sampled retained heap in bytes: | Singleton objects, 256 KiB budget | 1,661,221 | 1,922,539 | 1,661,184 | 1,669,376 | | Large string, 256 KiB budget | 262,912 | 1,311,952 | 262,912 | 262,912 | -Ordinary typed construction took a median 1.24 µs and 30 allocations; ordinary -raw construction took 3.02 µs and 81 allocations. Raw construction includes strict -lexical validation and preflight, then owned-tree construction, while typed input -has no JSON round trip. Raw numbers retain their literal, while input float64 -values retain only the available floating-point value. Both paths clone strings. - -The largest opt-in raw case has substantial transient allocation (about 51 MiB -cumulative versus 12.3 MiB retained). Two parser passes, duplicate-name tracking, -string conversion/copying, and map growth contribute. These measurements do not -justify equating the 6 MiB weighted budget with memory use, or making it the -default. Future storage optimizations can retain the approved accounting policy. - -The raw and typed retained totals can differ because map construction capacity, -array growth and exact numeric storage differ. Inputs remain alive during memory -samples; two collections before and after reduce pool retention. Small deltas -are noisy (ordinary typed samples ranged from 2,696 to 8,016 bytes). No figure is -peak memory, RSS, total invocation allocation, or a Lambda latency prediction. +Ordinary typed construction took a median 1.24 µs and 30 allocations; +ordinary raw construction took 3.02 µs and 81 allocations. +Raw construction includes strict lexical validation and preflight, then owned-tree construction, while typed input has no JSON round trip. +Raw numbers retain their literal, while input float64 values retain only the available floating-point value. +Both paths clone strings. + +The largest opt-in raw case has substantial transient allocation (about 51 MiB cumulative versus 12.3 MiB retained). +Two parser passes, duplicate-name tracking, string conversion/copying, and map growth contribute. +These measurements do not justify equating the 6 MiB weighted budget with memory use, or making it the default. +Future storage optimizations can retain the approved accounting policy. + +The raw and typed retained totals can differ because map construction capacity, array growth and exact numeric storage differ. +Inputs remain alive during memory samples; +two collections before and after reduce pool retention. +Small deltas are noisy (ordinary typed samples ranged from 2,696 to 8,016 bytes). +No figure is peak memory, RSS, total invocation allocation, or a Lambda latency prediction. JWT normalized views and dedicated scopes are not implemented in these samples. ## Public buffered invocation (2026-09-16) -Go 1.27.1, darwin/arm64, Apple M2. These run the complete public adapter with -gateway identity disabled and a body-echo handler. Fixtures are created outside -the timed loop. Raw calls include event decoding and response JSON v2 encoding; -typed calls deliberately exclude upstream/downstream envelope codecs. They are -not an end-to-end SDK or Lambda performance comparison. +Go 1.27.1, darwin/arm64, Apple M2. +These run the complete public adapter with gateway identity disabled and a body-echo handler. +Fixtures are created outside the timed loop. +Raw calls include event decoding and response JSON v2 encoding; +typed calls deliberately exclude upstream/downstream envelope codecs. +They are not an end-to-end SDK or Lambda performance comparison. ```sh go test -run '^$' -bench '^BenchmarkAdapterPublic$' -benchmem -benchtime=100ms -count=3 . @@ -225,24 +216,26 @@ Median allocated bytes per invocation (three samples, not peak/live memory): | 64 KiB binary, base64 request/response | 777,999 | 776,900 | 322,514 | 322,258 | | 5 MiB text, near buffered envelope limit | 37,776,984 | 37,777,048 | 15,731,675 | 15,731,427 | -The small case used 65/48/35/33 allocations per invocation respectively. Large -raw envelopes incur substantial transient codec/copy costs despite bounded -output buffers. The 6 MiB wire limit is not a heap limit. These are initial local -baselines, without performance thresholds or Lambda latency claims. Short-run -timings vary with GC and host load; comparisons should use longer repeated runs. +The small case used 65/48/35/33 allocations per invocation respectively. +Large raw envelopes incur substantial transient codec/copy costs despite bounded output buffers. +The 6 MiB wire limit is not a heap limit. +These are initial local baselines, without performance thresholds or Lambda latency claims. +Short-run timings vary with GC and host load; +comparisons should use longer repeated runs. Initial three-iteration measurements included noticeable cold codec setup costs; -the table instead uses the repeated 100 ms runs above. Native identity costs are -additional and depend on claim shape and source fidelity. +the table instead uses the repeated 100 ms runs above. +Native identity costs are additional and depend on claim shape and source fidelity. ## Public incremental streaming (2026-09-21) -Go 1.27.1, darwin/arm64, Apple M2, default GOMAXPROCS=8. Three 100 ms samples per -case exercise the public REST adapter, including stream completion and Close. -The application reuses a chunk of at most 4 KiB, flushing after each write. The -consumer reuses an 8 KiB buffer and reads incrementally; it never collects the -body. Adapter/event construction, raw fixture encoding and reusable buffers are -outside the timed loop. Gateway identity and local authentication are disabled. +Go 1.27.1, darwin/arm64, Apple M2, default GOMAXPROCS=8. +Three 100 ms samples per case exercise the public REST adapter, including stream completion and Close. +The application reuses a chunk of at most 4 KiB, flushing after each write. +The consumer reuses an 8 KiB buffer and reads incrementally; +it never collects the body. +Adapter/event construction, raw fixture encoding and reusable buffers are outside the timed loop. +Gateway identity and local authentication are disabled. ```sh go test -run '^$' -bench '^BenchmarkStreamingPublic$' -benchmem -benchtime=100ms -count=3 . @@ -256,15 +249,14 @@ Medians across the three samples: | 64 KiB | 7,905 | 80 | 6,117 | 56 | | 5 MiB | 7,877 | 80 | 6,124 | 56 | -Raw calls include input decoding; typed calls exclude upstream input decoding. +Raw calls include input decoding; +typed calls exclude upstream input decoding. Both include metadata serialization, the producer/reader bridge and cleanup. -The roughly constant allocated bytes in this fixture are evidence that the -adapter does not accumulate its response body. They do not establish a universal -heap bound: retained application data, metadata, request bodies, authentication -and concurrency add costs. One 5 MiB raw sample measured 8,170 B/op and 81 -allocations, illustrating short-run scheduling/allocation noise. - -These are cumulative allocations, not peak memory or RSS, and not an SDK Runtime -API or deployed Lambda benchmark. The buffered body-echo fixtures above also -carry large request bodies; do not treat these as a controlled timing comparison -between modes. No performance thresholds or AWS bandwidth claims are inferred. +The roughly constant allocated bytes in this fixture are evidence that the adapter does not accumulate its response body. +They do not establish a universal heap bound: retained application data, metadata, request bodies, authentication and concurrency add costs. +One 5 MiB raw sample measured 8,170 B/op and 81 allocations, illustrating short-run scheduling/allocation noise. + +These are cumulative allocations, not peak memory or RSS, and not an SDK Runtime API or deployed Lambda benchmark. +The buffered body-echo fixtures above also carry large request bodies; +do not treat these as a controlled timing comparison between modes. +No performance thresholds or AWS bandwidth claims are inferred. diff --git a/docs/cognito.md b/docs/cognito.md index dcaa2d6..b8ca4c2 100644 --- a/docs/cognito.md +++ b/docs/cognito.md @@ -1,9 +1,8 @@ # Cognito access-token verification -`authn.NewCognitoVerifier` provides local RS256 verification using Go 1.27's -`encoding/json/v2`/`jsontext` and standard-library RSA. It returns an immutable -JWT caller with `SourceLocallyVerifiedToken`. It performs no AWS account API -calls and adds no SDK or JWT-library dependency. +`authn.NewCognitoVerifier` provides local RS256 verification using Go 1.27's `encoding/json/v2`/`jsontext` and standard-library RSA. +It returns an immutable JWT caller with `SourceLocallyVerifiedToken`. +It performs no AWS account API calls and adds no SDK or JWT-library dependency. ## Configure explicit issuer, clients and resource binding @@ -28,22 +27,24 @@ if err != nil { } ``` -Use `protected` with an ordinary HTTP server or `edge.New`. Include IAMProof -with the explicit error adapter in [the HTTP authentication guide](authn.md) to -accept IAM and Cognito credentials on the same route. Authentication must precede -action authorization and any response/stream commitment. Gateway identity stays -disabled for these routes; there is no merge or fallback between producers. - -Issuer is the exact configured Cognito pool issuer URL. Both original -`cognito-idp` and updated `issuer-cognito-idp` formats work. Supply an HTTPS URL -with a pool path and no trailing slash, escaped path, dot segments, credentials, -query or fragment. The library appends `/.well-known/jwks.json` to this trusted -configuration. It never uses a token's issuer or JOSE URLs to discover a server. +Use `protected` with an ordinary HTTP server or `edge.New`. +Include IAMProof with the explicit error adapter in [the HTTP authentication guide](authn.md) to accept IAM and Cognito credentials on the same route. +Authentication must precede action authorization and any response/stream commitment. +Gateway identity stays disabled for these routes; +there is no merge or fallback between producers. + +Issuer is the exact configured Cognito pool issuer URL. +Both original `cognito-idp` and updated `issuer-cognito-idp` formats work. +Supply an HTTPS URL with a pool path and no trailing slash, escaped path, dot segments, credentials, query or fragment. +The library appends `/.well-known/jwks.json` to this trusted configuration. +It never uses a token's issuer or JOSE URLs to discover a server. The constructor does not prove that your configured URL belongs to AWS. -Every token must be an access token from an explicitly listed client. ID tokens -are rejected. `client_id` identifies the app client; `aud` identifies the bound -resource. They are never interchangeable: +Every token must be an access token from an explicitly listed client. +ID tokens are rejected. +`client_id` identifies the app client; +`aud` identifies the bound resource. +They are never interchangeable: | Policy | Absent aud | Matching string aud | Other aud | | --- | --- | --- | --- | @@ -51,87 +52,95 @@ resource. They are never interchangeable: | Audience set, AllowUnbound true | Accept | Accept | Reject | | Audience empty, AllowUnbound true | Accept | Reject | Reject | -Audience empty with AllowUnbound false is invalid configuration. Null, empty or -array audiences fail verification. Missing sub is allowed when client_id is -valid; sub is not assumed to be a UUID or evidence of a particular grant flow. +Audience empty with AllowUnbound false is invalid configuration. +Null, empty or array audiences fail verification. +Missing sub is allowed when client_id is valid; +sub is not assumed to be a UUID or evidence of a particular grant flow. -Cognito resource binding is unavailable for client-credentials M2M grants and SDK -authentication models. AllowUnbound makes that exception explicit per client. -Prefer dedicated app clients and resource-specific scopes for APIs accepting -unbound tokens: other APIs trusting that same issuer/client could accept them. -Scopes and Cognito groups remain separate identity facts; your dispatcher must -authorize the exact selected action. A valid token alone grants nothing. +Cognito resource binding is unavailable for client-credentials M2M grants and SDK authentication models. +AllowUnbound makes that exception explicit per client. +Prefer dedicated app clients and resource-specific scopes for APIs accepting unbound tokens: other APIs trusting that same issuer/client could accept them. +Scopes and Cognito groups remain separate identity facts; +your dispatcher must authorize the exact selected action. +A valid token alone grants nothing. ## Lifecycle and key cache Construct once and reuse the verifier across concurrent requests/invocations. -Construction performs no I/O. `Warm(ctx)` optionally fetches keys before serving; -otherwise Verify fetches lazily. Warm is not a forced refresh or a token check. +Construction performs no I/O. +`Warm(ctx)` optionally fetches keys before serving; +otherwise Verify fetches lazily. +Warm is not a forced refresh or a token check. It returns immediately when a usable snapshot is fresh. -The cache stores only keys, never tokens or verification results. A successful -fetch atomically replaces the complete snapshot, including removal of omitted -keys. Known fresh keys verify locally even while another refresh is failing. +The cache stores only keys, never tokens or verification results. +A successful fetch atomically replaces the complete snapshot, including removal of omitted keys. +Known fresh keys verify locally even while another refresh is failing. Expired keys are never used during an outage. - CacheTTL defaults to 15 minutes, configurable from 1 minute through 24 hours. - It starts at successful fetch completion. No autonomous refresh loop runs. -- An unknown kid can trigger a fetch, with a 30-second cooldown from the last - success or unknown-key attempt. Random kid values do not grow a negative cache. + It starts at successful fetch completion. + No autonomous refresh loop runs. +- An unknown kid can trigger a fetch, with a 30-second cooldown from the last success or unknown-key attempt. + Random kid values do not grow a negative cache. - Failed fetches back off for 5, 10, 20, 40, then 60 seconds, capped at 60. - This includes cold-cache failures. Where both gates apply, the later wins. -- Concurrent callers share at most one fetch. Each call fetches/joins at most - once. A known-key signature failure never triggers an additional fetch. + This includes cold-cache failures. + Where both gates apply, the later wins. +- Concurrent callers share at most one fetch. + Each call fetches/joins at most once. + A known-key signature failure never triggers an additional fetch. -A newly rotated key can be rejected during a successful-fetch cooldown. A -same-kid replacement may wait until TTL expiry. These limits are per verifier, -not fleet-wide AWS rate controls. Shorter TTL reduces the window in which a -removed key remains locally trusted and increases fetch frequency. +A newly rotated key can be rejected during a successful-fetch cooldown. +A same-kid replacement may wait until TTL expiry. +These limits are per verifier, not fleet-wide AWS rate controls. +Shorter TTL reduces the window in which a removed key remains locally trusted and increases fetch frequency. Cancellation stops an individual caller's wait without canceling other waiters. The shared fetch uses its own bounded context and no request context values. -It may finish and populate the cache after all waiters leave; Lambda can freeze -that work, so it is not a durable background job. There is no Close method. +It may finish and populate the cache after all waiters leave; +Lambda can freeze that work, so it is not a durable background job. +There is no Close method. Timeout defaults to 5 seconds and accepts a positive duration up to 1 minute. -It covers headers, body and key validation. Redirects, cookie storage and -application retries are disabled; only HTTP 200 is accepted. Cache lifetime is -explicit key-trust policy, not an HTTP Cache-Control/ETag cache. - -Transport defaults to http.DefaultTransport with normal TLS verification. A -custom RoundTripper is a trusted, caller-owned dependency: it must support -concurrency and honor context cancellation, including body reads. The library -cannot kill arbitrary blocked Go code. It bounds waiter duration and retains a -still-running fetch to prevent accumulating replacement goroutines; late success -after its deadline cannot populate the cache. It does not mutate or close the -supplied transport. +It covers headers, body and key validation. +Redirects, cookie storage and application retries are disabled; +only HTTP 200 is accepted. +Cache lifetime is explicit key-trust policy, not an HTTP Cache-Control/ETag cache. + +Transport defaults to http.DefaultTransport with normal TLS verification. +A custom RoundTripper is a trusted, caller-owned dependency: it must support concurrency and honor context cancellation, including body reads. +The library cannot kill arbitrary blocked Go code. +It bounds waiter duration and retains a still-running fetch to prevent accumulating replacement goroutines; +late success after its deadline cannot populate the cache. +It does not mutate or close the supplied transport. ## Token profile, limits and failures -Only compact RS256 tokens are supported. JSON is strict about duplicate members, -UTF-8, trailing data and a maximum depth of 64 containers. Base64url must be -canonical and unpadded. Optional typ must be JWT; critical headers, unencoded -payloads, token-supplied keys/URLs/certificate chains, compression and nested -content declarations are rejected. kid is an opaque identifier, never a URL. - -exp and iat are required plain nonnegative integer JSON seconds; nbf is optional. -Values must fit through year 9999. Fractions, exponents and string dates are -rejected. Require exp > iat and nbf < exp when present, and check expiration, -future iat and nbf both before and after expensive work. ClockSkew defaults to -zero and permits explicit leeway up to five minutes. These profile restrictions -are narrower than general JWT, not additional claims about AWS service limits. - -MaxTokenBytes defaults to 16 KiB and accepts 1 byte..1 MiB. ClaimsBudget defaults -to the identity package's weighted 256 KiB allowance, configurable up to 6 MiB. -The separate authn MaxAuthorizationBytes includes the scheme and spaces: raise -both where appropriate. Gateway/server total header limits still apply. - -Fixed bounds are a 4 KiB decoded protected header, a 256-byte kid, a 64 KiB JWKS -response after transport decoding, at most 32 keys, and 2048..4096-bit RSA keys. +Only compact RS256 tokens are supported. +JSON is strict about duplicate members, UTF-8, trailing data and a maximum depth of 64 containers. +Base64url must be canonical and unpadded. +Optional typ must be JWT; +critical headers, unencoded payloads, token-supplied keys/URLs/certificate chains, compression and nested content declarations are rejected. +kid is an opaque identifier, never a URL. + +exp and iat are required plain nonnegative integer JSON seconds; +nbf is optional. +Values must fit through year 9999. +Fractions, exponents and string dates are rejected. +Require exp > iat and nbf < exp when present, and check expiration, future iat and nbf both before and after expensive work. +ClockSkew defaults to zero and permits explicit leeway up to five minutes. +These profile restrictions are narrower than general JWT, not additional claims about AWS service limits. + +MaxTokenBytes defaults to 16 KiB and accepts 1 byte..1 MiB. +ClaimsBudget defaults to the identity package's weighted 256 KiB allowance, configurable up to 6 MiB. +The separate authn MaxAuthorizationBytes includes the scheme and spaces: raise both where appropriate. +Gateway/server total header limits still apply. + +Fixed bounds are a 4 KiB decoded protected header, a 256-byte kid, a 64 KiB JWKS response after transport decoding, at most 32 keys, and 2048..4096-bit RSA keys. Duplicate nonempty kids are rejected even across unsupported key types. -Malformed eligible signing keys invalidate the entire fetched set; previously -cached keys remain usable only until their original expiry. No indefinite union -of removed keys or partial publication of malformed sets occurs. +Malformed eligible signing keys invalidate the entire fetched set; +previously cached keys remain usable only until their original expiry. +No indefinite union of removed keys or partial publication of malformed sets occurs. | Verify outcome | Category | Existing middleware response | | --- | --- | --- | @@ -140,20 +149,18 @@ of removed keys or partial publication of malformed sets occurs. | Network/JWKS failure, expired cache outage, or failed required refresh/backoff | ErrUnavailable | 503, no challenge | | Caller canceled or deadline exceeded | ctx.Err() | Best-effort 503; no application handler | -Each failure returns a zero caller. Diagnostics contain no raw token, claims, -kid, response body, transport cause or cancellation cause. Direct Verify does not -install context or write a response. Constructors and nil/zero-verifier misuse -return ErrInvalidConfiguration. Warm reports key unavailability or cancellation, -not invalid credentials. - -Offline signature/claim verification cannot establish current Cognito revocation, -sign-out or user-enabled state. No GetUser/introspection call is implied. The -resulting caller is a snapshot, including during a long stream; ongoing policy -checks belong to the application. - -Local validation includes an independent RFC RS256 vector, signed synthetic -tokens, JWKS rotation/outage/concurrency tests, and actual Bearer/IAM composition -over native HTTP and raw/typed API Gateway entry points. No live Cognito or AWS -deployment was used. [Decision 0020](decisions/0020-cognito-verification.md) records -the approved tradeoffs and official sources. The [runnable configuration example](../authn/cognito_example_test.go) -shows resource-bound users and explicitly unbound M2M clients without network I/O. +Each failure returns a zero caller. +Diagnostics contain no raw token, claims, kid, response body, transport cause or cancellation cause. +Direct Verify does not install context or write a response. +Constructors and nil/zero-verifier misuse return ErrInvalidConfiguration. +Warm reports key unavailability or cancellation, not invalid credentials. + +Offline signature/claim verification cannot establish current Cognito revocation, sign-out or user-enabled state. +No GetUser/introspection call is implied. +The resulting caller is a snapshot, including during a long stream; +ongoing policy checks belong to the application. + +Local validation includes an independent RFC RS256 vector, signed synthetic tokens, JWKS rotation/outage/concurrency tests, and actual Bearer/IAM composition over native HTTP and raw/typed API Gateway entry points. +No live Cognito or AWS deployment was used. +[Decision 0020](decisions/0020-cognito-verification.md) records the approved tradeoffs and official sources. +The [runnable configuration example](../authn/cognito_example_test.go) shows resource-bound users and explicitly unbound M2M clients without network I/O. diff --git a/docs/consumer-testing.md b/docs/consumer-testing.md index 0ff802a..eb552e3 100644 --- a/docs/consumer-testing.md +++ b/docs/consumer-testing.md @@ -1,7 +1,8 @@ # Testing an edge consumer -Keep each test's trust boundary explicit. Identity constructors validate facts, -not credentials. Gateway fixtures describe shapes, not deployed authorizers. +Keep each test's trust boundary explicit. +Identity constructors validate facts, not credentials. +Gateway fixtures describe shapes, not deployed authorizers. | Boundary | Tools | Evidence | | --- | --- | --- | @@ -12,40 +13,37 @@ not credentials. Gateway fixtures describe shapes, not deployed authorizers. | Stream lifecycle | Public reader, controlled producer, incremental reads | Delivery, backpressure, cancellation and errors | | External module | Local replace and public imports | Package and registration boundaries | -For business-handler fixtures, prefer `SourceCustomAssertion` unless modeling a -different source deliberately. Use issuer-qualified JWT claims and valid IAM -caller ARNs, checking all constructor/context errors. Never inject a caller into -an adapter's or authenticator's parent context: both reject inherited callers. +For business-handler fixtures, prefer `SourceCustomAssertion` unless modeling a different source deliberately. +Use issuer-qualified JWT claims and valid IAM caller ARNs, checking all constructor/context errors. +Never inject a caller into an adapter's or authenticator's parent context: both reject inherited callers. An explicitly labeled verifier stub must return its contract's required source; the source label alone is not cryptographic evidence. -For authentication tests, use real verifiers, locally signed JWTs and explicit -local JWKS transports. Generate IAM proofs with synthetic credentials and return -synthetic STS responses. Keep rejection distinct from dependency outage. An -unexpected test URL must fail, never fall through to a network transport. The -production `iamproof` suite separately checks reconstruction/signatures; a canned -STS response cannot establish AWS acceptance. - -Keep REST, HTTP API 1.0 and HTTP API 2.0 fixtures distinct. The shared SDK V1 type -does not include the raw HTTP 1.0 discriminator: explicitly encode `version: "1.0"`. -V2 combines repeated header/query values and represents cookies separately. Do -not infer a lossless Gateway event from an ordinary request, or split all commas -into header values. See the [fixture inventory](gateway-fixtures.md). - -`httptest.ResponseRecorder` is useful for small native outcomes. It does not -reproduce AWS mapping, network cancellation, incremental delivery or terminal -stream errors. Go 1.27's `httptest.NewTestServer(t, handler)` uses an in-memory -network and can participate in `testing/synctest` without wall-clock sleeps. - -Always close a returned stream. Read metadata through its eight-NUL delimiter, -then consume body chunks incrementally and inspect the terminal read error. -Synchronize with a controlled producer to assert delivery before completion, -block consumption to prove backpressure, and close early to prove producer exit. +For authentication tests, use real verifiers, locally signed JWTs and explicit local JWKS transports. +Generate IAM proofs with synthetic credentials and return synthetic STS responses. +Keep rejection distinct from dependency outage. +An unexpected test URL must fail, never fall through to a network transport. +The production `iamproof` suite separately checks reconstruction/signatures; +a canned STS response cannot establish AWS acceptance. + +Keep REST, HTTP API 1.0 and HTTP API 2.0 fixtures distinct. +The shared SDK V1 type does not include the raw HTTP 1.0 discriminator: explicitly encode `version: "1.0"`. +V2 combines repeated header/query values and represents cookies separately. +Do not infer a lossless Gateway event from an ordinary request, or split all commas into header values. +See the [fixture inventory](gateway-fixtures.md). + +`httptest.ResponseRecorder` is useful for small native outcomes. +It does not reproduce AWS mapping, network cancellation, incremental delivery or terminal stream errors. +Go 1.27's `httptest.NewTestServer(t, handler)` uses an in-memory network and can participate in `testing/synctest` without wall-clock sleeps. + +Always close a returned stream. +Read metadata through its eight-NUL delimiter, then consume body chunks incrementally and inspect the terminal read error. +Synchronize with a controlled producer to assert delivery before completion, block consumption to prove backpressure, and close early to prove producer exit. A generic response collector or final-body assertion cannot establish these. -The [dispatcher](../examples/dispatcher/README.md) demonstrates these boundaries -with private helpers. Small-response assertions retain V2 header/cookie differences; -separate tests exercise streaming lifecycle. `sh scripts/check-consumer.sh` runs -a temporary consumer module with public imports and a local replace; CI runs it -explicitly. There is no exported `edgetest` package: decision 0023 defers it until -actual consumers establish useful stable operations beyond existing tools. +The [dispatcher](../examples/dispatcher/README.md) demonstrates these boundaries with private helpers. +Small-response assertions retain V2 header/cookie differences; +separate tests exercise streaming lifecycle. +`sh scripts/check-consumer.sh` runs a temporary consumer module with public imports and a local replace; +CI runs it explicitly. +There is no exported `edgetest` package: decision 0023 defers it until actual consumers establish useful stable operations beyond existing tools. diff --git a/docs/decisions/0001-gateway-identity.md b/docs/decisions/0001-gateway-identity.md index 97a50ed..36ab723 100644 --- a/docs/decisions/0001-gateway-identity.md +++ b/docs/decisions/0001-gateway-identity.md @@ -4,37 +4,30 @@ Status: accepted by the user. ## Context and user direction -The user accepted the overall rebuild plan, then refined the initial review's -gateway-default recommendation: no gateway assertions by default feels more -natural, with a positive variadic option such as `WithGateway` to enable them. +The user accepted the overall rebuild plan, then refined the initial review's gateway-default recommendation: no gateway assertions by default feels more natural, with a positive variadic option such as `WithGateway` to enable them. -This record distinguishes receiving API Gateway traffic from using gateway -assertions to establish application identity. +This record distinguishes receiving API Gateway traffic from using gateway assertions to establish application identity. ## Evidence -- AWS documents that a JWT authorizer validates the token from its configured - identity source, then passes claims to the integration. The event's claims are - the asserted result; comparing selected claims does not authenticate a second - raw token from another source. +- AWS documents that a JWT authorizer validates the token from its configured identity source, then passes claims to the integration. + The event's claims are the asserted result; + comparing selected claims does not authenticate a second raw token from another source. [JWT authorizer contract](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html) -- AWS documents IAM authorization as route configuration requiring SigV4 and - `execute-api` permission. Choosing whether this module consumes that assertion - does not alter the deployed route's authentication. +- AWS documents IAM authorization as route configuration requiring SigV4 and `execute-api` permission. + Choosing whether this module consumes that assertion does not alter the deployed route's authentication. [IAM authorization](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-access-control-iam.html) - Beakley's `lambda/adapter.go` attaches an identity even when anonymous; `authn/middleware.go` skips verification whenever the context slot is present. - That coupling prevents a local verifier from naturally following the adapter - on a route with no gateway-authenticated caller. -- Our repository currently has no runtime code. No existing API constrains the - new option name or default. + That coupling prevents a local verifier from naturally following the adapter on a route with no gateway-authenticated caller. +- Our repository currently has no runtime code. + No existing API constrains the new option name or default. ## Decision -Use the variadic option `WithGatewayIdentity(enabled bool)` with a -default of false. The positive opt-in call reads `WithGatewayIdentity(true)`; -the boolean permits configuration helpers to explicitly disable it without a -second negatively named option. +Use the variadic option `WithGatewayIdentity(enabled bool)` with a default of false. +The positive opt-in call reads `WithGatewayIdentity(true)`; +the boolean permits configuration helpers to explicitly disable it without a second negatively named option. Accepted behavior: @@ -44,52 +37,42 @@ Accepted behavior: | `WithGatewayIdentity(true)` | Derive identity from supported native gateway JWT/Cognito and IAM assertions | | Local bearer middleware explicitly configured | Verify the supplied token under caller-provided issuer/client policy | -The option covers both IAM and JWT/Cognito assertions. Custom-authorizer -contexts require an explicit mapper; their schema must not be guessed. +The option covers both IAM and JWT/Cognito assertions. +Custom-authorizer contexts require an explicit mapper; +their schema must not be guessed. -The base adapter must not insert an anonymous identity solely to mark itself -as having run. Reading absent identity still yields the anonymous zero value, -and protected routes deny it. Invocation metadata can remain available separately. -This does not promise to preserve an identity from an unrelated invocation or -settle precedence between multiple configured producers. +The base adapter must not insert an anonymous identity solely to mark itself as having run. +Reading absent identity still yields the anonymous zero value, and protected routes deny it. +Invocation metadata can remain available separately. +This does not promise to preserve an identity from an unrelated invocation or settle precedence between multiple configured producers. -Gateway mode uses asserted event claims without supplementing permissions -from an unverified header token. It assumes the deployment restricts invocation -to the intended trusted integration. It does not independently verify SigV4 or -JWT signatures, discover an issuer, or configure AWS authorization. +Gateway mode uses asserted event claims without supplementing permissions from an unverified header token. +It assumes the deployment restricts invocation to the intended trusted integration. +It does not independently verify SigV4 or JWT signatures, discover an issuer, or configure AWS authorization. ## Alternatives -- `WithGateway`: concise, but suggests enabling the transport, which is already - the adapter's purpose. +- `WithGateway`: concise, but suggests enabling the transport, which is already the adapter's purpose. - `NoGateway`: encodes a trust-default that the user's follow-up rejects. -- `WithGatewayIdentity()` without an argument: concise, but provides no direct - way for reusable option configuration to turn the policy back off. -- Automatically verify JWTs by default: cannot safely choose trusted issuers, - clients, audiences, and networking policy from untrusted request data. -- Opt in for JWT only while always consuming IAM: possible, but gives the same - gateway trust boundary two different defaults. +- `WithGatewayIdentity()` without an argument: concise, but provides no direct way for reusable option configuration to turn the policy back off. +- Automatically verify JWTs by default: cannot safely choose trusted issuers, clients, audiences, and networking policy from untrusted request data. +- Opt in for JWT only while always consuming IAM: possible, but gives the same gateway trust boundary two different defaults. ## Consequences and remaining decisions -- Applications must explicitly select how identity is established. An adapter - without authentication can serve public routes; it does not imply that all - routes are protected or that gateway authentication was disabled in AWS. -- Exact option mechanics, constructor validation, and error handling will be - reviewed with the adapter API. -- Local-versus-gateway precedence, re-verification, conflicting credentials, - and custom-authorizer mapping need further decisions before implementation. -- Claims unavailable at full fidelity from the gateway remain unavailable through - that source. Local verification is the path to trusted raw-token fidelity. +- Applications must explicitly select how identity is established. + An adapter without authentication can serve public routes; + it does not imply that all routes are protected or that gateway authentication was disabled in AWS. +- Exact option mechanics, constructor validation, and error handling will be reviewed with the adapter API. +- Local-versus-gateway precedence, re-verification, conflicting credentials, and custom-authorizer mapping need further decisions before implementation. +- Claims unavailable at full fidelity from the gateway remain unavailable through that source. + Local verification is the path to trusted raw-token fidelity. ## Resolution -The user replied "approved" to the concrete proposal: `WithGatewayIdentity(true)` -opts in to both IAM and JWT/Cognito assertions, the default performs transport -adaptation without establishing identity or fetching keys, and local verification -is explicitly composed. This approves the decision above, not the remaining -constructor, precedence, mapper, or error-policy decisions. +The user replied "approved" to the concrete proposal: `WithGatewayIdentity(true)` opts in to both IAM and JWT/Cognito assertions, the default performs transport adaptation without establishing identity or fetching keys, and local verification is explicitly composed. +This approves the decision above, not the remaining constructor, precedence, mapper, or error-policy decisions. -Implementation status: `WithGatewayIdentity` now records the approved setting in -constructor-owned configuration. Invocation and identity extraction are not yet -implemented; the option cannot establish identity until those stages are built. +Implementation status: `WithGatewayIdentity` now records the approved setting in constructor-owned configuration. +Invocation and identity extraction are not yet implemented; +the option cannot establish identity until those stages are built. diff --git a/docs/decisions/0002-adapter-boundary.md b/docs/decisions/0002-adapter-boundary.md index 38ea8e7..3510724 100644 --- a/docs/decisions/0002-adapter-boundary.md +++ b/docs/decisions/0002-adapter-boundary.md @@ -4,39 +4,30 @@ Status: accepted by the user. ## Question -Should the adapter validate configuration at construction, and should failures to -translate an invocation return a Lambda error or a synthesized HTTP response? +Should the adapter validate configuration at construction, and should failures to translate an invocation return a Lambda error or a synthesized HTTP response? -This decision settles constructor errors, option application, and transport -invocation-failure policy alongside the previously accepted raw `Invoke` boundary -and variadic gateway-identity option. +This decision settles constructor errors, option application, and transport invocation-failure policy alongside the previously accepted raw `Invoke` boundary and variadic gateway-identity option. ## Evidence -- The official AWS Lambda Go SDK defines `Handler` as - `Invoke(context.Context, []byte) ([]byte, error)`. Its v1.54.0 implementation - recognizes this interface before reflected function dispatch and passes raw - bytes directly. The ordinary function path decodes and encodes through - `encoding/json`. - [Handler interface](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/lambda#Handler) - [SDK handler source](https://github.com/aws/aws-lambda-go/blob/v1.54.0/lambda/handler.go) +- The official AWS Lambda Go SDK defines `Handler` as `Invoke(context.Context, []byte) ([]byte, error)`. + Its v1.54.0 implementation recognizes this interface before reflected function dispatch and passes raw bytes directly. + The ordinary function path decodes and encodes through `encoding/json`. + [Handler interface](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/lambda#Handler) [SDK handler source](https://github.com/aws/aws-lambda-go/blob/v1.54.0/lambda/handler.go) - Consequently, the adapter object should be passed to `lambda.Start(adapter)`. - Passing `adapter.Invoke` as a function loses interface dispatch and subjects - `[]byte` parameters/results to ordinary JSON serialization semantics. -- AWS documents function errors as a generic API Gateway 502 response; invocation - requests rejected by the Lambda API produce 500. API Gateway does not retry - Lambda invocations. Returning a valid proxy response instead lets the function - control the HTTP status and body, and is a handled outcome rather than a Lambda - function error. + Passing `adapter.Invoke` as a function loses interface dispatch and subjects `[]byte` parameters/results to ordinary JSON serialization semantics. +- AWS documents function errors as a generic API Gateway 502 response; + invocation requests rejected by the Lambda API produce 500. + API Gateway does not retry Lambda invocations. + Returning a valid proxy response instead lets the function control the HTTP status and body, and is a handled outcome rather than a Lambda function error. [API Gateway/Lambda error behavior](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway-errors.html) -- Beakley's adapter synthesizes a 500 response and returns a nil error for - extraction/conversion failures. This obscures the distinction between an - application HTTP response and failure of the adapter itself. -- Our repository currently contains plans and decision records only. There is - no implementation or compatibility commitment to preserve. +- Beakley's adapter synthesizes a 500 response and returns a nil error for extraction/conversion failures. + This obscures the distinction between an application HTTP response and failure of the adapter itself. +- Our repository currently contains plans and decision records only. + There is no implementation or compatibility commitment to preserve. -The SDK version above was inspected as evidence, not selected as the new module's -dependency version. That selection and its checks belong to implementation setup. +The SDK version above was inspected as evidence, not selected as the new module's dependency version. +That selection and its checks belong to implementation setup. ## Decision @@ -50,20 +41,18 @@ func WithGatewayIdentity(enabled bool) Option func (a *Adapter) Invoke(ctx context.Context, payload []byte) ([]byte, error) ``` -`New` validates the handler and configuration without I/O. Missing handlers, -invalid options, and invalid configuration return an error before the process -begins serving. Do not implicitly use `http.DefaultServeMux` or turn a missing -handler into a production fallback response. +`New` validates the handler and configuration without I/O. +Missing handlers, invalid options, and invalid configuration return an error before the process begins serving. +Do not implicitly use `http.DefaultServeMux` or turn a missing handler into a production fallback response. -Options are applied in argument order, with the last assignment winning for -scalar settings such as gateway identity. Reject nil options. The adapter owns -its resulting configuration, exposes no mutating setters, and uses fresh request -state for each invocation. The supplied handler remains caller-owned and must be -safe for concurrent use if invocations are made concurrently. +Options are applied in argument order, with the last assignment winning for scalar settings such as gateway identity. +Reject nil options. +The adapter owns its resulting configuration, exposes no mutating setters, and uses fresh request state for each invocation. +The supplied handler remains caller-owned and must be safe for concurrent use if invocations are made concurrently. -Application startup handles constructor failure in `main`, then calls -`lambda.Start(adapter)`. The library does not terminate the process. No `MustNew` -or separate library-owned start function is needed initially. +Application startup handles constructor failure in `main`, then calls `lambda.Start(adapter)`. +The library does not terminate the process. +No `MustNew` or separate library-owned start function is needed initially. ### Failure ownership @@ -75,64 +64,52 @@ or separate library-owned start function is needed initially. | Failure to encode a valid gateway response | `Invoke` returns an error; no partial payload is usable | | Handler/middleware writes an HTTP response, including 4xx/5xx | `Invoke` returns the encoded response with nil error | -In particular, malformed application JSON inside an otherwise valid request body -is the HTTP handler's concern. It is not an invalid Lambda event. The adapter -must not interpret application payloads to decide whether an invocation failed. +In particular, malformed application JSON inside an otherwise valid request body is the HTTP handler's concern. +It is not an invalid Lambda event. +The adapter must not interpret application payloads to decide whether an invocation failed. -Return contextual errors rather than logging and returning them. Error strings -must not include the raw event, authorization header, JWT, or application body. +Return contextual errors rather than logging and returning them. +Error strings must not include the raw event, authorization header, JWT, or application body. An HTTP client normally receives the gateway's generic 502 for adapter failures; -operators retain the Lambda failure signal and diagnostic error. Direct callers -of `Invoke` also receive the error. +operators retain the Lambda failure signal and diagnostic error. +Direct callers of `Invoke` also receive the error. -This policy covers transport decoding/conversion/encoding failures. It does not -yet decide authentication outcomes, malformed authorizer contexts, context -cancellation timing, handler panics, unsupported response capabilities, or the -public sentinel/type taxonomy. Those require the relevant contracts. +This policy covers transport decoding/conversion/encoding failures. +It does not yet decide authentication outcomes, malformed authorizer contexts, context cancellation timing, handler panics, unsupported response capabilities, or the public sentinel/type taxonomy. +Those require the relevant contracts. ## Alternatives and tradeoffs -- Return only `*Adapter` from `New`: a shorter startup line, but configuration - errors must become panics or surface during an invocation. An explicit error - keeps validation at the point the caller still controls startup. -- Return synthesized HTTP 500 responses for transport faults: gives clients a - controlled response body and status, but records the Lambda invocation as - handled. Before classifying the event, the correct response format is also - unknown. Prefer a consistent error path for adapter failures. -- Return Lambda errors for every HTTP 4xx/5xx: loses the handler's intended - response and misclassifies ordinary application outcomes as invocation faults. -- Export event unions or typed proxy methods at the time of this decision: - deferred pending a concrete consumer need. The user's subsequent request and - acceptance of [decision 0004](0004-typed-events.md) add HandleV1 and HandleV2. +- Return only `*Adapter` from `New`: a shorter startup line, but configuration errors must become panics or surface during an invocation. + An explicit error keeps validation at the point the caller still controls startup. +- Return synthesized HTTP 500 responses for transport faults: gives clients a controlled response body and status, but records the Lambda invocation as handled. + Before classifying the event, the correct response format is also unknown. + Prefer a consistent error path for adapter failures. +- Return Lambda errors for every HTTP 4xx/5xx: loses the handler's intended response and misclassifies ordinary application outcomes as invocation faults. +- Export event unions or typed proxy methods at the time of this decision: deferred pending a concrete consumer need. + The user's subsequent request and acceptance of [decision 0004](0004-typed-events.md) add HandleV1 and HandleV2. ## Validation -- Constructor cases: absent handler, nil option, invalid configuration, explicit - gateway enable/disable, and duplicate scalar options. -- SDK-boundary tests pass an adapter object through `lambda.NewHandler(...).Invoke` - and verify object-shaped responses without base64/double encoding. +- Constructor cases: absent handler, nil option, invalid configuration, explicit gateway enable/disable, and duplicate scalar options. +- SDK-boundary tests pass an adapter object through `lambda.NewHandler(...).Invoke` and verify object-shaped responses without base64/double encoding. - Malformed/unsupported envelopes never reach the application handler. -- Application HTTP error statuses and bodies survive the round trip with nil - invocation error; transport faults return errors with no sensitive payloads. +- Application HTTP error statuses and bodies survive the round trip with nil invocation error; + transport faults return errors with no sensitive payloads. -These tests will follow the reviewed payload and response contracts; this -decision does not authorize inventing those contracts during implementation. +These tests will follow the reviewed payload and response contracts; +this decision does not authorize inventing those contracts during implementation. ## Resolution -The user replied "approved" to the constructor and failure-handling contract: -`New` returns `(*Adapter, error)`, options apply in order with last scalar assignment -winning, the adapter object is passed to `lambda.Start`, transport failures return -invocation errors, and handler HTTP responses retain their status/body with nil -invocation error. - -Implementation status: constructor/options foundation is implemented with no -external dependencies. Tests cover nil/typed-nil handlers, nil options, and -construction without handler invocation. `go test -race ./...`, `go vet ./...`, -and Linux arm64/amd64 package builds pass on Go 1.27.1; formatting is clean. - -Update 2026-09-16: Invoke, HandleV1 and HandleV2 are implemented using the shared -HTTP, native identity, response and cleanup boundaries. Lambda SDK object/function -registration tests pass alongside malformed-input, application-error, binary, -cookie, cancellation, panic and invocation-isolation cases. Registration examples -are runnable. Current public behavior is described in README.md and decision 0018. +The user replied "approved" to the constructor and failure-handling contract: `New` returns `(*Adapter, error)`, options apply in order with last scalar assignment winning, the adapter object is passed to `lambda.Start`, transport failures return invocation errors, and handler HTTP responses retain their status/body with nil invocation error. + +Implementation status: constructor/options foundation is implemented with no external dependencies. +Tests cover nil/typed-nil handlers, nil options, and construction without handler invocation. +`go test -race ./...`, `go vet ./...`, and Linux arm64/amd64 package builds pass on Go 1.27.1; +formatting is clean. + +Update 2026-09-16: Invoke, HandleV1 and HandleV2 are implemented using the shared HTTP, native identity, response and cleanup boundaries. +Lambda SDK object/function registration tests pass alongside malformed-input, application-error, binary, cookie, cancellation, panic and invocation-isolation cases. +Registration examples are runnable. +Current public behavior is described in README.md and decision 0018. diff --git a/docs/decisions/0003-event-decoding.md b/docs/decisions/0003-event-decoding.md index d1ff965..55401fe 100644 --- a/docs/decisions/0003-event-decoding.md +++ b/docs/decisions/0003-event-decoding.md @@ -1,76 +1,65 @@ # 0003: AWS event types and explicit event decoding -Status: accepted by the user; private decoder and typed validators implemented. +Status: accepted by the user; +private decoder and typed validators implemented. ## Question -Which event shapes does the decoder accept, and should our JSON representation -use the AWS SDK event structs or private types owned by this module? +Which event shapes does the decoder accept, and should our JSON representation use the AWS SDK event structs or private types owned by this module? ## Evidence -- AWS's REST proxy example has `httpMethod`, `path`, and `requestContext`, with - an API ID in the context and no top-level version discriminator. Its examples - also show null optional fields. - [REST proxy input](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) - [AWS HTTP request example](https://aws.amazon.com/blogs/developer/handling-arbitrary-http-requests-in-amazon-api-gateway/) -- HTTP APIs support payload versions `1.0` and `2.0`. V1 carries method/path at - the top level; V2 uses `requestContext.http.method` and `rawPath`. The versions - differ in headers, queries, cookies, and custom-domain mapping visibility. +- AWS's REST proxy example has `httpMethod`, `path`, and `requestContext`, with an API ID in the context and no top-level version discriminator. + Its examples also show null optional fields. + [REST proxy input](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) [AWS HTTP request example](https://aws.amazon.com/blogs/developer/handling-arbitrary-http-requests-in-amazon-api-gateway/) +- HTTP APIs support payload versions `1.0` and `2.0`. + V1 carries method/path at the top level; + V2 uses `requestContext.http.method` and `rawPath`. + The versions differ in headers, queries, cookies, and custom-domain mapping visibility. [HTTP API payload formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) -- The inspected AWS Go SDK v1.54.0 uses non-pointer scalar event fields and - `map[string]string` for V2 authorizer JWT claims. Those types are convenient - consumers of the wire format, but alone cannot distinguish an omitted field - from a zero value or preserve a non-string claim value should one arrive. - [SDK event types](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/events#APIGatewayV2HTTPRequest) - [SDK JWT claims](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/events#APIGatewayV2HTTPRequestContextAuthorizerJWTDescription) -- JSON v2 requires a single JSON value, rejects invalid UTF-8 and duplicate - object names by default, and matches struct member names case-sensitively. - JSON null generally decodes as a Go zero value; unmarshaling into `any` decodes - numbers as float64. Presence/type validation therefore needs explicit handling. - [JSON v2 documentation](https://pkg.go.dev/encoding/json/v2) - [Unmarshal](https://pkg.go.dev/encoding/json/v2#Unmarshal) +- The inspected AWS Go SDK v1.54.0 uses non-pointer scalar event fields and `map[string]string` for V2 authorizer JWT claims. + Those types are convenient consumers of the wire format, but alone cannot distinguish an omitted field from a zero value or preserve a non-string claim value should one arrive. + [SDK event types](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/events#APIGatewayV2HTTPRequest) [SDK JWT claims](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/events#APIGatewayV2HTTPRequestContextAuthorizerJWTDescription) +- JSON v2 requires a single JSON value, rejects invalid UTF-8 and duplicate object names by default, and matches struct member names case-sensitively. + JSON null generally decodes as a Go zero value; + unmarshaling into `any` decodes numbers as float64. + Presence/type validation therefore needs explicit handling. + [JSON v2 documentation](https://pkg.go.dev/encoding/json/v2) [Unmarshal](https://pkg.go.dev/encoding/json/v2#Unmarshal) - Lambda Function URLs use the same request/response schema as API Gateway V2. Shape validation cannot authenticate the invoking service. [Function URL payloads](https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html) - At the time of this decision, implementation contained only the constructor/options foundation. - No existing decoder or wire schema constrains this choice. The accepted plan - already requires direct JSON v2 and rejection rather than V2-to-V1 fallback. + No existing decoder or wire schema constrains this choice. + The accepted plan already requires direct JSON v2 and rejection rather than V2-to-V1 fallback. ## Decision ### Wire representation -Reuse AWS Lambda Go request/response structs wherever they meet the reviewed -contract, decoded and encoded directly with `encoding/json/v2`. The user accepted -this direction after questioning the original private-schema proposal. SDK type -reuse does not require using the SDK's JSON decoder. Keep a small explicit -version discriminator and targeted presence/type validation where needed; +Reuse AWS Lambda Go request/response structs wherever they meet the reviewed contract, decoded and encoded directly with `encoding/json/v2`. +The user accepted this direction after questioning the original private-schema proposal. +SDK type reuse does not require using the SDK's JSON decoder. +Keep a small explicit version discriminator and targeted presence/type validation where needed; do not duplicate complete AWS schemas just to select the event format. -Carry authorizer data internally as `jsontext.Value` until -the selected identity producer interprets it. Preserve exactly the JSON -representation AWS supplies; -this does not assert that AWS supplies full-fidelity JWT claims or authorize -guessing an array from a flattened string. Avoid float64 conversion of arbitrary -claim numbers at the transport boundary. - -Authorizer extraction and SDK decoding must be designed together so an SDK claim -field cannot prematurely reject data intended to remain opaque. The exact -exception mechanism is an implementation detail; authorizer fidelity alone does not justify -private copies of the entire envelope. - -The AWS Lambda Go library remains the runtime integration and supplies event -types. Its reflected function path decodes into the handler's declared parameter -type; an `any` parameter receives generic JSON values, not automatically selected -AWS event structs. Our raw boundary selects the version before decoding the -concrete event. Dependency-version selection remains separate. -[SDK handler implementation](https://github.com/aws/aws-lambda-go/blob/v1.54.0/lambda/handler.go) -[AWS Go handler documentation](https://docs.aws.amazon.com/lambda/latest/dg/golang-handler.html) - -The user also requested a route for already typed events that avoids JSON work -inside edge. See [decision 0004](0004-typed-events.md) for the accepted signatures -and the limits of validation after another component has decoded the payload. +Carry authorizer data internally as `jsontext.Value` until the selected identity producer interprets it. +Preserve exactly the JSON representation AWS supplies; +this does not assert that AWS supplies full-fidelity JWT claims or authorize guessing an array from a flattened string. +Avoid float64 conversion of arbitrary claim numbers at the transport boundary. + +Authorizer extraction and SDK decoding must be designed together so an SDK claim field cannot prematurely reject data intended to remain opaque. +The exact exception mechanism is an implementation detail; +authorizer fidelity alone does not justify private copies of the entire envelope. + +The AWS Lambda Go library remains the runtime integration and supplies event types. +Its reflected function path decodes into the handler's declared parameter type; +an `any` parameter receives generic JSON values, not automatically selected AWS event structs. +Our raw boundary selects the version before decoding the concrete event. +Dependency-version selection remains separate. +[SDK handler implementation](https://github.com/aws/aws-lambda-go/blob/v1.54.0/lambda/handler.go) [AWS Go handler documentation](https://docs.aws.amazon.com/lambda/latest/dg/golang-handler.html) + +The user also requested a route for already typed events that avoids JSON work inside edge. +See [decision 0004](0004-typed-events.md) for the accepted signatures and the limits of validation after another component has decoded the payload. ### Version dispatch and minimum structure @@ -81,54 +70,51 @@ and the limits of validation after another component has decoded the payload. | `version` exactly `"2.0"` | `requestContext.http.method`, `rawPath` | Payload 2.0 envelope | | Any other present value, including null, empty string, or a number | None | Reject | -For every accepted envelope require a non-null object `requestContext`, a -nonempty string `requestContext.apiId`, and the canonical method/path fields as -nonempty strings. V2 also requires the `requestContext.http` object. These are -accepted library validity checks based on the documented envelopes, not a claim -that AWS publishes a formal required-field schema for all variants. +For every accepted envelope require a non-null object `requestContext`, a nonempty string `requestContext.apiId`, and the canonical method/path fields as nonempty strings. +V2 also requires the `requestContext.http` object. +These are accepted library validity checks based on the documented envelopes, not a claim that AWS publishes a formal required-field schema for all variants. -Do not fall back to a different format after a decode/validation error. Do not -invent `GET` or `/` when a canonical method/path is missing. V2's reported HTTP -context path does not replace a missing `rawPath`. Method syntax and exact path -escaping are part of the subsequent HTTP translation contract. +Do not fall back to a different format after a decode/validation error. +Do not invent `GET` or `/` when a canonical method/path is missing. +V2's reported HTTP context path does not replace a missing `rawPath`. +Method syntax and exact path escaping are part of the subsequent HTTP translation contract. The API ID helps reject unrelated event shapes such as ordinary ALB envelopes; -it is not proof of origin. We do not promise Function URL support merely because -a V2-shaped event passes validation, nor reject one based on guessed hostnames or -API ID spelling. Gateway identity remains the explicit deployment trust decision -accepted in decision 0001. +it is not proof of origin. +We do not promise Function URL support merely because a V2-shaped event passes validation, nor reject one based on guessed hostnames or API ID spelling. +Gateway identity remains the explicit deployment trust decision accepted in decision 0001. ### Already typed events The selected method determines the payload family without automatic dispatch. -Semantic checks still require nonempty API ID, method, and canonical -path. HandleV2 accepts Version equal to "2.0" or empty: an omitted Go string -does not make a caller-created V2 struct ambiguous, because its type and method -already select V2. Reject any other nonempty Version as contradictory input. -HandleV1 has no version field to inspect. Neither method can validate original -JSON shape, member presence, or syntax. Do not serialize structs to imitate -raw-wire validation. - -This is an accepted ergonomic exception for explicitly typed input, not a change -to raw Invoke: a present empty version in JSON remains invalid there. Matching -raw and typed valid events share translation; their available validation evidence -differs as accepted in decision 0004. +Semantic checks still require nonempty API ID, method, and canonical path. +HandleV2 accepts Version equal to "2.0" or empty: an omitted Go string does not make a caller-created V2 struct ambiguous, because its type and method already select V2. +Reject any other nonempty Version as contradictory input. +HandleV1 has no version field to inspect. +Neither method can validate original JSON shape, member presence, or syntax. +Do not serialize structs to imitate raw-wire validation. + +This is an accepted ergonomic exception for explicitly typed input, not a change to raw Invoke: a present empty version in JSON remains invalid there. +Matching raw and typed valid events share translation; +their available validation evidence differs as accepted in decision 0004. ### Strictness and forward compatibility -- Accept one top-level JSON object. Reject null, arrays, scalars, invalid UTF-8, - duplicate object names, and trailing non-whitespace content. +- Accept one top-level JSON object. + Reject null, arrays, scalars, invalid UTF-8, duplicate object names, and trailing non-whitespace content. - Use exact AWS member names and require correct types for consumed fields. Do not enable legacy case-insensitive matching or coercions. -- Permit unknown members for forward compatibility. Unknown data still must be - valid JSON; this does not relax syntax checking inside authorizer data. -- Handle documented absent/null optional fields intentionally. In particular, - absent/null body means empty bytes; absent/null header and query maps mean no - entries. A missing `isBase64Encoded` means false; a present value must be a JSON - boolean. Further fields will be specified when their consumers are designed. -- Preserve opaque authorizer data during transport decoding. Do not establish - identity, interpret claim semantics, or validate a custom-authorizer schema - when no identity producer was selected. +- Permit unknown members for forward compatibility. + Unknown data still must be valid JSON; + this does not relax syntax checking inside authorizer data. +- Handle documented absent/null optional fields intentionally. + In particular, absent/null body means empty bytes; + absent/null header and query maps mean no entries. + A missing `isBase64Encoded` means false; + a present value must be a JSON boolean. + Further fields will be specified when their consumers are designed. +- Preserve opaque authorizer data during transport decoding. + Do not establish identity, interpret claim semantics, or validate a custom-authorizer schema when no identity producer was selected. - Return failures through the invocation-error contract accepted in decision 0002. Do not include the input payload or credential values in errors. @@ -153,45 +139,35 @@ differs as accepted in decision 0004. | Typed V2 with other nonempty Version | Reject contradictory input | | Typed V1/V2 missing API ID, canonical method, or path | Reject before HTTP handling | -Write fixtures from documented contracts and focused synthetic cases, not by -copying Beakley's tests. Include an SDK-generated representative event as an -additional compatibility check rather than treating SDK serialization as the -sole oracle. Run fuzzing against the decoder after implementation. +Write fixtures from documented contracts and focused synthetic cases, not by copying Beakley's tests. +Include an SDK-generated representative event as an additional compatibility check rather than treating SDK serialization as the sole oracle. +Run fuzzing against the decoder after implementation. ## Alternatives and tradeoffs -- Private copies of all event types: more schema maintenance than needed for - automatic dispatch. Prefer SDK types with narrowly justified exceptions. -- Use `map[string]any` throughout: flexible but scatters type assertions and - exposes numeric precision loss. Keep flexibility only where the schema is - genuinely opaque. -- Guess a version from whichever decode succeeds: accepts malformed events in - the wrong format and contradicts the approved rejection policy. -- Reject all unknown fields: catches misspellings, but makes additive AWS fields - a breaking change for deployed applications. +- Private copies of all event types: more schema maintenance than needed for automatic dispatch. + Prefer SDK types with narrowly justified exceptions. +- Use `map[string]any` throughout: flexible but scatters type assertions and exposes numeric precision loss. + Keep flexibility only where the schema is genuinely opaque. +- Guess a version from whichever decode succeeds: accepts malformed events in the wrong format and contradicts the approved rejection policy. +- Reject all unknown fields: catches misspellings, but makes additive AWS fields a breaking change for deployed applications. -SDK structs alone do not provide the accepted presence checks or opaque-claim -contract. Limit supplementary representations to those needs and maintain -independent wire fixtures so SDK serialization is not the sole specification. +SDK structs alone do not provide the accepted presence checks or opaque-claim contract. +Limit supplementary representations to those needs and maintain independent wire fixtures so SDK serialization is not the sole specification. ## Resolution -The user accepted AWS type reuse, the typed entry points, and subsequently the -remaining decoding contract in full: dispatch, structural checks, strict JSON, -opaque authorizer data, and the typed V2 empty-version exception. This supersedes -the original recommendation for entirely private wire types. - -The private decoder and typed validators are implemented. Pin AWS Lambda Go -v1.55.0, verified against the -Go module proxy on 2026-09-14. Its API Gateway event definitions are unchanged -from the inspected v1.54.0, and its reflected handler still uses encoding/json. -Use SDK embedding with a shadowed request context/authorizer field to retain -opaque data without copying the complete schema. SDK dependency selection and -this private decoding mechanism do not alter the accepted public API. - -Validation: documented-shape synthetic fixtures, malformed/unsupported envelopes, -opaque authorizer ownership and precision, error redaction, and SDK serialization -compatibility pass. Race tests, vet, and Linux arm64/amd64 builds pass. A 15-second -fuzz run completed 1,553,008 executions without a failure. Public Invoke and typed -HTTP entry points still await response encoding and gateway identity production; these -checks do not claim end-to-end Lambda adaptation is implemented. +The user accepted AWS type reuse, the typed entry points, and subsequently the remaining decoding contract in full: dispatch, structural checks, strict JSON, opaque authorizer data, and the typed V2 empty-version exception. +This supersedes the original recommendation for entirely private wire types. + +The private decoder and typed validators are implemented. +Pin AWS Lambda Go v1.55.0, verified against the Go module proxy on 2026-09-14. +Its API Gateway event definitions are unchanged from the inspected v1.54.0, and its reflected handler still uses encoding/json. +Use SDK embedding with a shadowed request context/authorizer field to retain opaque data without copying the complete schema. +SDK dependency selection and this private decoding mechanism do not alter the accepted public API. + +Validation: documented-shape synthetic fixtures, malformed/unsupported envelopes, opaque authorizer ownership and precision, error redaction, and SDK serialization compatibility pass. +Race tests, vet, and Linux arm64/amd64 builds pass. +A 15-second fuzz run completed 1,553,008 executions without a failure. +Public Invoke and typed HTTP entry points still await response encoding and gateway identity production; +these checks do not claim end-to-end Lambda adaptation is implemented. diff --git a/docs/decisions/0004-typed-events.md b/docs/decisions/0004-typed-events.md index e7f7405..9e62679 100644 --- a/docs/decisions/0004-typed-events.md +++ b/docs/decisions/0004-typed-events.md @@ -1,26 +1,24 @@ # 0004: Typed event entry points -Status: accepted by the user; implemented and SDK-tested on 2026-09-16. +Status: accepted by the user; +implemented and SDK-tested on 2026-09-16. ## Question -How should callers with an AWS event struct bypass edge's JSON envelope work, -while retaining the same HTTP and gateway-identity behavior? +How should callers with an AWS event struct bypass edge's JSON envelope work, while retaining the same HTTP and gateway-identity behavior? ## Evidence -- The user requested an option to skip JSON when the caller already has typed - events and accepted reuse of AWS event structs in decision 0003. -- Our Adapter currently only holds a handler and immutable configuration. There - is no invocation implementation to preserve or duplicate. -- AWS permits a handler with context and a JSON-compatible input, returning a - JSON-compatible response and error. Its runtime chooses the input type from - the function signature and owns decoding/encoding for that function. - [AWS handler signatures](https://docs.aws.amazon.com/lambda/latest/dg/golang-handler.html) - [SDK implementation inspected at v1.54.0](https://github.com/aws/aws-lambda-go/blob/v1.54.0/lambda/handler.go) +- The user requested an option to skip JSON when the caller already has typed events and accepted reuse of AWS event structs in decision 0003. +- Our Adapter currently only holds a handler and immutable configuration. + There is no invocation implementation to preserve or duplicate. +- AWS permits a handler with context and a JSON-compatible input, returning a JSON-compatible response and error. + Its runtime chooses the input type from the function signature and owns decoding/encoding for that function. + [AWS handler signatures](https://docs.aws.amazon.com/lambda/latest/dg/golang-handler.html) [SDK implementation inspected at v1.54.0](https://github.com/aws/aws-lambda-go/blob/v1.54.0/lambda/handler.go) - AWS supplies matching request and response types for both payload families. - V1 represents REST proxy and HTTP API payload 1.0; the V1 request struct has no - version discriminator. The V2 JWT claims representation is map[string]string. + V1 represents REST proxy and HTTP API payload 1.0; + the V1 request struct has no version discriminator. + The V2 JWT claims representation is map[string]string. [AWS event types](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.54.0/events) ## Decision @@ -33,65 +31,55 @@ func (a *Adapter) HandleV2(ctx context.Context, event events.APIGatewayV2HTTPReq ``` The user approved these names, exact signatures, and the boundary contract below. -Both methods are implemented, sharing the same request, response, ownership and -gateway identity primitives as raw invocation. +Both methods are implemented, sharing the same request, response, ownership and gateway identity primitives as raw invocation. -The ordinary raw registration remains lambda.Start(adapter). A caller that -chooses a fixed payload family can register lambda.Start(adapter.HandleV1) or -lambda.Start(adapter.HandleV2), or pass an already decoded struct directly. +The ordinary raw registration remains lambda.Start(adapter). +A caller that chooses a fixed payload family can register lambda.Start(adapter.HandleV1) or lambda.Start(adapter.HandleV2), or pass an already decoded struct directly. These registrations are exercised through the actual SDK handler wrapper. -Use shared internal HTTP translation, response capture, and identity policy for -both raw and typed entry points. Do not marshal a typed event and call Invoke; -do not encode a response just to decode it into an AWS struct. Typed methods -skip envelope decoding, automatic format selection, and response serialization -inside edge. They still perform HTTP conversion, including base64 body handling. - -All JSON handled by edge continues to use encoding/json/v2 directly. Registering -a typed function with the inspected SDK delegates envelope decoding/encoding to -the SDK's encoding/json path outside edge. Applications requiring direct v2 -control of the Lambda envelope should use the raw Adapter registration. - -Keep semantic validation and transport-error behavior consistent where the -typed values retain the necessary information. On a conversion error, return a -zero response and a non-nil error. Ordinary application HTTP errors remain -responses with nil Go error, following decision 0002. - -Typed values cannot prove that original JSON had unique keys, exact spelling, -correct field presence, or preserved arbitrary numbers. In particular, V1 has -no Version field to validate. Do not promise raw-wire validation equivalence or -recover values lost by a caller's decoder. Identity extraction must respect the -fidelity of the supplied source and must never supplement it with unverified -header JWT claims. Authorizer mappings are settled in accepted decision 0018. +Use shared internal HTTP translation, response capture, and identity policy for both raw and typed entry points. +Do not marshal a typed event and call Invoke; +do not encode a response just to decode it into an AWS struct. +Typed methods skip envelope decoding, automatic format selection, and response serialization inside edge. +They still perform HTTP conversion, including base64 body handling. + +All JSON handled by edge continues to use encoding/json/v2 directly. +Registering a typed function with the inspected SDK delegates envelope decoding/encoding to the SDK's encoding/json path outside edge. +Applications requiring direct v2 control of the Lambda envelope should use the raw Adapter registration. + +Keep semantic validation and transport-error behavior consistent where the typed values retain the necessary information. +On a conversion error, return a zero response and a non-nil error. +Ordinary application HTTP errors remain responses with nil Go error, following decision 0002. + +Typed values cannot prove that original JSON had unique keys, exact spelling, correct field presence, or preserved arbitrary numbers. +In particular, V1 has no Version field to validate. +Do not promise raw-wire validation equivalence or recover values lost by a caller's decoder. +Identity extraction must respect the fidelity of the supplied source and must never supplement it with unverified header JWT claims. +Authorizer mappings are settled in accepted decision 0018. ## Alternatives and tradeoffs - A WithSkipJSON boolean cannot change a Go method's parameter or result type; a distinct callable boundary is needed for typed input and output. -- One any-based method with a type switch can accept both event types but loses - the compile-time request/response pairing. Registering that method directly - with Lambda would receive generic JSON values instead of AWS structs. -- Separate constructors or adapter types could encode the same distinction but - add configuration surface without a demonstrated need. +- One any-based method with a type switch can accept both event types but loses the compile-time request/response pairing. + Registering that method directly with Lambda would receive generic JSON values instead of AWS structs. +- Separate constructors or adapter types could encode the same distinction but add configuration surface without a demonstrated need. -AWS event types become a deliberate public dependency of edge. Keep them out of -transport-independent identity/authentication/authorization packages. Future SDK -upgrades must preserve the typed API and pass boundary compatibility tests. +AWS event types become a deliberate public dependency of edge. +Keep them out of transport-independent identity/authentication/authorization packages. +Future SDK upgrades must preserve the typed API and pass boundary compatibility tests. ## Validation plan - Compile and exercise each typed method through the pinned SDK handler wrapper. -- Compare raw and typed HTTP results using independently constructed equivalent - events for both formats, including cookies, multivalue headers, binary bodies, - application error responses, and conversion failures. +- Compare raw and typed HTTP results using independently constructed equivalent events for both formats, including cookies, multivalue headers, binary bodies, application error responses, and conversion failures. - Verify reviewed gateway-identity defaults and opt-in behavior on both paths. -- Test and document cases where original wire information is unavailable after - upstream decoding, without claiming identical raw-wire rejection guarantees. +- Test and document cases where original wire information is unavailable after upstream decoding, without claiming identical raw-wire rejection guarantees. ## Resolution -The user approved the proposed methods and boundary contract. This extends the -earlier decision 0002 deferral of typed proxy methods in response to a concrete -consumer requirement; its raw invocation contract remains accepted. The later -HTTP and identity decisions are now implemented behind both complete typed methods. +The user approved the proposed methods and boundary contract. +This extends the earlier decision 0002 deferral of typed proxy methods in response to a concrete consumer requirement; +its raw invocation contract remains accepted. +The later HTTP and identity decisions are now implemented behind both complete typed methods. Tests verify SDK registration and the documented upstream-codec qualifications. diff --git a/docs/decisions/0005-http-request-translation.md b/docs/decisions/0005-http-request-translation.md index 8285f64..db1a7a2 100644 --- a/docs/decisions/0005-http-request-translation.md +++ b/docs/decisions/0005-http-request-translation.md @@ -1,181 +1,169 @@ # 0005: Translating gateway requests into net/http -Status: accepted by the user; shared request conversion implemented. +Status: accepted by the user; +shared request conversion implemented. ## Question -What should the HTTP handler observe across raw and typed API Gateway inputs, -including information that API Gateway has already decoded, combined, or lost? +What should the HTTP handler observe across raw and typed API Gateway inputs, including information that API Gateway has already decoded, combined, or lost? ## Evidence -- AWS REST events contain decoded parameter maps, and AWS does not preserve - request-parameter ordering. Its documented header merge rule deduplicates a - key/value present in both single and multivalue maps, rather than discarding - all single-value entries whenever any multivalue entries exist. +- AWS REST events contain decoded parameter maps, and AWS does not preserve request-parameter ordering. + Its documented header merge rule deduplicates a key/value present in both single and multivalue maps, rather than discarding all single-value entries whenever any multivalue entries exist. [REST proxy inputs](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) -- HTTP API V2 supplies rawPath and rawQueryString. Its flattened query/header - maps combine duplicate values with commas. RawPath omits the custom-domain - API mapping prefix. V2 also has a dedicated cookies array. +- HTTP API V2 supplies rawPath and rawQueryString. + Its flattened query/header maps combine duplicate values with commas. + RawPath omits the custom-domain API mapping prefix. + V2 also has a dedicated cookies array. [HTTP API payload differences](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) -- Go's URL.Path is decoded; URL.RawPath can preserve the encoded spelling. - Incoming HTTP requests normally have no URL scheme or authority. The host is - in Request.Host and absent from Request.Header. Body is always non-nil, and - the server owns its cleanup. RemoteAddr has no required format, although the - ordinary HTTP server uses IP:port. TLS describes the connection received by - the server, not an assertion about an upstream connection. - [Go URL](https://pkg.go.dev/net/url#URL) - [Go Request](https://pkg.go.dev/net/http#Request) -- Go's base64 decoder can return partial data with an error. That partial data - must not reach the HTTP handler under decision 0002. +- Go's URL.Path is decoded; + URL.RawPath can preserve the encoded spelling. + Incoming HTTP requests normally have no URL scheme or authority. + The host is in Request.Host and absent from Request.Header. + Body is always non-nil, and the server owns its cleanup. + RemoteAddr has no required format, although the ordinary HTTP server uses IP:port. + TLS describes the connection received by the server, not an assertion about an upstream connection. + [Go URL](https://pkg.go.dev/net/url#URL) [Go Request](https://pkg.go.dev/net/http#Request) +- Go's base64 decoder can return partial data with an error. + That partial data must not reach the HTTP handler under decision 0002. [DecodeString](https://pkg.go.dev/encoding/base64#Encoding.DecodeString) - The new decoder preserves SDK transport fields and authorizer JSON separately. - It does not yet construct HTTP requests. Beakley's decoded-path reparsing and - wholesale single-map loss are independently reproduced requirements to avoid. + It does not yet construct HTTP requests. + Beakley's decoded-path reparsing and wholesale single-map loss are independently reproduced requirements to avoid. ## Decision ### Paths and queries -- V1: assign the supplied decoded path to URL.Path directly. Generate an escaped - request target from that value; do not parse it as a URL or unescape it again. +- V1: assign the supplied decoded path to URL.Path directly. + Generate an escaped request target from that value; + do not parse it as a URL or unescape it again. Literal question marks, hashes, and percent signs remain path data. -- V2: decode rawPath once into URL.Path and retain its encoded form in RawPath - when valid. Reject invalid path escapes and raw path delimiters that would - change the request target. Do not substitute requestContext.http.path. -- Require an absolute path beginning with a slash. Do not clean dot segments, - collapse repeated slashes, strip a stage, or invent a custom-domain prefix. +- V2: decode rawPath once into URL.Path and retain its encoded form in RawPath when valid. + Reject invalid path escapes and raw path delimiters that would change the request target. + Do not substitute requestContext.http.path. +- Require an absolute path beginning with a slash. + Do not clean dot segments, collapse repeated slashes, strip a stage, or invent a custom-domain prefix. ServeMux retains responsibility for its own redirect/routing behavior. -- V1 query names are case-sensitive. Preserve multivalue occurrences and their - order, then append a single-map value only when that exact value is absent - from the multivalue list for the same key. Include keys found only in the - single map. This applies AWS's header merge principle consistently to queries; - handling contradictory synthetic query maps is an edge policy, not an AWS - guarantee. Encode through url.Values.Encode; original encoding/key order is - unavailable and cannot be reconstructed. +- V1 query names are case-sensitive. + Preserve multivalue occurrences and their order, then append a single-map value only when that exact value is absent from the multivalue list for the same key. + Include keys found only in the single map. + This applies AWS's header merge principle consistently to queries; + handling contradictory synthetic query maps is an edge policy, not an AWS guarantee. + Encode through url.Values.Encode; + original encoding/key order is unavailable and cannot be reconstructed. - V2: use rawQueryString unchanged, including repeated parameters and encoding. Never reconstruct it from the comma-flattened map or split values on commas. Leave query parsing and its errors to the handler's ordinary net/url APIs. Request-line syntax validation rejects literal ASCII whitespace/control bytes; it does not decode query values or reject malformed query percent escapes. -- Populate RequestURI from the escaped path and query. V1 necessarily receives - a reconstructed target. A lost empty trailing question mark or API mapping - prefix cannot be recovered. Never populate Fragment, User, or Opaque. -- Leave URL.Scheme and URL.Host empty, as for an ordinary origin-form server - request. Do not derive them from forwarding headers. +- Populate RequestURI from the escaped path and query. + V1 necessarily receives a reconstructed target. + A lost empty trailing question mark or API mapping prefix cannot be recovered. + Never populate Fragment, User, or Opaque. +- Leave URL.Scheme and URL.Host empty, as for an ordinary origin-form server request. + Do not derive them from forwarding headers. ### Headers, cookies, and host -- Canonicalize header names with net/http's rules and validate HTTP field names - and values. Reject invalid field names and prohibited control bytes. Preserve - ordinary value text and commas without generic splitting or trimming. +- Canonicalize header names with net/http's rules and validate HTTP field names and values. + Reject invalid field names and prohibited control bytes. + Preserve ordinary value text and commas without generic splitting or trimming. - V1: merge by case-insensitive name and exact value as described for queries. - Preserve repeated values already in the multivalue list. Across differently - cased map keys, use sorted source keys for deterministic traversal because - original cross-key order is unavailable. Do not discard unrelated single-map - values. This also specifies deterministic handling of caller-created structs. -- V2: retain each flattened header value as supplied. If cookies is nonempty, - it supplies the Cookie header, joined with "; "; otherwise retain an existing - Cookie header. Dedicated cookies take precedence to avoid duplicate cookies - from contradictory typed/synthetic inputs. Do not comma-split cookie values. + Preserve repeated values already in the multivalue list. + Across differently cased map keys, use sorted source keys for deterministic traversal because original cross-key order is unavailable. + Do not discard unrelated single-map values. + This also specifies deterministic handling of caller-created structs. +- V2: retain each flattened header value as supplied. + If cookies is nonempty, it supplies the Cookie header, joined with "; + "; + otherwise retain an existing Cookie header. + Dedicated cookies take precedence to avoid duplicate cookies from contradictory typed/synthetic inputs. + Do not comma-split cookie values. - Move the selected Host header into Request.Host and remove it from Header. - Reject multiple selected host values or malformed authority syntax. If there - is no Host header, use requestContext.domainName; if both are missing, leave - Host empty. Do not trust X-Forwarded-Host to change the target host. + Reject multiple selected host values or malformed authority syntax. + If there is no Host header, use requestContext.domainName; + if both are missing, leave Host empty. + Do not trust X-Forwarded-Host to change the target host. - Leave gateway pathParameters and routeKey separate from Go ServeMux state. - Do not synthesize Request.Pattern or populate PathValue from an unrelated - gateway route. A future metadata accessor remains a separate API decision. + Do not synthesize Request.Pattern or populate PathValue from an unrelated gateway route. + A future metadata accessor remains a separate API decision. ### Method, body, and invocation metadata -- Preserve method casing; validate it as an HTTP token. No empty-method-to-GET - fallback. Unsupported application methods remain the handler's concern. -- Decode a base64 body with the standard padded encoding when flagged. Return - a conversion error on malformed base64 and discard all partial decoded data. - Otherwise use the supplied string bytes without parsing application JSON, - decompressing content, or applying a second text encoding. -- Set ContentLength to the actual decoded byte length. Reconcile a supplied - Content-Length header to that length, and remove Transfer-Encoding and Trailer - declarations because the Lambda envelope has already framed the complete body - and supplies no streaming trailers. Leave TransferEncoding and Trailer empty. - These are accepted transport normalization choices, not claims of original - client framing fidelity. +- Preserve method casing; + validate it as an HTTP token. + No empty-method-to-GET fallback. + Unsupported application methods remain the handler's concern. +- Decode a base64 body with the standard padded encoding when flagged. + Return a conversion error on malformed base64 and discard all partial decoded data. + Otherwise use the supplied string bytes without parsing application JSON, decompressing content, or applying a second text encoding. +- Set ContentLength to the actual decoded byte length. + Reconcile a supplied Content-Length header to that length, and remove Transfer-Encoding and Trailer declarations because the Lambda envelope has already framed the complete body and supplies no streaming trailers. + Leave TransferEncoding and Trailer empty. + These are accepted transport normalization choices, not claims of original client framing fidelity. - Supply http.NoBody for an empty body and a fresh readable body otherwise. - Own request headers, query value slices, and body state; do not mutate or alias - mutable request data supplied by typed callers. Application middleware can - change the request without changing the source event. -- Use the invocation context as the parent of a request context, and cancel the - child after ServeHTTP returns. Close the adapter-created body and clean up - multipart temporary files created through standard request parsing. The - gateway does not provide ordinary client-disconnect notification through this - context. Do not invent such a signal. -- Preserve the reported HTTP protocol and parsed major/minor values. Default - an absent value to HTTP/1.1 as an explicitly documented library fallback; - reject a present malformed protocol. Do not infer protocol from headers. + Own request headers, query value slices, and body state; + do not mutate or alias mutable request data supplied by typed callers. + Application middleware can change the request without changing the source event. +- Use the invocation context as the parent of a request context, and cancel the child after ServeHTTP returns. + Close the adapter-created body and clean up multipart temporary files created through standard request parsing. + The gateway does not provide ordinary client-disconnect notification through this context. + Do not invent such a signal. +- Preserve the reported HTTP protocol and parsed major/minor values. + Default an absent value to HTTP/1.1 as an explicitly documented library fallback; + reject a present malformed protocol. + Do not infer protocol from headers. - Set RemoteAddr to requestContext's source IP as supplied (empty when absent). - Do not add an invented client port or replace it with X-Forwarded-For. This - differs from the usual IP:port form but fits Go's documented field contract. -- Leave TLS nil: Lambda has no client TLS connection to expose. Do not create a - fabricated tls.ConnectionState from gateway metadata. Any later mTLS identity - support needs an explicit gateway assertion contract. + Do not add an invented client port or replace it with X-Forwarded-For. + This differs from the usual IP:port form but fits Go's documented field contract. +- Leave TLS nil: Lambda has no client TLS connection to expose. + Do not create a fabricated tls.ConnectionState from gateway metadata. + Any later mTLS identity support needs an explicit gateway assertion contract. ### Shared behavior and remaining decisions -Raw and typed entry points use the same conversion functions after their -respective validated input boundaries. Conversion failures use decision 0002's -error policy without credential-bearing details. Gateway identity remains the -separate opt-in producer from decision 0001. +Raw and typed entry points use the same conversion functions after their respective validated input boundaries. +Conversion failures use decision 0002's error policy without credential-bearing details. +Gateway identity remains the separate opt-in producer from decision 0001. -Response commitment, binary output selection, response limits, panic handling, -and optional ResponseWriter capabilities remain a separate response contract. -Gateway metadata accessors, identity interpretation, and cancellation's effect on -the final invocation result remain separate reviews. Passing cancellation to the -handler does not by itself settle whether to discard a completed response. +Response commitment, binary output selection, response limits, panic handling, and optional ResponseWriter capabilities remain a separate response contract. +Gateway metadata accessors, identity interpretation, and cancellation's effect on the final invocation result remain separate reviews. +Passing cancellation to the handler does not by itself settle whether to discard a completed response. ## Alternatives and consequences -- Parsing a V1 path as a complete URL changes literal path delimiters and can - route to a different resource. Assigning URL.Path avoids that bug. -- Using V2's flattened query map loses distinctions between repeats and literal - commas. RawQueryString preserves the representation AWS still provides. -- Multivalue replacement per whole map loses unrelated fields; replacement per - key also discards a differing single value. The documented key/value merge - rule preserves all supplied values without duplicating the single-map echo. -- Synthesizing https/TLS metadata may help existing redirect middleware, but - creates an inaccurate connection object. Document the server-request shape - and review explicit gateway metadata separately. -- Requiring every context metadata field would reject otherwise usable typed - requests. Defaults above are explicit and limited to fields unnecessary for - unambiguous route selection. +- Parsing a V1 path as a complete URL changes literal path delimiters and can route to a different resource. + Assigning URL.Path avoids that bug. +- Using V2's flattened query map loses distinctions between repeats and literal commas. + RawQueryString preserves the representation AWS still provides. +- Multivalue replacement per whole map loses unrelated fields; + replacement per key also discards a differing single value. + The documented key/value merge rule preserves all supplied values without duplicating the single-map echo. +- Synthesizing https/TLS metadata may help existing redirect middleware, but creates an inaccurate connection object. + Document the server-request shape and review explicit gateway metadata separately. +- Requiring every context metadata field would reject otherwise usable typed requests. + Defaults above are explicit and limited to fields unnecessary for unambiguous route selection. ## Validation plan -Compare relevant results with Go's HTTP request parsing and a real local HTTP -server, plus independent AWS fixtures. Cover V1 literal delimiters, V2 encoded -slashes and double escaping, Unicode, repeated slashes, malformed path escapes, -query repeats/commas, all header merge cases, cookie precedence, Host promotion, -invalid header bytes, body bytes/base64 errors, protocol handling, source address, -context cancellation, body/multipart cleanup, and caller-owned data isolation. -Exercise the same HTTP assertions through raw and typed entry points when the -response contract makes those public entry points implementable. +Compare relevant results with Go's HTTP request parsing and a real local HTTP server, plus independent AWS fixtures. +Cover V1 literal delimiters, V2 encoded slashes and double escaping, Unicode, repeated slashes, malformed path escapes, query repeats/commas, all header merge cases, cookie precedence, Host promotion, invalid header bytes, body bytes/base64 errors, protocol handling, source address, context cancellation, body/multipart cleanup, and caller-owned data isolation. +Exercise the same HTTP assertions through raw and typed entry points when the response contract makes those public entry points implementable. ## Resolution -The user approved this request-translation contract. Shared V1/V2 conversion and -request-lifetime management are implemented in request.go. Response behavior -remains a separate decision; public invocation methods are not implemented yet. - -Validation passed: request observations compared with a real local HTTP server, -independent request-target parsing, header/query merge and ownership checks, -binary bodies, malformed-input rejection, context cancellation, and body/multipart -cleanup on normal return and panic. Race tests, vet, formatting, and Linux arm64 -and amd64 builds passed. V2 target fuzzing completed 687,712 executions and V1 -query fuzzing completed 113,979 executions without failures, following an earlier -1,054,667-execution path-only run. - -Implementation limit: automatic multipart cleanup reaches forms attached to the -served request. Forms first parsed on middleware-created request copies are not -visible to the adapter, matching the standard Go server's limitation. The -recommendation to document middleware ownership for those forms is included in -[decision 0006](0006-buffered-responses.md) for review. Do not claim cleanup of -every independently created request or multipart reader. +The user approved this request-translation contract. +Shared V1/V2 conversion and request-lifetime management are implemented in request.go. +Response behavior remains a separate decision; +public invocation methods are not implemented yet. + +Validation passed: request observations compared with a real local HTTP server, independent request-target parsing, header/query merge and ownership checks, binary bodies, malformed-input rejection, context cancellation, and body/multipart cleanup on normal return and panic. +Race tests, vet, formatting, and Linux arm64 and amd64 builds passed. +V2 target fuzzing completed 687,712 executions and V1 query fuzzing completed 113,979 executions without failures, following an earlier 1,054,667-execution path-only run. + +Implementation limit: automatic multipart cleanup reaches forms attached to the served request. +Forms first parsed on middleware-created request copies are not visible to the adapter, matching the standard Go server's limitation. +The recommendation to document middleware ownership for those forms is included in [decision 0006](0006-buffered-responses.md) for review. +Do not claim cleanup of every independently created request or multipart reader. diff --git a/docs/decisions/0006-buffered-responses.md b/docs/decisions/0006-buffered-responses.md index 48fc01e..1a00cf3 100644 --- a/docs/decisions/0006-buffered-responses.md +++ b/docs/decisions/0006-buffered-responses.md @@ -1,247 +1,218 @@ # 0006: Buffered HTTP responses and invocation completion -Status: accepted with the 2026-09-14 review refinements. Private buffered writer, -header/body finalization and SDK response projections implemented on 2026-09-15. +Status: accepted with the 2026-09-14 review refinements. +Private buffered writer, header/body finalization and SDK response projections implemented on 2026-09-15. Public invocation still awaits the minimum identity and exported error contracts. -Reviewed against the implemented decoder/request layer on 2026-09-14. The -[plan review](../reviews/2026-09-14-plan-review.md) was approved by the user and its -refinements are incorporated below. Decision 0010 now settles the exact metadata -budget and V2 joinable-field set; their private shared layer is implemented. +Reviewed against the implemented decoder/request layer on 2026-09-14. +The [plan review](../reviews/2026-09-14-plan-review.md) was approved by the user and its refinements are incorporated below. +Decision 0010 now settles the exact metadata budget and V2 joinable-field set; +their private shared layer is implemented. ## Question -How should the adapter capture an HTTP response, encode it for each gateway -format, and handle features or failures that a buffered invocation cannot carry? +How should the adapter capture an HTTP response, encode it for each gateway format, and handle features or failures that a buffered invocation cannot carry? ## Evidence -- Go's ResponseWriter commits a final status and headers once, supplies an - implicit 200 on Write, and supports automatic content-type detection. The - ordinary server handles informational responses, trailers, and connection - capabilities that are not represented by a buffered gateway response. - [ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter) - [DetectContentType](https://pkg.go.dev/net/http#DetectContentType) - [ResponseController](https://pkg.go.dev/net/http#ResponseController) -- AWS V1 responses support multivalue headers. V2 has single-string headers and - a cookies array whose entries become separate Set-Cookie fields. Explicit - statusCode avoids V2's inferred-response behavior. - [REST response format](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) - [HTTP API response formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) -- AWS requires base64 encoding for binary REST proxy responses and matching - binary media configuration. The first Accept value affects REST binary - handling; AWS recommends */* when clients such as browsers control that order. +- Go's ResponseWriter commits a final status and headers once, supplies an implicit 200 on Write, and supports automatic content-type detection. + The ordinary server handles informational responses, trailers, and connection capabilities that are not represented by a buffered gateway response. + [ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter) [DetectContentType](https://pkg.go.dev/net/http#DetectContentType) [ResponseController](https://pkg.go.dev/net/http#ResponseController) +- AWS V1 responses support multivalue headers. + V2 has single-string headers and a cookies array whose entries become separate Set-Cookie fields. + Explicit statusCode avoids V2's inferred-response behavior. + [REST response format](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) [HTTP API response formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) +- AWS requires base64 encoding for binary REST proxy responses and matching binary media configuration. + The first Accept value affects REST binary handling; + AWS recommends */* when clients such as browsers control that order. [Binary responses](https://docs.aws.amazon.com/apigateway/latest/developerguide/lambda-proxy-binary-media.html) -- Lambda buffered responses have a 6 MB maximum, regardless of API Gateway's - larger service limit. JSON quoting, headers, cookies, and base64 consume that - payload budget. Streaming is a separate invocation contract. +- Lambda buffered responses have a 6 MB maximum, regardless of API Gateway's larger service limit. + JSON quoting, headers, cookies, and base64 consume that payload budget. + Streaming is a separate invocation contract. [Lambda buffered/streaming limits](https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html) -- The inspected Go 1.27.1 server rejects writes to 204/304 with ErrBodyNotAllowed, - omits HEAD bodies, and validates declared content length. Its cleanup removes - multipart files reachable from the request it served. WithContext makes a - request copy; forms parsed later on that copy are not visible to the original. - [Go server source](https://go.dev/src/net/http/server.go) - [WithContext](https://pkg.go.dev/net/http#Request.WithContext) +- The inspected Go 1.27.1 server rejects writes to 204/304 with ErrBodyNotAllowed, omits HEAD bodies, and validates declared content length. + Its cleanup removes multipart files reachable from the request it served. + WithContext makes a request copy; + forms parsed later on that copy are not visible to the original. + [Go server source](https://go.dev/src/net/http/server.go) [WithContext](https://pkg.go.dev/net/http#Request.WithContext) - Our decoder and request conversion are independent of response capture. - serveHTTP already cancels the request child context and cleans up on both - return and panic. It reports cleanup errors without file paths or input values. + serveHTTP already cancels the request child context and cleans up on both return and panic. + It reports cleanup errors without file paths or input values. ## Recommendation ### Writer commitment and ordinary responses - Implement a private buffered ResponseWriter shared by all entry points. - First final WriteHeader commits the status and a deep header snapshot; later - calls and header mutations cannot rewrite the committed response. Write commits - 200 when needed, and a handler that writes nothing returns 200 with an empty body. -- Accept final statuses 200 through 599. An invalid first status records an - adapter fault; WriteHeader cannot return an error, so completion must report - the fault. Do not log it or synthesize a different application response. -- Detect an absent Content-Type from up to the first 512 buffered body bytes, - unless Content-Encoding is set. An explicitly present nil/empty header slice - suppresses automatic content type, following net/http's header convention. - Empty bodies do not acquire a sniffed type. Do not generate Date or connection - management headers inside edge; the gateway owns the network response. -- Validate committed header names/values and require encodable UTF-8 header - strings so raw and typed paths cannot disagree through replacement encoding. - Canonicalize header names and retain all values; use deterministic traversal - for differently cased keys supplied by direct Header map assignment. -- Derive Content-Length from the buffered bytes when absent. If explicitly - present with a nil/empty slice, suppress inference. Otherwise require one valid - nonnegative decimal length and enforce it: an - overflowing Write returns http.ErrContentLength; a mismatch at completion is - an invocation error. Transport faults remain faults even if the handler ignores - a Write error. Ordinary application 4xx/5xx responses still return nil Go error. -- For HEAD, accept/count writes and retain at most the sniffing prefix, but emit - no body. Capture the original request method before application code runs. - Honor a supplied representation length; otherwise infer a length only - when the handler actually wrote representation bytes. Do not require HEAD's - declared length to match an omitted representation body. -- For 204/205/304, emit no body and return http.ErrBodyNotAllowed from a nonempty - Write. A zero-length Write commits an implicit response and returns (0, nil). - That - error alone does not invalidate the committed bodyless response. Suppress - Content-Length for these statuses and Content-Type for 304. Bodyless 205 follows - RFC 9110 and intentionally differs from Go 1.27.1's body-allowed helper. + First final WriteHeader commits the status and a deep header snapshot; + later calls and header mutations cannot rewrite the committed response. + Write commits 200 when needed, and a handler that writes nothing returns 200 with an empty body. +- Accept final statuses 200 through 599. + An invalid first status records an adapter fault; + WriteHeader cannot return an error, so completion must report the fault. + Do not log it or synthesize a different application response. +- Detect an absent Content-Type from up to the first 512 buffered body bytes, unless Content-Encoding is set. + An explicitly present nil/empty header slice suppresses automatic content type, following net/http's header convention. + Empty bodies do not acquire a sniffed type. + Do not generate Date or connection management headers inside edge; + the gateway owns the network response. +- Validate committed header names/values and require encodable UTF-8 header strings so raw and typed paths cannot disagree through replacement encoding. + Canonicalize header names and retain all values; + use deterministic traversal for differently cased keys supplied by direct Header map assignment. +- Derive Content-Length from the buffered bytes when absent. + If explicitly present with a nil/empty slice, suppress inference. + Otherwise require one valid nonnegative decimal length and enforce it: an overflowing Write returns http.ErrContentLength; + a mismatch at completion is an invocation error. + Transport faults remain faults even if the handler ignores a Write error. + Ordinary application 4xx/5xx responses still return nil Go error. +- For HEAD, accept/count writes and retain at most the sniffing prefix, but emit no body. + Capture the original request method before application code runs. + Honor a supplied representation length; + otherwise infer a length only when the handler actually wrote representation bytes. + Do not require HEAD's declared length to match an omitted representation body. +- For 204/205/304, emit no body and return http.ErrBodyNotAllowed from a nonempty Write. + A zero-length Write commits an implicit response and returns (0, nil). + That error alone does not invalidate the committed bodyless response. + Suppress Content-Length for these statuses and Content-Type for 304. + Bodyless 205 follows RFC 9110 and intentionally differs from Go 1.27.1's body-allowed helper. [205 semantics](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.6) ### Capabilities the buffered adapter cannot provide -- Do not implement Flusher, Hijacker, Pusher, CloseNotifier, full-duplex, or - connection-deadline methods. ResponseController capability requests naturally - return an error matching http.ErrNotSupported. Request.Context is the lifecycle - signal; it does not imply a client-disconnect signal. -- Treat explicit 1xx writes, including 101 upgrades, as unsupported invocation - faults. A buffered proxy response cannot convey their separate timing. This - is an intentional compatibility limit, rather than pretending they were sent. -- Reject declared trailers and TrailerPrefix values. Reject upgrades. Remove - connection-specific response headers and fields nominated by Connection; +- Do not implement Flusher, Hijacker, Pusher, CloseNotifier, full-duplex, or connection-deadline methods. + ResponseController capability requests naturally return an error matching http.ErrNotSupported. + Request.Context is the lifecycle signal; + it does not imply a client-disconnect signal. +- Treat explicit 1xx writes, including 101 upgrades, as unsupported invocation faults. + A buffered proxy response cannot convey their separate timing. + This is an intentional compatibility limit, rather than pretending they were sent. +- Reject declared trailers and TrailerPrefix values. + Reject upgrades. + Remove connection-specific response headers and fields nominated by Connection; never claim a chunked transfer encoding for an already buffered payload. ### Gateway response encoding -- Return AWS response structs from typed methods. Invoke serializes the selected - response with encoding/json/v2 directly. Do not JSON-round-trip typed events - or responses. Always set an explicit final status and body representation. +- Return AWS response structs from typed methods. + Invoke serializes the selected response with encoding/json/v2 directly. + Do not JSON-round-trip typed events or responses. + Always set an explicit final status and body representation. - V1 uses MultiValueHeaders for every response header, including each Set-Cookie. Leave the redundant single-value Headers map empty. -- V2 moves Set-Cookie values into Cookies. Join repeated values with a comma and - space only for a documented, audited set of list-valued fields. Reject repeated - singleton or unknown fields. Preserve order and commas inside individual - values. Leave MultiValueHeaders empty. Decision 0010 specifies the initial list; - V2 cannot retain general header line boundaries, so applications requiring - them must choose V1. -- Emit text directly only when bytes are valid UTF-8, Content-Encoding is absent - or identity, and the media type is textual: text/*, application/json, - application/xml, application/javascript, application/x-www-form-urlencoded, - or a +json/+xml suffix. Otherwise base64 encode the bytes and set - IsBase64Encoded. An empty emitted body is not base64 encoded. Missing/invalid - media type is conservatively binary if it was not resolved by sniffing. -- Binary selection changes only the envelope representation, not the application - Content-Type or byte sequence. REST deployments must configure binary media - types; document the browser Accept caveat in the runnable Lambda example. +- V2 moves Set-Cookie values into Cookies. + Join repeated values with a comma and space only for a documented, audited set of list-valued fields. + Reject repeated singleton or unknown fields. + Preserve order and commas inside individual values. + Leave MultiValueHeaders empty. + Decision 0010 specifies the initial list; + V2 cannot retain general header line boundaries, so applications requiring them must choose V1. +- Emit text directly only when bytes are valid UTF-8, Content-Encoding is absent or identity, and the media type is textual: text/*, application/json, application/xml, application/javascript, application/x-www-form-urlencoded, or a +json/+xml suffix. + Otherwise base64 encode the bytes and set IsBase64Encoded. + An empty emitted body is not base64 encoded. + Missing/invalid media type is conservatively binary if it was not resolved by sniffing. +- Binary selection changes only the envelope representation, not the application Content-Type or byte sequence. + REST deployments must configure binary media types; + document the browser Accept caveat in the runnable Lambda example. ### Bounds and completion -- Initially cap stored response body bytes at 6,291,456 (6 MiB). AWS explicitly - defines its documented MB unit as 1,024 KB in the - [Lambda quotas](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html). - This corrects the draft's earlier 6,000,000-byte interpretation. Reject a Write - crossing the cap without retaining a partial write, and record a sticky fault. - Check base64 expansion before allocating the encoded body. No new public - size-tuning option is proposed for the first implementation. -- Invoke uses json/v2.MarshalWrite with a bounded destination to enforce - 6,291,456 bytes including JSON overhead while retaining encoded output. - A body below the cap can still fail this check. Preflight committed metadata - using decision 0010's weighted resource charge before copying. - A typed method cannot measure the exact envelope produced by its caller's - serializer without violating the agreed JSON-free typed boundary; document - that the caller/runtime owns that final limit. Apply the common body/expansion - caps on both paths. Header collections are caller-controlled; a body cap is - not a total-process-memory guarantee. -- Propagate handler panics to the Lambda runtime after cleanup; do not convert - them into successful HTTP 500 responses. Direct Go callers retain normal panic - behavior. Do not add logging to the adapter. -- One invocation scope covers conversion, identity, serving, finalization, and - cleanup. Reject an already-canceled parent before running application code. - Before returning success, check the invocation parent context. If it is - canceled, return its error and no usable response. Check the parent, not the - child context canceled during normal cleanup. This deliberately - makes cancellation win over a just-completed HTTP response when already known. -- Cleanup failures are invocation failures even after a handler produced a - response. Keep diagnostics free of file paths, bodies, tokens, and header values. - Preserve a primary operation error and join sanitized cleanup failures. Parent - cancellation replaces only an otherwise successful result. Preserve errors.Is - for context and standard HTTP errors; review exported adapter error categories - separately before publishing the invocation methods. +- Initially cap stored response body bytes at 6,291,456 (6 MiB). + AWS explicitly defines its documented MB unit as 1,024 KB in the [Lambda quotas](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html). + This corrects the draft's earlier 6,000,000-byte interpretation. + Reject a Write crossing the cap without retaining a partial write, and record a sticky fault. + Check base64 expansion before allocating the encoded body. + No new public size-tuning option is proposed for the first implementation. +- Invoke uses json/v2.MarshalWrite with a bounded destination to enforce 6,291,456 bytes including JSON overhead while retaining encoded output. + A body below the cap can still fail this check. + Preflight committed metadata using decision 0010's weighted resource charge before copying. + A typed method cannot measure the exact envelope produced by its caller's serializer without violating the agreed JSON-free typed boundary; + document that the caller/runtime owns that final limit. + Apply the common body/expansion caps on both paths. + Header collections are caller-controlled; + a body cap is not a total-process-memory guarantee. +- Propagate handler panics to the Lambda runtime after cleanup; + do not convert them into successful HTTP 500 responses. + Direct Go callers retain normal panic behavior. + Do not add logging to the adapter. +- One invocation scope covers conversion, identity, serving, finalization, and cleanup. + Reject an already-canceled parent before running application code. + Before returning success, check the invocation parent context. + If it is canceled, return its error and no usable response. + Check the parent, not the child context canceled during normal cleanup. + This deliberately makes cancellation win over a just-completed HTTP response when already known. +- Cleanup failures are invocation failures even after a handler produced a response. + Keep diagnostics free of file paths, bodies, tokens, and header values. + Preserve a primary operation error and join sanitized cleanup failures. + Parent cancellation replaces only an otherwise successful result. + Preserve errors.Is for context and standard HTTP errors; + review exported adapter error categories separately before publishing the invocation methods. ### Multipart cleanup limit discovered during implementation -The request layer removes multipart files attached to the served request. Like -Go's server, it cannot discover a form first parsed on an independently copied -request. Recommend documenting that middleware which makes such a copy owns -cleanup of forms it creates there. Do not preparse multipart bodies: that would -consume bodies before handlers choose parsing limits and would change streaming -read behavior. This limitation is surfaced for review; there is no hidden global -temporary-directory scan or eager parsing workaround in the implementation. +The request layer removes multipart files attached to the served request. +Like Go's server, it cannot discover a form first parsed on an independently copied request. +Recommend documenting that middleware which makes such a copy owns cleanup of forms it creates there. +Do not preparse multipart bodies: that would consume bodies before handlers choose parsing limits and would change streaming read behavior. +This limitation is surfaced for review; +there is no hidden global temporary-directory scan or eager parsing workaround in the implementation. ## Alternatives and consequences -- httptest.ResponseRecorder is a useful test reference but advertises Flush and - does not enforce every production server behavior. Do not expose it as our - production writer merely to avoid implementing the approved contract. -- Silently dropping informational responses or trailers would let applications - believe unsupported output reached clients. Explicit faults are inspectable. -- Base64 for every response is simpler but adds size overhead and REST deployment - requirements even for ordinary JSON/text responses. UTF-8 alone is insufficient - to decide whether a declared binary representation should be encoded. -- Returning HTTP 500 for all faults obscures the Lambda invocation failure signal - and contradicts decision 0002. Application-written 500 responses remain valid. -- Typed callers retain their no-JSON boundary, at the cost of owning final - serialization limits. The library cannot promise exact codec parity with an - external runtime whose formatting options the caller controls. +- httptest.ResponseRecorder is a useful test reference but advertises Flush and does not enforce every production server behavior. + Do not expose it as our production writer merely to avoid implementing the approved contract. +- Silently dropping informational responses or trailers would let applications believe unsupported output reached clients. + Explicit faults are inspectable. +- Base64 for every response is simpler but adds size overhead and REST deployment requirements even for ordinary JSON/text responses. + UTF-8 alone is insufficient to decide whether a declared binary representation should be encoded. +- Returning HTTP 500 for all faults obscures the Lambda invocation failure signal and contradicts decision 0002. + Application-written 500 responses remain valid. +- Typed callers retain their no-JSON boundary, at the cost of owning final serialization limits. + The library cannot promise exact codec parity with an external runtime whose formatting options the caller controls. ## Validation plan -Use a real net/http server for commitment, sniffing, HEAD, 204/304, and length -comparisons. Exercise multiple Write calls, post-commit mutation, ignored writer -errors, status/field validation, size boundaries, binary/text round trips, v1 -multivalue headers, v2 cookies, unsupported capability checks, panic cleanup, -parent cancellation, and cleanup failure propagation. Verify raw registration -through lambda.NewHandler(adapter).Invoke and typed method registration through -the SDK wrapper once the remaining gateway-identity path permits public wiring. -Add explicit 205 divergence, zero-length writes, suppressed inferred length, -exact envelope boundaries, metadata bounds, and sequential/concurrent isolation. +Use a real net/http server for commitment, sniffing, HEAD, 204/304, and length comparisons. +Exercise multiple Write calls, post-commit mutation, ignored writer errors, status/field validation, size boundaries, binary/text round trips, v1 multivalue headers, v2 cookies, unsupported capability checks, panic cleanup, parent cancellation, and cleanup failure propagation. +Verify raw registration through lambda.NewHandler(adapter).Invoke and typed method registration through the SDK wrapper once the remaining gateway-identity path permits public wiring. +Add explicit 205 divergence, zero-length writes, suppressed inferred length, exact envelope boundaries, metadata bounds, and sequential/concurrent isolation. ## Resolution -Accepted by the user on 2026-09-14 with the plan-review enhancements. Implement -settled behavior now; resolve the separately identified metadata, header-list, -error API, and identity details before their dependent implementation. +Accepted by the user on 2026-09-14 with the plan-review enhancements. +Implement settled behavior now; +resolve the separately identified metadata, header-list, error API, and identity details before their dependent implementation. ## Implementation and validation — 2026-09-15 -buffered.go provides the private writer, synchronous serveBuffered call and typed -AWS response projections. It captures the original request method, snapshots -headers on the first final status, retains bounded body storage and reports -sticky write failures at completion. Automatic fields consume the remaining -metadata budget and respect explicit suppression and Connection nominations. -Typed projection does no JSON work; the separate raw encoder still accounts for -the complete envelope, including its overhead. +buffered.go provides the private writer, synchronous serveBuffered call and typed AWS response projections. +It captures the original request method, snapshots headers on the first final status, retains bounded body storage and reports sticky write failures at completion. +Automatic fields consume the remaining metadata budget and respect explicit suppression and Connection nominations. +Typed projection does no JSON work; +the separate raw encoder still accounts for the complete envelope, including its overhead. HEAD counts successful representation writes while retaining at most 512 bytes. -It honors the supplied length without requiring that all representation bytes -be written, but rejects writes exceeding a supplied length, as Go's server does. -Its emitted body is empty. Statuses 204/205/304 reject nonempty writes without -turning that rejection alone into an invocation failure. Bodyless 205 remains -the accepted intentional difference from Go's behavior. - -Installed Go 1.27.1 server source was checked for response.WriteHeader, -response.write and chunkWriter.writeHeader. Once final status is committed, all -later WriteHeader calls are ignored, including invalid or informational codes. -This follows Go's superfluous-status behavior; it does not permit an initial -informational response. Late TrailerPrefix entries or represented Trailer -declarations still fail at completion. Ordinary late header-map changes cannot -alter the committed response. Header-map edits that are added and removed before -the writer observes them have no transport effect. - -Tests compare small-response status, body, Content-Type, Content-Length, -suppression, redirects and HEAD/204/304 behavior with Go 1.27's real HTTP server -over its in-memory test network. Additional cases cover 205, status validation, -ignored write errors, large HEAD representations, exact stored-body boundaries, -raw-envelope overhead, text/base64 conversion, cookies and repeated fields, -unsupported ResponseController capabilities and late trailers. Both Gateway -request formats exercise ServeMux path parameters, ServeContent ranges, gzip -output and request-body echoing. Invocation-scope tests cover cleanup on write -failure, panic, parent cancellation and cleanup failure. - -At that implementation stage these tests assembled private primitives. Update -2026-09-16: public Invoke/HandleV1/HandleV2 now use the same scope and establish -opted-in native gateway identity under decision 0018. Public-boundary tests cover -SDK registration, ServeMux path parameters/redirects, ServeContent ranges, cookies, -binary responses, complete raw-envelope limits, multipart cleanup and isolation. +It honors the supplied length without requiring that all representation bytes be written, but rejects writes exceeding a supplied length, as Go's server does. +Its emitted body is empty. +Statuses 204/205/304 reject nonempty writes without turning that rejection alone into an invocation failure. +Bodyless 205 remains the accepted intentional difference from Go's behavior. + +Installed Go 1.27.1 server source was checked for response.WriteHeader, response.write and chunkWriter.writeHeader. +Once final status is committed, all later WriteHeader calls are ignored, including invalid or informational codes. +This follows Go's superfluous-status behavior; +it does not permit an initial informational response. +Late TrailerPrefix entries or represented Trailer declarations still fail at completion. +Ordinary late header-map changes cannot alter the committed response. +Header-map edits that are added and removed before the writer observes them have no transport effect. + +Tests compare small-response status, body, Content-Type, Content-Length, suppression, redirects and HEAD/204/304 behavior with Go 1.27's real HTTP server over its in-memory test network. +Additional cases cover 205, status validation, ignored write errors, large HEAD representations, exact stored-body boundaries, raw-envelope overhead, text/base64 conversion, cookies and repeated fields, unsupported ResponseController capabilities and late trailers. +Both Gateway request formats exercise ServeMux path parameters, ServeContent ranges, gzip output and request-body echoing. +Invocation-scope tests cover cleanup on write failure, panic, parent cancellation and cleanup failure. + +At that implementation stage these tests assembled private primitives. +Update 2026-09-16: public Invoke/HandleV1/HandleV2 now use the same scope and establish opted-in native gateway identity under decision 0018. +Public-boundary tests cover SDK registration, ServeMux path parameters/redirects, ServeContent ranges, cookies, binary responses, complete raw-envelope limits, multipart cleanup and isolation. Streaming HTTP writer behavior remains a separate implementation milestone. -Validation: focused writer/integration tests, full race suite, vet, formatting -and diff checks, and Linux arm64/amd64 builds pass on Go 1.27.1. No live AWS -resources were invoked. +Validation: focused writer/integration tests, full race suite, vet, formatting and diff checks, and Linux arm64/amd64 builds pass on Go 1.27.1. +No live AWS resources were invoked. diff --git a/docs/decisions/0007-response-metadata.md b/docs/decisions/0007-response-metadata.md index fa716c0..4aa8282 100644 --- a/docs/decisions/0007-response-metadata.md +++ b/docs/decisions/0007-response-metadata.md @@ -1,61 +1,54 @@ # 0007: Response metadata budgets and V2 list fields -Status: historical qualification; concrete metadata policy resolved by accepted -decision 0010 on 2026-09-15. +Status: historical qualification; +concrete metadata policy resolved by accepted decision 0010 on 2026-09-15. -The concrete follow-up is [decision 0010](0010-response-header-design.md), proposed -after the streaming bridge implementation. It recommends a broader field audit, -weighted resource accounting, and separate encoded-output limits. The user has -accepted its measured default and override; this record preserves the earlier -qualification and the superseded initial proposal below. +The concrete follow-up is [decision 0010](0010-response-header-design.md), proposed after the streaming bridge implementation. +It recommends a broader field audit, weighted resource accounting, and separate encoded-output limits. +The user has accepted its measured default and override; +this record preserves the earlier qualification and the superseded initial proposal below. ## Accepted qualification -The user accepted the qualified recommendation: keep the documented Lambda -envelope limit and semantics-preserving header translation, but do not freeze -the proposed 64 KiB/1,024-entry limits without stronger justification. The ten -fields below are an initial audit, not an AWS allowlist or an exhaustive set of -valid list-valued fields. Broader compatibility was considered in decision 0010's -accepted 25-field audit and measured resource policy. - -The numeric proposal was a library resource policy, not an AWS quota or a result -of header-allocation benchmarks. Its intended protection was to bound additional -snapshot allocations, not memory already allocated by the application. Current -AWS HTTP API quotas explicitly limit the request line and headers; that does not -establish the same limit for an edge response snapshot. +The user accepted the qualified recommendation: keep the documented Lambda envelope limit and semantics-preserving header translation, but do not freeze the proposed 64 KiB/1,024-entry limits without stronger justification. +The ten fields below are an initial audit, not an AWS allowlist or an exhaustive set of valid list-valued fields. +Broader compatibility was considered in decision 0010's accepted 25-field audit and measured resource policy. + +The numeric proposal was a library resource policy, not an AWS quota or a result of header-allocation benchmarks. +Its intended protection was to bound additional snapshot allocations, not memory already allocated by the application. +Current AWS HTTP API quotas explicitly limit the request line and headers; +that does not establish the same limit for an edge response snapshot. [HTTP API quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html) ## Question and evidence -Decision 0006 and the accepted plan review require bounded metadata snapshots -and an audited list of fields whose repeated values can be joined for V2. They -deliberately left the numeric budgets and initial field set unspecified. +Decision 0006 and the accepted plan review require bounded metadata snapshots and an audited list of fields whose repeated values can be joined for V2. +They deliberately left the numeric budgets and initial field set unspecified. The existing request helper preserves multiple values and canonicalizes keys; -the new response path must not inherit its unbounded copying or assume every -field is a list. AWS V2 provides a single string per header and a separate Cookies -array; V1 supports multiple values. +the new response path must not inherit its unbounded copying or assume every field is a list. +AWS V2 provides a single string per header and a separate Cookies array; +V1 supports multiple values. [AWS payload formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) -HTTP does not define one universal field-size limit. A library metadata budget -is our resource policy, not a claim about gateway quotas. +HTTP does not define one universal field-size limit. +A library metadata budget is our resource policy, not a claim about gateway quotas. [HTTP field limits](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.4) ## Initial proposal retained for discussion (not accepted as written) -Before sorting, canonicalizing, or cloning a committed Header map, allow at most -64 KiB (65,536 bytes) of names plus values and 1,024 entries. Count each original -map key's bytes once and every value's bytes. Count max(1, len(values)) entries -per original key, including empty slices, so empty values cannot bypass the -entry bound. Include Set-Cookie and fields later stripped from the response. -Check incrementally without overflowing counters. Neither limit is configurable -in the first implementation. +Before sorting, canonicalizing, or cloning a committed Header map, allow at most 64 KiB (65,536 bytes) of names plus values and 1,024 entries. +Count each original map key's bytes once and every value's bytes. +Count max(1, len(values)) entries per original key, including empty slices, so empty values cannot bypass the entry bound. +Include Set-Cookie and fields later stripped from the response. +Check incrementally without overflowing counters. +Neither limit is configurable in the first implementation. -The budget protects adapter-created copies; it cannot prevent an application -from allocating its own Header map. The separate 6 MiB serialized-envelope -limit still applies to Invoke. These metadata limits apply equally to raw and -typed entry points. Generated Content-Type/Content-Length have small fixed -overhead outside the application snapshot budget. +The budget protects adapter-created copies; +it cannot prevent an application from allocating its own Header map. +The separate 6 MiB serialized-envelope limit still applies to Invoke. +These metadata limits apply equally to raw and typed entry points. +Generated Content-Type/Content-Length have small fixed overhead outside the application snapshot budget. For V2, initially allow comma-space joining for exactly these repeated fields: @@ -65,31 +58,31 @@ For V2, initially allow comma-space joining for exactly these repeated fields: | Cache-Control | [RFC 9111 section 5.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2) | | Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Expose-Headers | [Fetch field grammar](https://fetch.spec.whatwg.org/#http-new-header-syntax) | -Each has a list grammar. Preserve order, duplicates, and embedded commas without -parsing field-specific parameters. This authorizes joining, not semantic -validation of every field. A single value for any valid field passes through. -Set-Cookie keeps its separate Cookies treatment. Reject other repeated fields, -including Location, Content-Type, Access-Control-Allow-Origin, and unknown -extension fields; do not silently select the first or last value. Further list -fields can be added after checking their definitions and consumer requirements. +Each has a list grammar. +Preserve order, duplicates, and embedded commas without parsing field-specific parameters. +This authorizes joining, not semantic validation of every field. +A single value for any valid field passes through. +Set-Cookie keeps its separate Cookies treatment. +Reject other repeated fields, including Location, Content-Type, Access-Control-Allow-Origin, and unknown extension fields; +do not silently select the first or last value. +Further list fields can be added after checking their definitions and consumer requirements. -WWW-Authenticate allows multiple challenges, but some clients handle separate -lines better. V2 cannot promise separate lines; applications requiring them need -V1. Hop-by-hop fields are removed under decision 0006 before V2 joining. +WWW-Authenticate allows multiple challenges, but some clients handle separate lines better. +V2 cannot promise separate lines; +applications requiring them need V1. +Hop-by-hop fields are removed under decision 0006 before V2 joining. ## Alternatives and consequences -- Using the full payload budget for metadata permits disproportionate map/slice - overhead. A byte limit alone does not constrain large slices of empty values. -- Much smaller limits could unnecessarily constrain cookies and application - metadata. The proposed 64 KiB/1,024 bounds are library defaults chosen for a - generous finite snapshot, not documented AWS allowances. -- An exhaustive registry or public join callback adds maintenance or API surface - before there is a demonstrated need. The initial set covers common response, - cache, authentication, and CORS fields; other repeated fields fail visibly. +- Using the full payload budget for metadata permits disproportionate map/slice overhead. + A byte limit alone does not constrain large slices of empty values. +- Much smaller limits could unnecessarily constrain cookies and application metadata. + The proposed 64 KiB/1,024 bounds are library defaults chosen for a generous finite snapshot, not documented AWS allowances. +- An exhaustive registry or public join callback adds maintenance or API surface before there is a demonstrated need. + The initial set covers common response, cache, authentication, and CORS fields; + other repeated fields fail visibly. ## Resolution -The user agreed with the qualification above, then approved the evidence-based -resource budget and broader field audit in decision 0010. Follow that later -record for the current contract and implementation status. +The user agreed with the qualification above, then approved the evidence-based resource budget and broader field audit in decision 0010. +Follow that later record for the current contract and implementation status. diff --git a/docs/decisions/0008-streaming-architecture.md b/docs/decisions/0008-streaming-architecture.md index 637e676..03e2913 100644 --- a/docs/decisions/0008-streaming-architecture.md +++ b/docs/decisions/0008-streaming-architecture.md @@ -1,86 +1,81 @@ # 0008: Streaming architecture alongside buffered invocation -Status: architecture accepted; concrete boundaries proposed in decision 0009. +Status: architecture accepted; +concrete boundaries proposed in decision 0009. ## Question -How can edge support streaming HTTP responses without changing the accepted -buffered/raw/typed contracts or abandoning ordinary net/http handlers? +How can edge support streaming HTTP responses without changing the accepted buffered/raw/typed contracts or abandoning ordinary net/http handlers? ## AWS evidence -- API Gateway currently supports response streaming for REST APIs, including - Regional, private, and edge-optimized endpoints. HTTP APIs and request - streaming are not supported. Integration response transfer mode must be - STREAM. Streams can run for up to 15 minutes, subject to invocation and idle - timeouts; a disconnected client need not stop Lambda execution. +- API Gateway currently supports response streaming for REST APIs, including Regional, private, and edge-optimized endpoints. + HTTP APIs and request streaming are not supported. + Integration response transfer mode must be STREAM. + Streams can run for up to 15 minutes, subject to invocation and idle timeouts; + a disconnected client need not stop Lambda execution. [Streaming support and constraints](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) -- Lambda proxy streaming uses InvokeWithResponseStream and a different - integration URI ending in response-streaming-invocations. Input remains the - REST proxy event. Output is JSON metadata, eight NUL delimiter bytes, then raw - payload bytes. The delimiter must appear within the first 16 KB. Metadata - supports statusCode, headers, multiValueHeaders, and cookies. There is no - base64 body field. These deployment and framing requirements must match. +- Lambda proxy streaming uses InvokeWithResponseStream and a different integration URI ending in response-streaming-invocations. + Input remains the REST proxy event. + Output is JSON metadata, eight NUL delimiter bytes, then raw payload bytes. + The delimiter must appear within the first 16 KB. + Metadata supports statusCode, headers, multiValueHeaders, and cookies. + There is no base64 body field. + These deployment and framing requirements must match. [Lambda streaming integration contract](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) -- The full API Gateway streaming guide explicitly states that streaming can - exceed the ordinary 10 MB response limit. Use that service-specific guide over - the conflicting 10 MB statement in an AWS .NET blog article; do not impose a - 10 MB streaming cap. Lambda documents a separate 200 MB streamed-response - ceiling versus 6 MB buffered. Both stages can apply bandwidth shaping; the - gateway describes 2 MB/s after its first 10 MB. No deployed throughput or - boundary measurements have been performed by this project. - [API Gateway streaming guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) - [Lambda streaming](https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html) +- The full API Gateway streaming guide explicitly states that streaming can exceed the ordinary 10 MB response limit. + Use that service-specific guide over the conflicting 10 MB statement in an AWS .NET blog article; + do not impose a 10 MB streaming cap. + Lambda documents a separate 200 MB streamed-response ceiling versus 6 MB buffered. + Both stages can apply bandwidth shaping; + the gateway describes 2 MB/s after its first 10 MB. + No deployed throughput or boundary measurements have been performed by this project. + [API Gateway streaming guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) [Lambda streaming](https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html) ## SDK and repository evidence -The pinned aws-lambda-go v1.55.0 already provides -events.APIGatewayProxyStreamingResponse, with status, headers, multivalue headers, -cookies, and an io.Reader body. It implements Read, Close, and ContentType; Read -generates the prefix using encoding/json. Reusing that serializer inside edge -would conflict with our direct JSON v2 requirement. +The pinned aws-lambda-go v1.55.0 already provides events.APIGatewayProxyStreamingResponse, with status, headers, multivalue headers, cookies, and an io.Reader body. +It implements Read, Close, and ContentType; +Read generates the prefix using encoding/json. +Reusing that serializer inside edge would conflict with our direct JSON v2 requirement. [Pinned SDK streaming response](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/apigw.go) -On 2026-09-14, both `go list -m -json github.com/aws/aws-lambda-go@latest` and -AWS's GitHub latest-release endpoint resolve to v1.55.0. The release is neither -draft nor prerelease, was published on 2026-08-27, and explicitly includes Go 1.27 -compatibility work. go.mod already requires this version. "Pinned" means the -exact version recorded for reproducible builds, not a policy of remaining on an -older release. Use the latest stable SDK and verify compatibility when updating. +On 2026-09-14, both `go list -m -json github.com/aws/aws-lambda-go@latest` and AWS's GitHub latest-release endpoint resolve to v1.55.0. +The release is neither draft nor prerelease, was published on 2026-08-27, and explicitly includes Go 1.27 compatibility work. +go.mod already requires this version. +"Pinned" means the exact version recorded for reproducible builds, not a policy of remaining on an older release. +Use the latest stable SDK and verify compatibility when updating. [Latest stable release verified](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0) -The SDK's function-handler path can return readers. Its runtime loop consumes -and closes the reader and honors its ContentType method. Its legacy/public -Handler.Invoke byte-slice interface instead reads the complete response into -memory. Calling lambda.NewHandler(...).Invoke can check content but cannot prove -incremental streaming. Reader dispatch has JSON-serialization precedence rules; +The SDK's function-handler path can return readers. +Its runtime loop consumes and closes the reader and honors its ContentType method. +Its legacy/public Handler.Invoke byte-slice interface instead reads the complete response into memory. +Calling lambda.NewHandler(...).Invoke can check content but cannot prove incremental streaming. +Reader dispatch has JSON-serialization precedence rules; the exact wrapper must be tested against this pinned implementation. -[Handler dispatch](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/handler.go) -[Runtime loop](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/invoke_loop.go) -[Runtime transport and error trailers](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/runtime_api_client.go) +[Handler dispatch](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/handler.go) [Runtime loop](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/invoke_loop.go) [Runtime transport and error trailers](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/runtime_api_client.go) -Our request conversion can be shared. The buffered response encoder creates a -complete body and envelope, so it cannot implement streaming. withInvocation -currently ends its scope when its callback returns; returning a reader from that -callback would cancel and clean up too early. +Our request conversion can be shared. +The buffered response encoder creates a complete body and envelope, so it cannot implement streaming. +withInvocation currently ends its scope when its callback returns; +returning a reader from that callback would cancel and clean up too early. ## Recommendation -Keep both modes in the root edge package with a common http.Handler application -boundary. Add an explicit streaming adapter/entry point rather than changing the -return contract of Invoke, HandleV1, or HandleV2. A boolean option cannot make a -byte-slice result incremental. Constructor/method names and exact raw/typed -streaming signatures remain a separate proposal; NewStreaming is a possible name, -not an approved API. +Keep both modes in the root edge package with a common http.Handler application boundary. +Add an explicit streaming adapter/entry point rather than changing the return contract of Invoke, HandleV1, or HandleV2. +A boolean option cannot make a byte-slice result incremental. +Constructor/method names and exact raw/typed streaming signatures remain a separate proposal; +NewStreaming is a possible name, not an approved API. -Start streaming support with REST Lambda proxy integration only. Do not infer -streaming from payload version, an Accept header, or a call to Flush. The AWS -integration must be configured for it. HTTP API payload 1.0 shares the V1 event -type but does not gain streaming support from that type. Function URLs are a -possible later target requiring their own request/authentication contract. +Start streaming support with REST Lambda proxy integration only. +Do not infer streaming from payload version, an Accept header, or a call to Flush. +The AWS integration must be configured for it. +HTTP API payload 1.0 shares the V1 event type but does not gain streaming support from that type. +Function URLs are a possible later target requiring their own request/authentication contract. -Share request conversion, gateway metadata, identity production, and applicable -HTTP header/status validation. Use distinct buffered and streaming writers: +Share request conversion, gateway metadata, identity production, and applicable HTTP header/status validation. +Use distinct buffered and streaming writers: | Concern | Buffered | Streaming | | --- | --- | --- | @@ -93,58 +88,51 @@ HTTP header/status validation. Use distinct buffered and streaming writers: | Failure | No usable result on transport failure | Depends on whether transmission has started | | Lifetime | Through synchronous completion | Through stream consumption/closure and producer completion | -Retain AWS's runtime transport. Implement the small metadata encoder/framing -reader in edge with encoding/json/v2 directly; do not build a separate Runtime -API client. Use a bounded producer/consumer bridge, such as io.Pipe, between -ServeHTTP writes and the runtime's reader. Close must cancel the producer and -unblock pending writes. Prove that no producer survives invocation completion. - -Authenticate and authorize before exposing stream content. Validate the prefix -before emitting it and preserve multivalue headers supported by this format. -Unlike the earlier arbitrary snapshot proposal, the streaming prefix has a -documented AWS framing bound; verify its exact byte boundary in the wire tests. - -Define commitment and failure semantics explicitly. Pre-transmission failures -can remain invocation errors. After transmission begins, headers/status cannot -be replaced and bytes cannot be retracted. Terminate a failed stream and use the -runtime's error path; do not inject an unrequested error document into arbitrary -application data. Application-defined SSE/NDJSON error events remain the -application's decision. Consumer-visible truncation/error behavior needs testing. +Retain AWS's runtime transport. +Implement the small metadata encoder/framing reader in edge with encoding/json/v2 directly; +do not build a separate Runtime API client. +Use a bounded producer/consumer bridge, such as io.Pipe, between ServeHTTP writes and the runtime's reader. +Close must cancel the producer and unblock pending writes. +Prove that no producer survives invocation completion. + +Authenticate and authorize before exposing stream content. +Validate the prefix before emitting it and preserve multivalue headers supported by this format. +Unlike the earlier arbitrary snapshot proposal, the streaming prefix has a documented AWS framing bound; +verify its exact byte boundary in the wire tests. + +Define commitment and failure semantics explicitly. +Pre-transmission failures can remain invocation errors. +After transmission begins, headers/status cannot be replaced and bytes cannot be retracted. +Terminate a failed stream and use the runtime's error path; +do not inject an unrequested error document into arbitrary application data. +Application-defined SSE/NDJSON error events remain the application's decision. +Consumer-visible truncation/error behavior needs testing. Goroutine panics require a deliberate bridge to the runtime failure mechanism; -the SDK's synchronous handler recovery does not catch a producer panic after -the function returns. Preserve sanitized diagnostics and explicitly review any -necessary difference from buffered panic propagation. Do not advertise arbitrary -HTTP trailers, Hijacker, WebSockets, request streaming, or full duplex merely -because response flushing works. +the SDK's synchronous handler recovery does not catch a producer panic after the function returns. +Preserve sanitized diagnostics and explicitly review any necessary difference from buffered panic propagation. +Do not advertise arbitrary HTTP trailers, Hijacker, WebSockets, request streaming, or full duplex merely because response flushing works. ## Sequence and proof before public API -Design the shared boundaries now; finish the first buffered adapter slice, then -implement streaming under an agreed contract. First make a local Runtime API -protocol probe using the pinned SDK. Verify that initial bytes arrive before the -producer completes, along with ContentType handling, raw/typed input capture, -JSON v2 prefix framing, backpressure, Close, cancellation, and late read errors. +Design the shared boundaries now; +finish the first buffered adapter slice, then implement streaming under an agreed contract. +First make a local Runtime API protocol probe using the pinned SDK. +Verify that initial bytes arrive before the producer completes, along with ContentType handling, raw/typed input capture, JSON v2 prefix framing, backpressure, Close, cancellation, and late read errors. Do not use a fully collecting test wrapper as evidence of streaming. -Then exercise ordinary SSE, NDJSON, and binary handlers with a slow reader and -an early-closing reader. Cover prefix limits, headers/status commitment, HEAD, -bodyless statuses, compression, panic cleanup, and warm-invocation reuse. An -optional deployed test would be needed to establish end-to-end API Gateway -flush timing, interruption behavior, and disputed service limits; none is -authorized or performed by this design review. +Then exercise ordinary SSE, NDJSON, and binary handlers with a slow reader and an early-closing reader. +Cover prefix limits, headers/status commitment, HEAD, bodyless statuses, compression, panic cleanup, and warm-invocation reuse. +An optional deployed test would be needed to establish end-to-end API Gateway flush timing, interruption behavior, and disputed service limits; +none is authorized or performed by this design review. ## Alternatives and resolution -Using the SDK streaming response object is attractive but delegates its internal -prefix JSON to the v1 compatibility API. Returning complete byte slices keeps -the old API but defeats incremental delivery. A separate runtime or mandatory -Lambda Web Adapter adds deployment machinery that the existing SDK transport -does not yet appear to require. +Using the SDK streaming response object is attractive but delegates its internal prefix JSON to the v1 compatibility API. +Returning complete byte slices keeps the old API but defeats incremental delivery. +A separate runtime or mandatory Lambda Web Adapter adds deployment machinery that the existing SDK transport does not yet appear to require. The user agreed with the architecture and requested the detailed design graph. -[Decision 0009](0009-streaming-boundaries.md) proposes concrete APIs, ownership, -commitment, and failure rules, backed by a local SDK Runtime API probe. SDK -transport reuse remains the preferred approach, with a documented wire-header -compatibility question to settle before claiming AWS deployment support. No -streaming methods or placeholder APIs have been added. +[Decision 0009](0009-streaming-boundaries.md) proposes concrete APIs, ownership, commitment, and failure rules, backed by a local SDK Runtime API probe. +SDK transport reuse remains the preferred approach, with a documented wire-header compatibility question to settle before claiming AWS deployment support. +No streaming methods or placeholder APIs have been added. diff --git a/docs/decisions/0009-streaming-boundaries.md b/docs/decisions/0009-streaming-boundaries.md index 4342f45..d41978e 100644 --- a/docs/decisions/0009-streaming-boundaries.md +++ b/docs/decisions/0009-streaming-boundaries.md @@ -1,18 +1,18 @@ # 0009: Streaming entry points, ownership, and failure boundaries -Status: accepted by the user on 2026-09-14; implemented with local validation. +Status: accepted by the user on 2026-09-14; +implemented with local validation. Deployed SDK/API Gateway compatibility remains unverified. -The [2026-09-17 writer review](0022-streaming-writer-review.md) applies this -accepted contract to the completed shared foundations. Its refinements were -approved on 2026-09-17 and implemented: no inferred streaming lengths and shared -empty Content-Encoding sniffing. Its local SDK probe observes connection reuse, -extending the existing runtime compatibility question below. Decision 0010 -already resolved the conservative metadata-prefix implementation limit. +The [2026-09-17 writer review](0022-streaming-writer-review.md) applies this accepted contract to the completed shared foundations. +Its refinements were approved on 2026-09-17 and implemented: no inferred streaming lengths and shared empty Content-Encoding sniffing. +Its local SDK probe observes connection reuse, extending the existing runtime compatibility question below. +Decision 0010 already resolved the conservative metadata-prefix implementation limit. ## Recommended public boundary -Keep a distinct adapter in the root edge package. Accepted signatures: +Keep a distinct adapter in the root edge package. +Accepted signatures: ```go func NewStreaming(handler http.Handler, opts ...Option) (*StreamingAdapter, error) @@ -21,29 +21,28 @@ func (a *StreamingAdapter) HandleV1(ctx context.Context, event events.APIGateway ``` Register `lambda.Start(adapter.Handle)` or `lambda.Start(adapter.HandleV1)`. -Do not register the streaming adapter object via the byte-returning lambda.Handler -interface. No HandleV2: API Gateway HTTP APIs do not currently stream responses. -REST configuration is a deployment precondition; a typed V1 event alone cannot -prove the invoking API product. Raw Handle rejects the explicitly versioned -HTTP API envelopes supported by the separate buffered adapter. - -Handle receives raw JSON via the standard jsontext.Value capture type and applies -edge's direct JSON v2 decoding and validation. The SDK still owns its initial -capture step. HandleV1 skips request JSON work. Both must encode streaming -metadata with JSON v2; typed streaming cannot eliminate that wire prefix. - -Reuse the existing Option type and immutable configuration, including the -accepted WithGatewayIdentity default. NewStreaming performs the same constructor -validation as New and starts no goroutines or I/O. The caller owns handler -concurrency safety. Do not add Start helpers, stream-specific request types, or -a public ResponseWriter interface. - -The concrete stream remains private and implements Read, Close, ContentType, -and a MarshalJSON rejection method. The latter follows the SDK streaming type's -reader-dispatch pattern: it prevents accidental JSON serialization and lets the -SDK consume the reader. It does not perform JSON encoding. ContentType returns -the Lambda integration media type, independently of the application's HTTP -Content-Type in the prefix. Direct callers must consume and Close the stream. +Do not register the streaming adapter object via the byte-returning lambda.Handler interface. +No HandleV2: API Gateway HTTP APIs do not currently stream responses. +REST configuration is a deployment precondition; +a typed V1 event alone cannot prove the invoking API product. +Raw Handle rejects the explicitly versioned HTTP API envelopes supported by the separate buffered adapter. + +Handle receives raw JSON via the standard jsontext.Value capture type and applies edge's direct JSON v2 decoding and validation. +The SDK still owns its initial capture step. +HandleV1 skips request JSON work. +Both must encode streaming metadata with JSON v2; +typed streaming cannot eliminate that wire prefix. + +Reuse the existing Option type and immutable configuration, including the accepted WithGatewayIdentity default. +NewStreaming performs the same constructor validation as New and starts no goroutines or I/O. +The caller owns handler concurrency safety. +Do not add Start helpers, stream-specific request types, or a public ResponseWriter interface. + +The concrete stream remains private and implements Read, Close, ContentType, and a MarshalJSON rejection method. +The latter follows the SDK streaming type's reader-dispatch pattern: it prevents accidental JSON serialization and lets the SDK consume the reader. +It does not perform JSON encoding. +ContentType returns the Lambda integration media type, independently of the application's HTTP Content-Type in the prefix. +Direct callers must consume and Close the stream. ## Design graph @@ -68,10 +67,9 @@ flowchart TD Close --> Next[Only then permit next invocation] ``` -The graph separates application HTTP commitment from handing a response stream -to the runtime. That handoff is our practical error boundary; do not assume that -the absence of an observed network write makes it safe to return a different -result after handing off the reader. +The graph separates application HTTP commitment from handing a response stream to the runtime. +That handoff is our practical error boundary; +do not assume that the absence of an observed network write makes it safe to return a different result after handing off the reader. ## Ownership and lifecycle @@ -83,59 +81,56 @@ result after handing off the reader. | SDK runtime | Consume/close the returned stream and perform Runtime API transport | | Application | Honor request cancellation and Write/Flush errors; authorize before sending content; own any goroutines it starts | -Use one producer, not a goroutine per write. Publish a one-shot ready signal -after prefix validation and before blocking on pipe output. Otherwise the -producer can wait for a reader while the entry point waits for the producer. +Use one producer, not a goroutine per write. +Publish a one-shot ready signal after prefix validation and before blocking on pipe output. +Otherwise the producer can wait for a reader while the entry point waits for the producer. Do not share the handler's mutable Header map with the consumer. -Successful EOF must occur only after producer completion and request cleanup, -so cleanup failures can still become terminal read errors. Keep the invocation -scope until consumption finishes or Close completes. Refactor the shared scope -into acquisition/cleanup primitives with the existing synchronous helper as one -owner; do not return a live stream from inside today's withInvocation callback. - -Close is idempotent, safe to call concurrently with Read, and cancels the child -context, closes the pipe's read side to unblock writes, then joins the producer. -Parent cancellation also closes the bridge so a writer blocked by a stalled -consumer can exit. A context.AfterFunc cancellation callback must be stopped or -joined during teardown; stopping it alone does not wait for an active callback. - -Go cannot forcibly terminate a handler that ignores cancellation and write -errors. Close may wait for such a handler; in Lambda the invocation timeout is -the final bound. Do not return early and silently leave that handler running -into a warm invocation. The guarantee is bounded adapter buffering and cleanup -of cooperative handlers, not the ability to kill arbitrary application code. -[io.Pipe](https://pkg.go.dev/io#Pipe) -[context.AfterFunc](https://pkg.go.dev/context#AfterFunc) +Successful EOF must occur only after producer completion and request cleanup, so cleanup failures can still become terminal read errors. +Keep the invocation scope until consumption finishes or Close completes. +Refactor the shared scope into acquisition/cleanup primitives with the existing synchronous helper as one owner; +do not return a live stream from inside today's withInvocation callback. + +Close is idempotent, safe to call concurrently with Read, and cancels the child context, closes the pipe's read side to unblock writes, then joins the producer. +Parent cancellation also closes the bridge so a writer blocked by a stalled consumer can exit. +A context.AfterFunc cancellation callback must be stopped or joined during teardown; +stopping it alone does not wait for an active callback. + +Go cannot forcibly terminate a handler that ignores cancellation and write errors. +Close may wait for such a handler; +in Lambda the invocation timeout is the final bound. +Do not return early and silently leave that handler running into a warm invocation. +The guarantee is bounded adapter buffering and cleanup of cooperative handlers, not the ability to kill arbitrary application code. +[io.Pipe](https://pkg.go.dev/io#Pipe) [context.AfterFunc](https://pkg.go.dev/context#AfterFunc) ## Writer and framing behavior -- Share HTTP status/header validation where the contracts match. Preserve REST - multivalue response fields rather than applying V2 comma-joining rules. +- Share HTTP status/header validation where the contracts match. + Preserve REST multivalue response fields rather than applying V2 comma-joining rules. - First final WriteHeader, Write, or Flush commits status/header snapshot. - Flush without a prior status commits 200. Subsequent header mutation does not - alter the snapshot. -- Recommend retaining at most a 512-byte initial body prefix for content-type - detection when needed, then using the unbuffered pipe. Publish on filling that - prefix, explicit Flush, or handler completion. If type detection is suppressed - or unnecessary, first nonempty Write can publish immediately. Empty Flush - publishes metadata without inventing a type from absent body bytes. + Flush without a prior status commits 200. + Subsequent header mutation does not alter the snapshot. +- Recommend retaining at most a 512-byte initial body prefix for content-type detection when needed, then using the unbuffered pipe. + Publish on filling that prefix, explicit Flush, or handler completion. + If type detection is suppressed or unnecessary, first nonempty Write can publish immediately. + Empty Flush publishes metadata without inventing a type from absent body bytes. - Implement Flush and FlushError so ResponseController.Flush can report failure. - Flush drains edge's pending prefix/body bytes toward the SDK. It does not - guarantee client receipt, packet boundaries, or intermediary flush timing. -- Use direct JSON v2 for the bounded prefix, followed by eight NUL bytes, then - raw body bytes. Do not base64 encode the body or buffer it to infer length. - Enforce AWS's documented first-16-KB framing requirement. Verify the precise - byte-boundary interpretation before finalizing its numeric constant; this is - distinct from the unapproved general metadata budgets in decision 0007. -- Honor supplied valid Content-Length and reject writes that exceed it before - forwarding their excess. A short body discovered after handoff is a terminal - stream error. Without a declared length, let the gateway provide framing. -- Capture the original method. Suppress HEAD bodies and 204/205/304 bodies; - retain the accepted forbidden-write conventions. Do not infer a HEAD length - after already publishing its metadata. -- Do not expose Hijacker, full duplex, connection deadlines, or arbitrary HTTP - trailers. Explicit unsupported features follow the accepted fault policy. + Flush drains edge's pending prefix/body bytes toward the SDK. + It does not guarantee client receipt, packet boundaries, or intermediary flush timing. +- Use direct JSON v2 for the bounded prefix, followed by eight NUL bytes, then raw body bytes. + Do not base64 encode the body or buffer it to infer length. + Enforce AWS's documented first-16-KB framing requirement. + Verify the precise byte-boundary interpretation before finalizing its numeric constant; + this is distinct from the unapproved general metadata budgets in decision 0007. +- Honor supplied valid Content-Length and reject writes that exceed it before forwarding their excess. + A short body discovered after handoff is a terminal stream error. + Without a declared length, let the gateway provide framing. +- Capture the original method. + Suppress HEAD bodies and 204/205/304 bodies; + retain the accepted forbidden-write conventions. + Do not infer a HEAD length after already publishing its metadata. +- Do not expose Hijacker, full duplex, connection deadlines, or arbitrary HTTP trailers. + Explicit unsupported features follow the accepted fault policy. Lambda error trailers are runtime signaling, not application HTTP trailers. ## Failures and observability @@ -149,109 +144,90 @@ of cooperative handlers, not the ability to kill arbitrary application code. | Producer panic after handoff | Recover within producer, clean up, report a sanitized terminal stream error | | Parent cancellation or consumer Close | Cancel, unblock the pipe, join producer; preserve an existing primary failure | -The late-panic rule intentionally differs from the SDK's synchronous panic path, -which can terminate the process. Do not introduce os.Exit or an unhandled worker -panic into the library. The user explicitly approved this difference. +The late-panic rule intentionally differs from the SDK's synchronous panic path, which can terminate the process. +Do not introduce os.Exit or an unhandled worker panic into the library. +The user explicitly approved this difference. -AWS says midstream error trailers are treated as a successful response with error -metadata. Do not claim they necessarily increment Lambda's ordinary invocation -error metric, change an HTTP status, or reach every API Gateway client as useful -trailers. Application protocols such as SSE/NDJSON may define their own error -events; edge must not inject these into arbitrary streams. +AWS says midstream error trailers are treated as a successful response with error metadata. +Do not claim they necessarily increment Lambda's ordinary invocation error metric, change an HTTP status, or reach every API Gateway client as useful trailers. +Application protocols such as SSE/NDJSON may define their own error events; +edge must not inject these into arbitrary streams. [AWS Runtime API streaming errors](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html) -Recommend an optional application-owned terminal-error reporter so failures -after handoff are observable without library logging: +Recommend an optional application-owned terminal-error reporter so failures after handoff are observable without library logging: ```go func WithStreamErrorReporter(report func(context.Context, error)) Option ``` -Reject nil callbacks and reject this streaming-only option in New. Invoke it once -for a terminal failure after handoff, including cancellation/panic, after producer -cleanup and before completion. Do not report errors that were returned directly -before handoff. It receives the invocation context (possibly canceled) and only -sanitized errors and must return promptly. If it panics, recover at the reporter -boundary, preserve the original terminal failure, add a sanitized reporter fault, -and do not recursively report. Integrate these faults with the separately reviewed -shared error categories. No callback is required to use streaming; without one the reader -and SDK error metadata are the available signals. +Reject nil callbacks and reject this streaming-only option in New. +Invoke it once for a terminal failure after handoff, including cancellation/panic, after producer cleanup and before completion. +Do not report errors that were returned directly before handoff. +It receives the invocation context (possibly canceled) and only sanitized errors and must return promptly. +If it panics, recover at the reporter boundary, preserve the original terminal failure, add a sanitized reporter fault, and do not recursively report. +Integrate these faults with the separately reviewed shared error categories. +No callback is required to use streaming; +without one the reader and SDK error metadata are the available signals. ## Local SDK probe: observed results and compatibility gates -`internal/streamprobe/runtime_test.go` starts the pinned SDK in a subprocess and -serves a synthetic Runtime API locally. The second stream segment is withheld -until the server receives the first byte. All five cases passed with race -detection on Go 1.27.1: +`internal/streamprobe/runtime_test.go` starts the pinned SDK in a subprocess and serves a synthetic Runtime API locally. +The second stream segment is withheld until the server receives the first byte. +All five cases passed with race detection on Go 1.27.1: -- Raw jsontext.Value capture preserved the exact object, duplicate names, and - an integer above 2^53 for later strict validation by edge. +- Raw jsontext.Value capture preserved the exact object, duplicate names, and an integer above 2^53 for later strict validation by edge. - Typed APIGatewayProxyRequest input reached the typed function correctly. -- SDK delivery was incremental and HTTP/1.1 chunked; the integration ContentType - method was honored through an io.ReadCloser return type. +- SDK delivery was incremental and HTTP/1.1 chunked; + the integration ContentType method was honored through an io.ReadCloser return type. - Reader Close ran before the next invocation request. - A late reader error became Lambda runtime error trailers. -- Returning bytes and a non-EOF error together lost those bytes in the SDK's - errorCapturingReader. The edge stream must normalize reads: return any bytes - with nil error, and deliver the saved error on the next nonempty Read. A fifth - probe case verifies this guard preserves the final bytes and the error trailer. +- Returning bytes and a non-EOF error together lost those bytes in the SDK's errorCapturingReader. + The edge stream must normalize reads: return any bytes with nil error, and deliver the saved error on the next nonempty Read. + A fifth probe case verifies this guard preserves the final bytes and the error trailer. Do not change SDK code in place. -The SDK did not send Lambda-Runtime-Function-Response-Mode. AWS's custom-runtime -documentation says streaming responses must send this header as `streaming`. -The SDK also provides official streaming response examples, so this observation -alone does not prove the service rejects or buffers its output. It is a concrete -documentation/SDK mismatch, not resolved by a permissive local test server. - -The SDK is the latest stable release: both the Go module proxy and AWS GitHub -release endpoint were checked on 2026-09-14 and returned v1.55.0. Reading the full -API Gateway guide through AWS MCP confirms its supported combination is STREAM, -InvokeWithResponseStream, and correctly framed metadata/body output. That guide -does not impose this Runtime API header itself. Keep the header question scoped -to the Lambda runtime boundary; it is not evidence that API Gateway streaming -or the SDK's documented streaming response type is unsupported. -[API Gateway supported configurations](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) -[AWS SDK stable release](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0) - -Recommend retaining SDK reuse as the design, but treat this mismatch as a release -gate: seek an authoritative clarification or a suitable SDK fix/version; if -needed, propose a separately approved minimal AWS integration test. Do not patch -http.DefaultTransport globally, alter the module cache, or silently introduce a -custom runtime to bypass the issue. The local probe proves the SDK's wire -behavior, not API Gateway delivery or billing/error metrics. +The SDK did not send Lambda-Runtime-Function-Response-Mode. +AWS's custom-runtime documentation says streaming responses must send this header as `streaming`. +The SDK also provides official streaming response examples, so this observation alone does not prove the service rejects or buffers its output. +It is a concrete documentation/SDK mismatch, not resolved by a permissive local test server. + +The SDK is the latest stable release: both the Go module proxy and AWS GitHub release endpoint were checked on 2026-09-14 and returned v1.55.0. +Reading the full API Gateway guide through AWS MCP confirms its supported combination is STREAM, InvokeWithResponseStream, and correctly framed metadata/body output. +That guide does not impose this Runtime API header itself. +Keep the header question scoped to the Lambda runtime boundary; +it is not evidence that API Gateway streaming or the SDK's documented streaming response type is unsupported. +[API Gateway supported configurations](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) [AWS SDK stable release](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0) + +Recommend retaining SDK reuse as the design, but treat this mismatch as a release gate: seek an authoritative clarification or a suitable SDK fix/version; +if needed, propose a separately approved minimal AWS integration test. +Do not patch http.DefaultTransport globally, alter the module cache, or silently introduce a custom runtime to bypass the issue. +The local probe proves the SDK's wire behavior, not API Gateway delivery or billing/error metrics. ## Implementation order and resolution 1. Review this API, ownership graph, handoff boundary, and late-error/panic policy. -2. Extend scope ownership and implement the private stream bridge, including - byte/error normalization, cancellation, Close, and handshake tests. +2. Extend scope ownership and implement the private stream bridge, including byte/error normalization, cancellation, Close, and handshake tests. 3. Implement shared header validation and separate streaming writer/framing. 4. Wire the proposed raw and typed REST entry points with identity policy. 5. Exercise SSE, NDJSON, binary output, early Close, slow reads, and warm reuse; - resolve the AWS runtime-header mismatch and exact prefix-boundary questions before - advertising deployment support. - -The first complete buffered adapter remains the initial deliverable. The local -SDK probe and design allow streaming requirements to shape shared ownership now. -The user approved the detailed API, ownership, handoff, and failure policies on -2026-09-14 and asked to start with the private bridge. This acceptance does not -resolve the separate metadata-policy or AWS compatibility questions above. - -Implemented on 2026-09-14: shared invocation context/cleanup primitives and the -private bridge in `stream.go`. Publication waits for the entry point to accept -ownership before body writes; the producer cannot finish publication ahead of -that handoff. The bridge copies its prefix and otherwise uses io.Pipe without -body accumulation. The future framing layer must validate and bound that prefix. - -Tests in `stream_test.go` cover incremental reads, backpressure, prefix ownership, -concurrent Close/Read, parent cancellation/deadlines without a consumer, waiting -for cleanup before terminal results, error precedence, both panic boundaries, -reporter panic isolation, and bytes-plus-error normalization. Go 1.27 synctest -bubbles also require their goroutines to exit. Full race tests, the existing SDK -probe, vet, formatting, and Linux arm64/amd64 builds pass on Go 1.27.1. - -That initial milestone supplied the ownership/transport foundation. Decision -0010 subsequently implemented the prefix codec/limit, and decision 0022 completed -the HTTP writer, constructor/options and raw/typed public entry points on -2026-09-17. The SDK probe now exercises that production path. The documented -Runtime API header/connection mismatch remains an open deployment qualification. + resolve the AWS runtime-header mismatch and exact prefix-boundary questions before advertising deployment support. + +The first complete buffered adapter remains the initial deliverable. +The local SDK probe and design allow streaming requirements to shape shared ownership now. +The user approved the detailed API, ownership, handoff, and failure policies on 2026-09-14 and asked to start with the private bridge. +This acceptance does not resolve the separate metadata-policy or AWS compatibility questions above. + +Implemented on 2026-09-14: shared invocation context/cleanup primitives and the private bridge in `stream.go`. +Publication waits for the entry point to accept ownership before body writes; +the producer cannot finish publication ahead of that handoff. +The bridge copies its prefix and otherwise uses io.Pipe without body accumulation. +The future framing layer must validate and bound that prefix. + +Tests in `stream_test.go` cover incremental reads, backpressure, prefix ownership, concurrent Close/Read, parent cancellation/deadlines without a consumer, waiting for cleanup before terminal results, error precedence, both panic boundaries, reporter panic isolation, and bytes-plus-error normalization. +Go 1.27 synctest bubbles also require their goroutines to exit. +Full race tests, the existing SDK probe, vet, formatting, and Linux arm64/amd64 builds pass on Go 1.27.1. + +That initial milestone supplied the ownership/transport foundation. +Decision 0010 subsequently implemented the prefix codec/limit, and decision 0022 completed the HTTP writer, constructor/options and raw/typed public entry points on 2026-09-17. +The SDK probe now exercises that production path. +The documented Runtime API header/connection mismatch remains an open deployment qualification. diff --git a/docs/decisions/0010-response-header-design.md b/docs/decisions/0010-response-header-design.md index 9f6a96f..bf19d3b 100644 --- a/docs/decisions/0010-response-header-design.md +++ b/docs/decisions/0010-response-header-design.md @@ -1,34 +1,31 @@ # 0010: Shared response headers and transport-specific encoding -Status: accepted by the user on 2026-09-15, including the measured 256 KiB -default and explicit override up to 6 MiB. Shared snapshots, projections and -streaming-prefix encoding are implemented and connected to both HTTP writers. -Decision 0022 records the approved shared sniffing refinement and streaming -Content-Length policy. - -Terminology: V1/V2 in this record mean API Gateway payload formats 1.0/2.0, -not versions of this Go module. The field-combination table concerns outgoing -responses only. It does not restrict custom incoming request headers. -The user's action-dispatch requirement is recorded separately in -[decision 0011](0011-request-header-processing.md). +Status: accepted by the user on 2026-09-15, including the measured 256 KiB default and explicit override up to 6 MiB. +Shared snapshots, projections and streaming-prefix encoding are implemented and connected to both HTTP writers. +Decision 0022 records the approved shared sniffing refinement and streaming Content-Length policy. + +Terminology: V1/V2 in this record mean API Gateway payload formats 1.0/2.0, not versions of this Go module. +The field-combination table concerns outgoing responses only. +It does not restrict custom incoming request headers. +The user's action-dispatch requirement is recorded separately in [decision 0011](0011-request-header-processing.md). ## Recommendation -Keep the application boundary as http.Header. Capture one private, owned, -canonical multivalue snapshot at HTTP commitment. Validate/filter it using shared -rules, then encode it for the selected gateway response format. Only V2 needs -comma combination; REST streaming shares V1's multivalue representation. +Keep the application boundary as http.Header. +Capture one private, owned, canonical multivalue snapshot at HTTP commitment. +Validate/filter it using shared rules, then encode it for the selected gateway response format. +Only V2 needs comma combination; +REST streaming shares V1's multivalue representation. Replace decision 0007's initial ten-field audit with the broader table below. -Replace its unaccepted 64 KiB/1,024-entry pair with a single weighted resource -budget with a measured default and explicit override. Keep actual envelope and -stream-prefix checks separate. These are accepted refinements, not changes to -the already implemented bridge. +Replace its unaccepted 64 KiB/1,024-entry pair with a single weighted resource budget with a measured default and explicit override. +Keep actual envelope and stream-prefix checks separate. +These are accepted refinements, not changes to the already implemented bridge. ## Evidence and limits of the evidence -AWS public MCP was used to read the complete current gateway response-format, -streaming-integration, REST-quota, and HTTP-API-quota pages. The sources establish: +AWS public MCP was used to read the complete current gateway response-format, streaming-integration, REST-quota, and HTTP-API-quota pages. +The sources establish: | Boundary | What the documentation establishes | | --- | --- | @@ -39,81 +36,67 @@ streaming-integration, REST-quota, and HTTP-API-quota pages. The sources establi | REST quotas | Current table lists 20,480 bytes for combined header names, values, whitespace and terminators; private APIs have an 8,000-byte entry | | HTTP API quotas | The 10,240-byte entry explicitly covers the request line and headers; it does not establish an equivalent response limit | -Sources: [HTTP API formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), -[streaming format](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html), -[REST quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-execution-service-limits-table.html), -[HTTP API quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html). - -These are not interchangeable byte counts. Edge does not know every deployment -setting or the final headers added by Gateway/CDN layers. Passing edge's checks -cannot guarantee gateway acceptance. Do not turn the HTTP API request quota into -a response limit, infer private/public endpoint configuration from payload V1, -or promise an exact final HTTP wire size from a Lambda JSON envelope. - -HTTP allows comma combination when the field definition supports it, with -Set-Cookie requiring special handling. The IANA registry provides useful type -and specification references, but registration alone is not permission to join. -Structured Fields Lists and Dictionaries support combination; Items do not -become lists merely because their names are registered. -[RFC 9110 sections 5.2–5.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2), -[IANA field registry](https://www.iana.org/assignments/http-fields/), -[RFC 9651 section 4.2](https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2). +Sources: [HTTP API formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), [streaming format](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html), [REST quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-execution-service-limits-table.html), [HTTP API quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html). + +These are not interchangeable byte counts. +Edge does not know every deployment setting or the final headers added by Gateway/CDN layers. +Passing edge's checks cannot guarantee gateway acceptance. +Do not turn the HTTP API request quota into a response limit, infer private/public endpoint configuration from payload V1, or promise an exact final HTTP wire size from a Lambda JSON envelope. + +HTTP allows comma combination when the field definition supports it, with Set-Cookie requiring special handling. +The IANA registry provides useful type and specification references, but registration alone is not permission to join. +Structured Fields Lists and Dictionaries support combination; +Items do not become lists merely because their names are registered. +[RFC 9110 sections 5.2–5.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2), [IANA field registry](https://www.iana.org/assignments/http-fields/), [RFC 9651 section 4.2](https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2). Go 1.27's new DefaultMaxHeaderValueCount is 500, but it is a request-parser limit. -It is useful evidence that value count matters, not authority for applying 500 -to edge responses. The installed Go 1.27.1 ResponseWriter documentation confirms -header commitment and nil-value suppression. Our request helpers already have -token/control-character checks, but their copying is not resource-bounded and -must not be reused wholesale for response snapshots. -[Go ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter), -[Go request value-count limit](https://pkg.go.dev/net/http#DefaultMaxHeaderValueCount). +It is useful evidence that value count matters, not authority for applying 500 to edge responses. +The installed Go 1.27.1 ResponseWriter documentation confirms header commitment and nil-value suppression. +Our request helpers already have token/control-character checks, but their copying is not resource-bounded and must not be reused wholesale for response snapshots. +[Go ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter), [Go request value-count limit](https://pkg.go.dev/net/http#DefaultMaxHeaderValueCount). ## Shared commitment pipeline -1. Preflight the application map's resource charge without allocating a sorted - key slice or cloning values. Reject over-budget input before snapshot growth. -2. Recognize Go's TrailerPrefix as an unsupported feature before generic name - validation. Reject invalid token names, invalid UTF-8, CR/LF, NUL, DEL and other control - bytes except HTAB. Reuse small syntax predicates where correct; response - UTF-8 validation remains explicit. Reject invalid data rather than silently - removing it or replacing bytes during JSON encoding. -3. Sort original keys lexically, canonicalize names, concatenate same-name - slices in that deterministic key order, and preserve slice element order. - This keeps the accepted mixed-case policy. A Go map cannot recover insertion - order across differently spelled keys; recommend Header.Add/Set to callers. -4. Normalize only leading/trailing SP and HTAB in values. Preserve interior - whitespace, quoting, commas and parameters. Do not split values on commas, - deduplicate values, rewrite CSP directives, or parse arbitrary field grammars. -5. Preserve the distinction between absence and an explicitly present nil/empty - slice until automatic-header decisions finish. Empty slices emit no field; +1. Preflight the application map's resource charge without allocating a sorted key slice or cloning values. + Reject over-budget input before snapshot growth. +2. Recognize Go's TrailerPrefix as an unsupported feature before generic name validation. + Reject invalid token names, invalid UTF-8, CR/LF, NUL, DEL and other control bytes except HTAB. + Reuse small syntax predicates where correct; + response UTF-8 validation remains explicit. + Reject invalid data rather than silently removing it or replacing bytes during JSON encoding. +3. Sort original keys lexically, canonicalize names, concatenate same-name slices in that deterministic key order, and preserve slice element order. + This keeps the accepted mixed-case policy. + A Go map cannot recover insertion order across differently spelled keys; + recommend Header.Add/Set to callers. +4. Normalize only leading/trailing SP and HTAB in values. + Preserve interior whitespace, quoting, commas and parameters. + Do not split values on commas, deduplicate values, rewrite CSP directives, or parse arbitrary field grammars. +5. Preserve the distinction between absence and an explicitly present nil/empty slice until automatic-header decisions finish. + Empty slices emit no field; a slice containing an empty string represents an actual empty field value. - If differently cased aliases include actual values, those values win over a - nil alias: a suppression marker cannot erase explicit content. + If differently cased aliases include actual values, those values win over a nil alias: a suppression marker cannot erase explicit content. 6. Detect unsupported upgrades/trailer declarations before hop-header removal. - Parse Connection's comma-separated tokens case-insensitively; malformed - tokens are faults. Remove Connection-nominated fields and the fixed - connection-specific set listed below. Preserve removal/suppression state so - automatic Content-Type/Content-Length cannot reintroduce a nominated field. -7. Apply the accepted status/method, Content-Type and Content-Length rules to - the resulting snapshot. Buffering can infer length at completion; streaming - cannot infer an unpublished final body length after handoff. Generated fields - must fit the same resource budget and the applicable encoded-output limit. -8. Encode using the transport-specific rules below. Pre-handoff failures remain - invocation errors; no partial headers are published. Unsupported trailers - attempted after commitment remain faults under the accepted late-error policy. - -The fixed removal set follows the categories also handled by Go's ReverseProxy: -Connection, Proxy-Connection, Keep-Alive, Proxy-Authenticate, -Proxy-Authorization, TE, Trailer, Transfer-Encoding and Upgrade. Upgrade/trailer -attempts first produce the accepted unsupported-feature fault; removal must not -hide that fault. This is application-response handling, separate from the Lambda -SDK's own transport headers and error trailers. -[Go ReverseProxy source](https://go.dev/src/net/http/httputil/reverseproxy.go), -[Connection semantics](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.6.1). - -Keep the shared layer concerned with syntax, ownership, transport controls and -representation. Beyond the already accepted Content-Length rules, do not expand -this milestone into a universal semantic validator for every HTTP header. + Parse Connection's comma-separated tokens case-insensitively; + malformed tokens are faults. + Remove Connection-nominated fields and the fixed connection-specific set listed below. + Preserve removal/suppression state so automatic Content-Type/Content-Length cannot reintroduce a nominated field. +7. Apply the accepted status/method, Content-Type and Content-Length rules to the resulting snapshot. + Buffering can infer length at completion; + streaming cannot infer an unpublished final body length after handoff. + Generated fields must fit the same resource budget and the applicable encoded-output limit. +8. Encode using the transport-specific rules below. + Pre-handoff failures remain invocation errors; + no partial headers are published. + Unsupported trailers attempted after commitment remain faults under the accepted late-error policy. + +The fixed removal set follows the categories also handled by Go's ReverseProxy: Connection, Proxy-Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailer, Transfer-Encoding and Upgrade. +Upgrade/trailer attempts first produce the accepted unsupported-feature fault; +removal must not hide that fault. +This is application-response handling, separate from the Lambda SDK's own transport headers and error trailers. +[Go ReverseProxy source](https://go.dev/src/net/http/httputil/reverseproxy.go), [Connection semantics](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.6.1). + +Keep the shared layer concerned with syntax, ownership, transport controls and representation. +Beyond the already accepted Content-Length rules, do not expand this milestone into a universal semantic validator for every HTTP header. Applications remain responsible for field-specific validity in every format. ## Format-specific rules @@ -124,18 +107,16 @@ Applications remain responsible for field-specific validity in every format. | Buffered V2 | One value unchanged; multiple values combined only for the audited set | Move values into Cookies; never comma-combine | | REST streaming | MultiValueHeaders in JSON v2 metadata; no redundant Headers map | Separate elements in MultiValueHeaders; omit redundant Cookies | -Preserve the accepted V1 treatment of unknown repeated fields. V2 rejects a -repeated field outside the audited set, even if its values happen to be equal. +Preserve the accepted V1 treatment of unknown repeated fields. +V2 rejects a repeated field outside the audited set, even if its values happen to be equal. Do not silently use the first/last value or deduplicate to avoid an error. -Examples of V2 rejections include Content-Type, Location, ETag, Date and -Access-Control-Allow-Origin. Content-Length retains its stricter shared rules. +Examples of V2 rejections include Content-Type, Location, ETag, Date and Access-Control-Allow-Origin. +Content-Length retains its stricter shared rules. For a known combinable field, concatenate complete values with comma-space. -This is a representation conversion, not validation that each value is legal for -that field. In particular, registration does not repair malformed list syntax, -empty list elements, invalid dictionary members, or invalid singleton-alternative -combinations. Original within-slice order is preserved, including for ordered -fallbacks, challenges and signature dictionaries. +This is a representation conversion, not validation that each value is legal for that field. +In particular, registration does not repair malformed list syntax, empty list elements, invalid dictionary members, or invalid singleton-alternative combinations. +Original within-slice order is preserved, including for ordered fallbacks, challenges and signature dictionaries. ### Accepted initial V2 combination table @@ -154,151 +135,123 @@ fallbacks, challenges and signature dictionaries. | Content-Digest, Repr-Digest, Want-Content-Digest, Want-Repr-Digest | [RFC 9530 sections 2–4](https://www.rfc-editor.org/rfc/rfc9530.html#section-2) structured Dictionaries | | Signature, Signature-Input | [RFC 9421 sections 4.1–4.2](https://www.rfc-editor.org/rfc/rfc9421.html#section-4.1) structured Dictionaries | -This is an audited starting set of 25 fields, not an exhaustive HTTP registry or -an AWS allowlist. Signature-field transport does not imply signature verification -or a guarantee that Gateway transformations preserve signed message components. +This is an audited starting set of 25 fields, not an exhaustive HTTP registry or an AWS allowlist. +Signature-field transport does not imply signature verification or a guarantee that Gateway transformations preserve signed message components. Do not add a public joining callback or mutable global registry initially. -Applications with another list-defined extension can explicitly construct one -valid combined value using ordinary Header.Set, or use V1 when separate values -are required. Add fields to the built-in set with a specification reference and -fixtures when needed. Revisit a per-adapter extension option only if middleware -integration demonstrates that the ordinary HTTP boundary is insufficient. +Applications with another list-defined extension can explicitly construct one valid combined value using ordinary Header.Set, or use V1 when separate values are required. +Add fields to the built-in set with a specification reference and fixtures when needed. +Revisit a per-adapter extension option only if middleware integration demonstrates that the ordinary HTTP boundary is insufficient. ## Resource accounting and encoded limits -Recommend a single preflight charge based on the HTTP/2 field-section accounting -model: for each original value, charge len(name) + len(value) + 32 bytes. -Charge a nil/empty slice as one name-plus-32 entry. Count original bytes before -trimming, merging or dropping fields, including cookies and hop headers. +Recommend a single preflight charge based on the HTTP/2 field-section accounting model: for each original value, charge len(name) + len(value) + 32 bytes. +Charge a nil/empty slice as one name-plus-32 entry. +Count original bytes before trimming, merging or dropping fields, including cookies and hop headers. Use overflow-safe incremental subtraction from the remaining budget. [RFC 9113 section 6.5.2](https://www.rfc-editor.org/rfc/rfc9113.html#section-6.5.2). -The 32-byte per-line term is established HTTP accounting reused as an edge -resource model. It is not an AWS quota or an exact measurement of Go heap usage. -Counting suppressed entries is an additional edge policy that bounds copying -and sorting even when map values are empty. It also avoids an independent, -arbitrary value-count limit. - -Following the allocation probe, recommend a **262,144-byte (256 KiB) default** -resource budget on both buffered and streaming paths, with an explicit -WithResponseHeaderBudget(bytes int) Option for applications that need another -budget. Recommend accepting positive values up to the existing 6,291,456-byte -response ceiling, rejecting invalid configuration in New/NewStreaming. Omission -uses the default; an explicit zero does not disable the bound. This follows the -existing options' last-assignment-wins rule and fixed constructor configuration. +The 32-byte per-line term is established HTTP accounting reused as an edge resource model. +It is not an AWS quota or an exact measurement of Go heap usage. +Counting suppressed entries is an additional edge policy that bounds copying and sorting even when map values are empty. +It also avoids an independent, arbitrary value-count limit. + +Following the allocation probe, recommend a **262,144-byte (256 KiB) default** resource budget on both buffered and streaming paths, with an explicit WithResponseHeaderBudget(bytes int) Option for applications that need another budget. +Recommend accepting positive values up to the existing 6,291,456-byte response ceiling, rejecting invalid configuration in New/NewStreaming. +Omission uses the default; +an explicit zero does not disable the bound. +This follows the existing options' last-assignment-wins rule and fixed constructor configuration. The numeric default and option are accepted API/library policy, not AWS quotas. -The earlier proposal used 6 MiB unconditionally. A prototype with 157,286 distinct -names near that weighted ceiling retained approximately 14.41 MiB for its -snapshot and cumulatively allocated 16.82 MiB per operation, even after -preallocating sorting storage. At a 256 KiB fixture charge, 6,553 distinct names -retained approximately 0.48 MiB and cumulatively allocated 0.58 MiB. A 1 MiB -candidate retained approximately 1.90 MiB. These are measured shapes, not -worst-case proofs. See [the allocation report](../benchmarks.md#response-header-allocation-probe). - -The default aims to keep additional metadata storage modest while leaving an -explicit route for applications with unusual headers. The original 6 MiB remains -the largest opt-in budget; it is no longer the default memory tradeoff for every -application. The 256 KiB choice is an engineering judgment informed by these -measurements, not a uniquely correct threshold derived from AWS documentation. -An override does not change the independent response-envelope or streaming-prefix -limits, and does not guarantee acceptance by API Gateway. - -At the default, every valid key costs at least 33 bytes per charged entry, -allowing at most 7,943 entries; the 6 MiB override permits at most 190,650. -Counting only name/value bytes would admit arbitrarily large slices of empty -strings; the per-entry charge prevents this without a second count setting. - -This is a library policy. A response below an AWS envelope limit can -still exceed the weighted budget. A passing weighted budget does not promise -that a response fits Lambda JSON, API Gateway, a browser, or a low-memory Lambda -configuration. Map overhead, backing strings, encoding temporaries and the -application's existing allocations are not an exact 6 MiB heap guarantee. - -The isolated, test-only internal/headerprobe package now measures ordinary -headers/cookies, many empty values, many distinct names, suppressed names, -mixed-case collisions, escaped values and rejection before copying. It does not -implement production header validation, filtering or projection. Retained heap -deltas keep the input alive and exclude application allocations; neither these -deltas nor cumulative allocations measure peak memory or RSS. Repeat the probes -against the actual implementation before treating these figures as its baseline. - -Independently enforce the existing complete buffered JSON envelope limit on raw -Invoke. Typed buffered methods still perform no response JSON round trip and -leave the caller's final serialized-envelope limit with that caller/runtime. - -For streaming, recommend a conservative initial limit of **16,000 bytes for the -entire encoded JSON prefix plus all eight delimiter bytes**, checked by bounded -direct JSON v2 encoding before publish. That leaves at most 15,992 JSON bytes, -including status, names, array syntax and escaping. The guide does not explicitly -resolve decimal/binary KB or delimiter-start versus delimiter-end accounting. -Keeping the complete delimiter within 16,000 bytes satisfies the stricter -interpretations; it deliberately gives up up to 384 bytes compared with a -16,384-byte interpretation. Label this as edge's conservative compatibility -policy and relax it only on authoritative clarification or approved deployment -evidence. This resolves the implementation choice without claiming AWS specified -an exact 16,000-byte service boundary. - -Neither the weighted budget nor the exact streaming-prefix cap is a stream body -limit. The streaming writer retains only its accepted sniffing prefix and uses -the already implemented bridge for the body. +The earlier proposal used 6 MiB unconditionally. +A prototype with 157,286 distinct names near that weighted ceiling retained approximately 14.41 MiB for its snapshot and cumulatively allocated 16.82 MiB per operation, even after preallocating sorting storage. +At a 256 KiB fixture charge, 6,553 distinct names retained approximately 0.48 MiB and cumulatively allocated 0.58 MiB. +A 1 MiB candidate retained approximately 1.90 MiB. +These are measured shapes, not worst-case proofs. +See [the allocation report](../benchmarks.md#response-header-allocation-probe). + +The default aims to keep additional metadata storage modest while leaving an explicit route for applications with unusual headers. +The original 6 MiB remains the largest opt-in budget; +it is no longer the default memory tradeoff for every application. +The 256 KiB choice is an engineering judgment informed by these measurements, not a uniquely correct threshold derived from AWS documentation. +An override does not change the independent response-envelope or streaming-prefix limits, and does not guarantee acceptance by API Gateway. + +At the default, every valid key costs at least 33 bytes per charged entry, allowing at most 7,943 entries; +the 6 MiB override permits at most 190,650. +Counting only name/value bytes would admit arbitrarily large slices of empty strings; +the per-entry charge prevents this without a second count setting. + +This is a library policy. +A response below an AWS envelope limit can still exceed the weighted budget. +A passing weighted budget does not promise that a response fits Lambda JSON, API Gateway, a browser, or a low-memory Lambda configuration. +Map overhead, backing strings, encoding temporaries and the application's existing allocations are not an exact 6 MiB heap guarantee. + +The isolated, test-only internal/headerprobe package now measures ordinary headers/cookies, many empty values, many distinct names, suppressed names, mixed-case collisions, escaped values and rejection before copying. +It does not implement production header validation, filtering or projection. +Retained heap deltas keep the input alive and exclude application allocations; +neither these deltas nor cumulative allocations measure peak memory or RSS. +Repeat the probes against the actual implementation before treating these figures as its baseline. + +Independently enforce the existing complete buffered JSON envelope limit on raw Invoke. +Typed buffered methods still perform no response JSON round trip and leave the caller's final serialized-envelope limit with that caller/runtime. + +For streaming, recommend a conservative initial limit of **16,000 bytes for the entire encoded JSON prefix plus all eight delimiter bytes**, checked by bounded direct JSON v2 encoding before publish. +That leaves at most 15,992 JSON bytes, including status, names, array syntax and escaping. +The guide does not explicitly resolve decimal/binary KB or delimiter-start versus delimiter-end accounting. +Keeping the complete delimiter within 16,000 bytes satisfies the stricter interpretations; +it deliberately gives up up to 384 bytes compared with a 16,384-byte interpretation. +Label this as edge's conservative compatibility policy and relax it only on authoritative clarification or approved deployment evidence. +This resolves the implementation choice without claiming AWS specified an exact 16,000-byte service boundary. + +Neither the weighted budget nor the exact streaming-prefix cap is a stream body limit. +The streaming writer retains only its accepted sniffing prefix and uses the already implemented bridge for the body. ## Alternatives and next implementation -- Join every repeated V2 field: simplest mechanically, but can alter singleton, - cookie and extension semantics. AWS's representation does not authorize that. +- Join every repeated V2 field: simplest mechanically, but can alter singleton, cookie and extension semantics. + AWS's representation does not authorize that. - Preserve every field in every format: impossible with V2's single-string map. -- Fixed 64 KiB plus 1,024 entries: still lacks a demonstrated resource/compatibility - reason for those numbers. A weighted budget replaces both controls. -- Fixed 6 MiB weighted default: broad compatibility, but the prototype retains - roughly 14.41 MiB for one high-cardinality snapshot before application and - response-encoding allocations. Prefer a smaller default with an explicit - override rather than assigning that tradeoff to every consumer. -- Reuse Gateway's smallest quota globally: would conflate request/response, - endpoint configuration, HTTP field bytes and JSON framing bytes. -- Full semantic registry/parser: significantly larger scope than transport - adaptation; unnecessary for ordinary single values and V1 preservation. - -The shared layer is implemented in response_headers.go. It owns canonical maps -and value slices, tracks the original preflight charge without refunds, preserves -automatic-header suppression and returns independent V1/V2 projections. New -validates WithResponseHeaderBudget after option application, so the final scalar -assignment wins. Final status/body behavior and HTTP commitment timing belong -to the separate writers; the buffered writer now implements those rules. +- Fixed 64 KiB plus 1,024 entries: still lacks a demonstrated resource/compatibility reason for those numbers. + A weighted budget replaces both controls. +- Fixed 6 MiB weighted default: broad compatibility, but the prototype retains roughly 14.41 MiB for one high-cardinality snapshot before application and response-encoding allocations. + Prefer a smaller default with an explicit override rather than assigning that tradeoff to every consumer. +- Reuse Gateway's smallest quota globally: would conflate request/response, endpoint configuration, HTTP field bytes and JSON framing bytes. +- Full semantic registry/parser: significantly larger scope than transport adaptation; + unnecessary for ordinary single values and V1 preservation. + +The shared layer is implemented in response_headers.go. +It owns canonical maps and value slices, tracks the original preflight charge without refunds, preserves automatic-header suppression and returns independent V1/V2 projections. +New validates WithResponseHeaderBudget after option application, so the final scalar assignment wins. +Final status/body behavior and HTTP commitment timing belong to the separate writers; +the buffered writer now implements those rules. Connection parsing tolerates empty list elements under RFC 9110 section 5.6.1.2; -the input budget bounds that scanning. Nonempty malformed tokens remain faults. -TrailerPrefix entries, represented Trailer/Upgrade values and upgrade nominations -are unsupported; nil/empty Trailer/Upgrade slices emit nothing and are removed. -Strict Content-Length validation follows hop-field removal. A nominated length -cannot reappear through inference. These details follow the accepted transport -and suppression rules; they add no new public policy controls. +the input budget bounds that scanning. +Nonempty malformed tokens remain faults. +TrailerPrefix entries, represented Trailer/Upgrade values and upgrade nominations are unsupported; +nil/empty Trailer/Upgrade slices emit nothing and are removed. +Strict Content-Length validation follows hop-field removal. +A nominated length cannot reappear through inference. +These details follow the accepted transport and suppression rules; +they add no new public policy controls. [HTTP list recipient requirements](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.1.2). -stream_encoding.go encodes explicit status and multivalue metadata through direct -JSON v2, using the shared bounded destination with a 15,992-byte JSON limit. It -returns exactly sized storage containing JSON plus eight NUL bytes. It never -returns a partial prefix. A bridge test verifies that oversized metadata fails -before either headers or body become available. - -Tests cover input/projection ownership, casing, suppression, syntax/UTF-8, -Connection filtering, cookies, all 25 joinable names, ordered challenges and -signature dictionaries, rejected repeats, Content-Length syntax, configuration, -exact resource boundaries, generated-field accounting and exact encoded prefix -limits including escaping. Over-budget preflight rejection allocates no snapshot. +stream_encoding.go encodes explicit status and multivalue metadata through direct JSON v2, using the shared bounded destination with a 15,992-byte JSON limit. +It returns exactly sized storage containing JSON plus eight NUL bytes. +It never returns a partial prefix. +A bridge test verifies that oversized metadata fails before either headers or body become available. + +Tests cover input/projection ownership, casing, suppression, syntax/UTF-8, Connection filtering, cookies, all 25 joinable names, ordered challenges and signature dictionaries, rejected repeats, Content-Length syntax, configuration, exact resource boundaries, generated-field accounting and exact encoded prefix limits including escaping. +Over-budget preflight rejection allocates no snapshot. Production allocation samples are recorded in [the benchmark report](../benchmarks.md). -Validation on Go 1.27.1: full race tests, vet, formatting/diff checks and Linux -arm64/amd64 builds pass. The default's measured retained snapshot matches the -probe at approximately 0.48 MiB for the high-cardinality fixture. Projection -allocations are reported separately; no new resource policy is needed. +Validation on Go 1.27.1: full race tests, vet, formatting/diff checks and Linux arm64/amd64 builds pass. +The default's measured retained snapshot matches the probe at approximately 0.48 MiB for the high-cardinality fixture. +Projection allocations are reported separately; +no new resource policy is needed. -Both writers now use these primitives for status/method rules, sniffing, -length enforcement, commitment timing and late unsupported-trailer detection. -The streaming HTTP writer and public entry points were completed on 2026-09-17 -under decision 0022, including the shared empty-encoding sniffing refinement. +Both writers now use these primitives for status/method rules, sniffing, length enforcement, commitment timing and late unsupported-trailer detection. +The streaming HTTP writer and public entry points were completed on 2026-09-17 under decision 0022, including the shared empty-encoding sniffing refinement. -The SDK Runtime API header/connection compatibility question remains a separate -deployment qualification. No account APIs or live AWS deployments were used for -this proposal. +The SDK Runtime API header/connection compatibility question remains a separate deployment qualification. +No account APIs or live AWS deployments were used for this proposal. diff --git a/docs/decisions/0011-request-header-processing.md b/docs/decisions/0011-request-header-processing.md index 9ed3383..16ce259 100644 --- a/docs/decisions/0011-request-header-processing.md +++ b/docs/decisions/0011-request-header-processing.md @@ -1,142 +1,124 @@ # 0011: Consumer request-header processing and action dispatch -Status: accepted on 2026-09-15, including the Go review's standard HTTP boundary -and immutable value selector. Selector and consumer middleware example implemented. +Status: accepted on 2026-09-15, including the Go review's standard HTTP boundary and immutable value selector. +Selector and consumer middleware example implemented. ## Requirement and terminology -Consumers will implement APIs in which a request header identifies the action -to dispatch. They need a supported interface for processing custom headers. -This is incoming request processing, separate from decision 0010's outgoing -response-header conversion. API Gateway payload format 2.0 is not module v2. +Consumers will implement APIs in which a request header identifies the action to dispatch. +They need a supported interface for processing custom headers. +This is incoming request processing, separate from decision 0010's outgoing response-header conversion. +API Gateway payload format 2.0 is not module v2. -The existing request conversion preserves custom header names and values. Its -only special handling is the accepted transport normalization, such as moving -Host into Request.Host and reconciling body framing. The response-combination -table must never become an incoming-header allowlist. +The existing request conversion preserves custom header names and values. +Its only special handling is the accepted transport normalization, such as moving Host into Request.Host and reconciling body framing. +The response-combination table must never become an incoming-header allowlist. ## Evidence -request.go canonicalizes names and preserves repeated V1 values. API Gateway -payload 2.0 provides flattened header strings: AWS combines repeated values -with commas. Original field-line multiplicity is unavailable in that format. -Reading Header.Values is necessary for V1/ordinary HTTP multiplicity, but not -sufficient to establish that a payload 2.0 value originated in one field line. -[AWS payload differences](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), -[Go Header.Values](https://pkg.go.dev/net/http#Header.Values). - -Header.Get returns the first associated value. Using it alone to choose a -security-relevant action could hide conflicting values. The repository has no -consumer dispatcher implementation or action-header contract to reuse; a -focused search of Beakley also found no such contract. +request.go canonicalizes names and preserves repeated V1 values. +API Gateway payload 2.0 provides flattened header strings: AWS combines repeated values with commas. +Original field-line multiplicity is unavailable in that format. +Reading Header.Values is necessary for V1/ordinary HTTP multiplicity, but not sufficient to establish that a payload 2.0 value originated in one field line. +[AWS payload differences](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), [Go Header.Values](https://pkg.go.dev/net/http#Header.Values). + +Header.Get returns the first associated value. +Using it alone to choose a security-relevant action could hide conflicting values. +The repository has no consumer dispatcher implementation or action-header contract to reuse; +a focused search of Beakley also found no such contract. [Go Header.Get](https://pkg.go.dev/net/http#Header.Get). ## Recommended extension boundary Use the standard http.Handler interface as the supported consumer boundary. -Consumers implement handlers or compose middleware with the ordinary function -shape: +Consumers implement handlers or compose middleware with the ordinary function shape: ```go // Consumer-defined middleware; not an additional exported edge type. func processHeaders(next http.Handler) http.Handler ``` -Header processing can inspect the complete normalized *http.Request, attach -consumer-owned typed context metadata via WithContext, reject a request with an -ordinary HTTP response, or call the next handler. This avoids inventing a second -error-to-HTTP protocol or putting application parsing errors into the Lambda -invocation-failure channel. It also supports headers beyond the action selector. - -Recommend ordinary explicit composition into the handler supplied to New or -NewStreaming, usable identically with an ordinary Go HTTP server. A processor -must preserve the request's context ancestry, honor cancellation, and obey the -usual ResponseWriter lifetime. Middleware construction occurs outside request -execution; the wrapped handler must be safe for the application's concurrency. +Header processing can inspect the complete normalized *http.Request, attach consumer-owned typed context metadata via WithContext, reject a request with an ordinary HTTP response, or call the next handler. +This avoids inventing a second error-to-HTTP protocol or putting application parsing errors into the Lambda invocation-failure channel. +It also supports headers beyond the action selector. + +Recommend ordinary explicit composition into the handler supplied to New or NewStreaming, usable identically with an ordinary Go HTTP server. +A processor must preserve the request's context ancestry, honor cancellation, and obey the usual ResponseWriter lifetime. +Middleware construction occurs outside request execution; +the wrapped handler must be safe for the application's concurrency. Do not add a raw AWS-event hook for this feature. -The HTTP handler contract is broader than a callback returning a header map: -an action dispatcher needs validation, request metadata and controlled rejection, -not just header rewriting. Keep the business action registry, payload schemas, -handler resolution and action-specific authorization in the consuming dispatcher. -First-class support means a documented extension contract, strict selection -helpers, consumer examples and a cross-format test matrix; it does not require -edge to own the application's action registry. +The HTTP handler contract is broader than a callback returning a header map: an action dispatcher needs validation, request metadata and controlled rejection, not just header rewriting. +Keep the business action registry, payload schemas, handler resolution and action-specific authorization in the consuming dispatcher. +First-class support means a documented extension contract, strict selection helpers, consumer examples and a cross-format test matrix; +it does not require edge to own the application's action registry. ## Action-selection helper and pipeline -Recommend providing a shared strict single-value header selector for consumers -to use inside their middleware/dispatcher, with inspectable missing/invalid/ -ambiguous outcomes. The signatures and errors below are accepted independently -of the broader transport error API. Parsing needs no AWS event types or JSON. +Recommend providing a shared strict single-value header selector for consumers to use inside their middleware/dispatcher, with inspectable missing/invalid/ambiguous outcomes. +The signatures and errors below are accepted independently of the broader transport error API. +Parsing needs no AWS event types or JSON. The accepted selection contract is: -- Configure the action header name explicitly; do not hard-code an X-Action - convention. Validate its name at construction. -- Require one nonempty normalized action value. Preserve identifier case. -- Check every represented value case-insensitively by header name. Reject - multiple values, including identical repeats that are still visible. +- Configure the action header name explicitly; + do not hard-code an X-Action convention. + Validate its name at construction. +- Require one nonempty normalized action value. + Preserve identifier case. +- Check every represented value case-insensitively by header name. + Reject multiple values, including identical repeats that are still visible. - For an action grammar that excludes commas, reject any comma-bearing value. - This rejects both joined duplicates in payload 2.0 and literal commas; it does - not recover which one occurred. Do not split a joined value and choose one. -- Do not promise detection of duplicate field lines already deduplicated or lost - by Gateway or an earlier proxy. Validate the available representation. -- Parse the action once and use the same selected value for authorization, - dispatch and consumer logging. Do not read a mutable header again later to - choose a different action from the one authorized. -- Do not infer authentication or authorization from possession of the action - header. Action-specific authorization must precede action execution and any - streaming response publication. - -The consumer pipeline is normalized HTTP request and optional gateway identity, -then consumer header processing/local authentication, action selection, -action-specific authorization and execution. The application can place general -authentication before selection to suit its disclosure policy; the invariant is -that action authorization and execution use the same validated selection. - -Missing/malformed action selections should ordinarily produce an application -400 response, not a Lambda invocation error. The consumer owns response bodies -and unknown-action policy. Authentication/authorization response policy remains -the separate authn/authz contract. No fallback/default action is inferred. + This rejects both joined duplicates in payload 2.0 and literal commas; + it does not recover which one occurred. + Do not split a joined value and choose one. +- Do not promise detection of duplicate field lines already deduplicated or lost by Gateway or an earlier proxy. + Validate the available representation. +- Parse the action once and use the same selected value for authorization, dispatch and consumer logging. + Do not read a mutable header again later to choose a different action from the one authorized. +- Do not infer authentication or authorization from possession of the action header. + Action-specific authorization must precede action execution and any streaming response publication. + +The consumer pipeline is normalized HTTP request and optional gateway identity, then consumer header processing/local authentication, action selection, action-specific authorization and execution. +The application can place general authentication before selection to suit its disclosure policy; +the invariant is that action authorization and execution use the same validated selection. + +Missing/malformed action selections should ordinarily produce an application 400 response, not a Lambda invocation error. +The consumer owns response bodies and unknown-action policy. +Authentication/authorization response policy remains the separate authn/authz contract. +No fallback/default action is inferred. ## Accepted API and behavior -The user accepted the following contract, including the Go review refinements -recorded below. +The user accepted the following contract, including the Go review refinements recorded below. ### Interface and composition -Use http.Handler directly, with http.HandlerFunc when adapting a handler -function. Do not export RequestMiddleware or RequestMiddlewareFunc: no edge API -consumes their proposed Wrap method, and the standard interface already supplies -the required extension point. Consumers may define their own processor types -without adopting an additional edge interface. +Use http.Handler directly, with http.HandlerFunc when adapting a handler function. +Do not export RequestMiddleware or RequestMiddlewareFunc: no edge API consumes their proposed Wrap method, and the standard interface already supplies the required extension point. +Consumers may define their own processor types without adopting an additional edge interface. -Use ordinary explicit composition: outer(inner(dispatcher)). The -outer processor runs first on the request. Do not add WithRequestMiddleware, -a Chain helper, implicit reordering, or another middleware package initially. +Use ordinary explicit composition: outer(inner(dispatcher)). +The outer processor runs first on the request. +Do not add WithRequestMiddleware, a Chain helper, implicit reordering, or another middleware package initially. The same composed handler goes to edge.New, edge.NewStreaming, or net/http. -This keeps execution order visible and does not tie header processing to an AWS -constructor. Consumer middleware factories can return their own configuration -errors before composition and follow the ordinary HTTP handler contract. +This keeps execution order visible and does not tie header processing to an AWS constructor. +Consumer middleware factories can return their own configuration errors before composition and follow the ordinary HTTP handler contract. ### Action header and identifier grammar -Require an explicit header name at selector construction. Use Action with a -value such as orders.create in examples, but do not make it a default or reserve -the name globally. Existing consumer names remain supported. Avoid introducing -an X- prefix simply to designate an application-defined field. +Require an explicit header name at selector construction. +Use Action with a value such as orders.create in examples, but do not make it a default or reserve the name globally. +Existing consumer names remain supported. +Avoid introducing an X- prefix simply to designate an application-defined field. [RFC 6648](https://www.rfc-editor.org/rfc/rfc6648.html#section-3). -For the built-in selector, trim outer SP/HTAB and require a nonempty HTTP token, -preserving case. The existing validToken helper already implements this grammar. -This admits common identifiers such as orders.create, CreateOrder and -orders-create, while excluding commas, embedded whitespace, quoting, colon, -slash and non-ASCII text. HTTP does not mandate this grammar for action headers; -it is our recommended selector contract. A consumer with a different protocol -can implement its own parser in HTTP middleware without changing gateway -translation or weakening the standard selector. +For the built-in selector, trim outer SP/HTAB and require a nonempty HTTP token, preserving case. +The existing validToken helper already implements this grammar. +This admits common identifiers such as orders.create, CreateOrder and orders-create, while excluding commas, embedded whitespace, quoting, colon, slash and non-ASCII text. +HTTP does not mandate this grammar for action headers; +it is our recommended selector contract. +A consumer with a different protocol can implement its own parser in HTTP middleware without changing gateway translation or weakening the standard selector. [HTTP token grammar](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2). ### Selector API and errors @@ -149,18 +131,18 @@ func (h ActionHeader) Parse(headers http.Header) (string, error) ``` Construction validates/canonicalizes the configured field name, stored privately. -The selector is a small immutable value: copying it is safe and it needs neither -shared mutable state nor pointer identity. Its zero value has no configured name, -so Parse returns a configuration error rather than inferring a default header. -Parse neither modifies headers nor writes a response. It needs no context, -AWS event types, or JSON processing. An instance can be reused concurrently; +The selector is a small immutable value: copying it is safe and it needs neither shared mutable state nor pointer identity. +Its zero value has no configured name, so Parse returns a configuration error rather than inferring a default header. +Parse neither modifies headers nor writes a response. +It needs no context, AWS event types, or JSON processing. +An instance can be reused concurrently; callers must not concurrently mutate a Header map being inspected. -Match header names using ASCII case-insensitive comparison, even when a consumer supplies a Header map -with noncanonical direct assignments. Count represented values across all -matching aliases; do not rely solely on Header.Get or Header.Values. Count a -nil/empty value slice as zero values. Multiple represented values are ambiguous -even when identical, and take precedence over errors in an individual value. +Match header names using ASCII case-insensitive comparison, even when a consumer supplies a Header map with noncanonical direct assignments. +Count represented values across all matching aliases; +do not rely solely on Header.Get or Header.Values. +Count a nil/empty value slice as zero values. +Multiple represented values are ambiguous even when identical, and take precedence over errors in an individual value. Export three action-selection sentinels, inspected with errors.Is: @@ -171,95 +153,74 @@ Export three action-selection sentinels, inspected with errors.Is: | ErrActionInvalid | Exactly one value that is empty after SP/HTAB trimming or fails token grammar | For one comma-bearing value, ambiguity classification precedes token validation; -it deliberately covers both Gateway-combined values and a literal comma, whose -origins cannot be distinguished. Return an empty action on every error. Do not -include the supplied action or other header values in error messages. Invalid -constructor configuration is separate from these request-input categories. +it deliberately covers both Gateway-combined values and a literal comma, whose origins cannot be distinguished. +Return an empty action on every error. +Do not include the supplied action or other header values in error messages. +Invalid constructor configuration is separate from these request-input categories. Keep these application-input errors separate from transport invocation faults. -The parsing helper does not encode HTTP statuses. Recommend 400 for selection -errors and, for the header-dispatched API convention, 400 with a distinct -consumer-owned unknown-action code for a syntactically valid but unregistered -action. The consumer retains its response schema and disclosure policy. General -authentication and action authorization retain their separate authn/authz rules. +The parsing helper does not encode HTTP statuses. +Recommend 400 for selection errors and, for the header-dispatched API convention, 400 with a distinct consumer-owned unknown-action code for a syntactically valid but unregistered action. +The consumer retains its response schema and disclosure policy. +General authentication and action authorization retain their separate authn/authz rules. ### Action metadata ownership -Return a string and keep action metadata consumer-owned. Do not add an edge.Action -type, global action context key, or action package at this stage. The dispatcher -can convert the validated string into its own action type, pass it directly to -its authorizer/handler, or attach it through a private typed context key. +Return a string and keep action metadata consumer-owned. +Do not add an edge.Action type, global action context key, or action package at this stage. +The dispatcher can convert the validated string into its own action type, pass it directly to its authorizer/handler, or attach it through a private typed context key. -Resolve once, authorize that same selection, and execute it. If selecting a -registered operation produces an immutable operation descriptor, pass that -descriptor through authorization and execution instead of resolving again from -mutable headers. This supports first-class selection without coupling the -transport module to a consumer's action registry or permissions model. +Resolve once, authorize that same selection, and execute it. +If selecting a registered operation produces an immutable operation descriptor, pass that descriptor through authorization and execution instead of resolving again from mutable headers. +This supports first-class selection without coupling the transport module to a consumer's action registry or permissions model. ## Go rules and Go 1.27 review — 2026-09-15 -Reviewed the Go rules skill's core, types, naming, functions, errors, commentary -and testing guidance, the official Go 1.27 release notes, and the current -adapter/request code. This review replaces the earlier proposed Wrap interface -and pointer-returning selector with the recommendations above. The changes follow -the rules on least mechanism, interfaces at their consumption boundary, and -value semantics for small immutable types. They are API design judgments, not -new Go 1.27 requirements. The user-requested consumer interface remains -http.Handler; first-class header support includes the selector, documentation, -examples and contract tests. - -Keep the constructor because explicit startup validation is useful. A standalone -ParseActionHeader(headers, name) helper would repeat configuration validation on -every request. A useful implicit zero value would require choosing a default -header name, which conflicts with the explicit configuration recommendation. -Keep three sentinel errors with errors.Is; they classify failures without -inventing a structured error payload. errors.AsType is useful when callers need -a typed error's data, but is not a replacement for sentinel matching here. - -Go 1.27 supports generic methods, but this selector has no requirement for a -generic method or generic action type. Consumers can convert the returned string -to their own action type. Direct encoding/json/v2 remains the accepted JSON -boundary; the selector and HTTP middleware need no JSON processing. +Reviewed the Go rules skill's core, types, naming, functions, errors, commentary and testing guidance, the official Go 1.27 release notes, and the current adapter/request code. +This review replaces the earlier proposed Wrap interface and pointer-returning selector with the recommendations above. +The changes follow the rules on least mechanism, interfaces at their consumption boundary, and value semantics for small immutable types. +They are API design judgments, not new Go 1.27 requirements. +The user-requested consumer interface remains http.Handler; +first-class header support includes the selector, documentation, examples and contract tests. + +Keep the constructor because explicit startup validation is useful. +A standalone ParseActionHeader(headers, name) helper would repeat configuration validation on every request. +A useful implicit zero value would require choosing a default header name, which conflicts with the explicit configuration recommendation. +Keep three sentinel errors with errors.Is; +they classify failures without inventing a structured error payload. +errors.AsType is useful when callers need a typed error's data, but is not a replacement for sentinel matching here. + +Go 1.27 supports generic methods, but this selector has no requirement for a generic method or generic action type. +Consumers can convert the returned string to their own action type. +Direct encoding/json/v2 remains the accepted JSON boundary; +the selector and HTTP middleware need no JSON processing. [Go 1.27 release notes](https://go.dev/doc/go1.27). -For subsequent contract tests, consider httptest.NewTestServer for ordinary HTTP -integration using its in-memory network, and synctest.Sleep when a streaming -test needs both virtual time advancement and quiescence. Existing tests need no -mechanical rewrite. Go 1.27's Server.MaxHeaderValueCount is an HTTP server request -limit; it neither validates action ambiguity nor bounds Lambda response headers. +For subsequent contract tests, consider httptest.NewTestServer for ordinary HTTP integration using its in-memory network, and synctest.Sleep when a streaming test needs both virtual time advancement and quiescence. +Existing tests need no mechanical rewrite. +Go 1.27's Server.MaxHeaderValueCount is an HTTP server request limit; +it neither validates action ambiguity nor bounds Lambda response headers. It does not replace this selector or settle decision 0010's budgets. -[httptest.NewTestServer](https://pkg.go.dev/net/http/httptest#NewTestServer), -[synctest.Sleep](https://pkg.go.dev/testing/synctest#Sleep), -[http.Server](https://pkg.go.dev/net/http#Server). +[httptest.NewTestServer](https://pkg.go.dev/net/http/httptest#NewTestServer), [synctest.Sleep](https://pkg.go.dev/testing/synctest#Sleep), [http.Server](https://pkg.go.dev/net/http#Server). -These accepted recommendations settle the initial implementation for this -feature. They do not imply approval of decision 0010's separate response-header -budgets/combination policy or settle the broader identity/error APIs. +These accepted recommendations settle the initial implementation for this feature. +They do not imply approval of decision 0010's separate response-header budgets/combination policy or settle the broader identity/error APIs. ## Implementation and validation action.go implements the immutable value selector and three sentinel errors. -It reuses the HTTP token validator and compares canonical field names, including -noncanonical direct map assignments, without Unicode case folding. Selection -does not mutate the request or encode JSON. - -Tests cover custom header names, missing/empty values, token grammar, casing, -repeated/conflicting and identical values, comma values, sanitized errors, -zero configuration, copying and concurrent selector reuse. The request matrix -covers native HTTP through Go 1.27's in-memory test server, raw REST and HTTP -payload 1.0/2.0 decoding, and already typed V1/V2 request conversion. It preserves -the accepted distinction between V1 values mirrored across maps and actual -repeats within multivalue lists. - -The runnable consumer example uses ordinary http.Handler middleware and a -consumer-owned context key. Composition tests cover extra-header metadata, -ordering, rejected-request short circuiting, context ancestry/cancellation and -using the same selection for authorization and execution after a header change. -They exercise the existing constructor and private request foundations, not -public Lambda invocation, which is still pending. Authorization-before-streaming -integration remains an acceptance test for the future public streaming writer; +It reuses the HTTP token validator and compares canonical field names, including noncanonical direct map assignments, without Unicode case folding. +Selection does not mutate the request or encode JSON. + +Tests cover custom header names, missing/empty values, token grammar, casing, repeated/conflicting and identical values, comma values, sanitized errors, zero configuration, copying and concurrent selector reuse. +The request matrix covers native HTTP through Go 1.27's in-memory test server, raw REST and HTTP payload 1.0/2.0 decoding, and already typed V1/V2 request conversion. +It preserves the accepted distinction between V1 values mirrored across maps and actual repeats within multivalue lists. + +The runnable consumer example uses ordinary http.Handler middleware and a consumer-owned context key. +Composition tests cover extra-header metadata, ordering, rejected-request short circuiting, context ancestry/cancellation and using the same selection for authorization and execution after a header change. +They exercise the existing constructor and private request foundations, not public Lambda invocation, which is still pending. +Authorization-before-streaming integration remains an acceptance test for the future public streaming writer; this feature adds no streaming API or response-header policy. -Validation on Go 1.27.1: focused action tests and the runnable example, the full -race suite (including the existing SDK streaming probe), go vet, formatting/diff -checks, and Linux arm64/amd64 builds all pass. No live AWS resources were used. +Validation on Go 1.27.1: focused action tests and the runnable example, the full race suite (including the existing SDK streaming probe), go vet, formatting/diff checks, and Linux arm64/amd64 builds all pass. +No live AWS resources were used. diff --git a/docs/decisions/0012-shared-identity.md b/docs/decisions/0012-shared-identity.md index 00204ab..deeb00b 100644 --- a/docs/decisions/0012-shared-identity.md +++ b/docs/decisions/0012-shared-identity.md @@ -1,39 +1,38 @@ # 0012: Shared identity, provenance, and producer composition -Status: accepted as the starting contract on 2026-09-15; claims foundation -implemented under 0014; caller/context and JWT normalization implemented on -2026-09-16. Native gateway producers and buffered public invocation are now -implemented under accepted decision 0018. +Status: accepted as the starting contract on 2026-09-15; +claims foundation implemented under 0014; +caller/context and JWT normalization implemented on 2026-09-16. +Native gateway producers and buffered public invocation are now implemented under accepted decision 0018. This walks the dependencies needed for the first callable buffered adapter. -Decision 0013 covers the public errors used at those boundaries. Approval of the -implementation order does not approve these new API and trust decisions. +Decision 0013 covers the public errors used at those boundaries. +Approval of the implementation order does not approve these new API and trust decisions. ## Evidence and constraints at proposal time - `adapter.go` records WithGatewayIdentity, but does not yet produce identity. - `decode.go` preserves raw authorizer JSON separately from SDK event types. Typed entry points must not marshal events to get back onto that path. -- `invocation.go` already owns cancellation, the original body, multipart - cleanup, and panic propagation. Identity must fit inside that lifetime. -- Decision 0001 accepts anonymous by default, native gateway identity by opt-in, - and explicitly configured local verification. It leaves producer precedence - open. The saved Beakley review found both an invalid IAM/JWT union and an - anonymous context value that prevented subsequent bearer verification. +- `invocation.go` already owns cancellation, the original body, multipart cleanup, and panic propagation. + Identity must fit inside that lifetime. +- Decision 0001 accepts anonymous by default, native gateway identity by opt-in, and explicitly configured local verification. + It leaves producer precedence open. + The saved Beakley review found both an invalid IAM/JWT union and an anonymous context value that prevented subsequent bearer verification. - Go's context documentation recommends private keys and request-scoped values. Go rules favor concrete producer types and interfaces defined by consumers. - The Go 1.27 notes make direct JSON v2 available with strict defaults; generic - methods are available but do not, by themselves, justify a generic claims API. -- AWS documents different REST identity fields for IAM, Cognito, and custom - authorizers. HTTP API payload 1.0 and REST share a shape; payload version does - not identify the API product or its configured authorization type. -- The installed AWS SDK v1.55.0 has independent JWT, IAM, and Lambda authorizer - fields in V2, with JWT claims represented as map[string]string. V1 authorizer - data is map[string]any. These are data containers, not identity validators. + The Go 1.27 notes make direct JSON v2 available with strict defaults; + generic methods are available but do not, by themselves, justify a generic claims API. +- AWS documents different REST identity fields for IAM, Cognito, and custom authorizers. + HTTP API payload 1.0 and REST share a shape; + payload version does not identify the API product or its configured authorization type. +- The installed AWS SDK v1.55.0 has independent JWT, IAM, and Lambda authorizer fields in V2, with JWT claims represented as map[string]string. + V1 authorizer data is map[string]any. + These are data containers, not identity validators. - AWS JWT authorizers validate aud preferentially to client_id when both exist; that does not make those fields interchangeable in an application identity. - Scope checks also depend on route configuration. A gateway assertion does not - universally establish that an access-token-only policy was applied. + Scope checks also depend on route configuration. + A gateway assertion does not universally establish that an access-token-only policy was applied. Official sources checked through AWS public MCP and installed Go documentation: @@ -85,10 +84,11 @@ Identity is independent of the selected action and carries no dispatcher state. **Question:** interface, exported struct with optional arms, or an opaque value? -**Recommend:** a small concrete `identity.Caller` with private representation, -anonymous zero value, and mutually exclusive JWT/IAM views. Construction returns -an error for invalid inputs; users cannot set both arms. Read-only value methods -can share immutable backing data. There is no AWS dependency in identity. +**Recommend:** a small concrete `identity.Caller` with private representation, anonymous zero value, and mutually exclusive JWT/IAM views. +Construction returns an error for invalid inputs; +users cannot set both arms. +Read-only value methods can share immutable backing data. +There is no AWS dependency in identity. Proposed core surface (declarations illustrate the contract, not runnable code): @@ -103,31 +103,29 @@ func FromContext(ctx context.Context) Caller func WithCaller(ctx context.Context, caller Caller) (context.Context, error) ``` -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. Avoid a -second Authenticated boolean that can disagree with the union. Do not make a -public equality/hash contract for the entire caller or infer equivalence from -matching subjects. Consumers may define small interfaces where actually needed. +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. +Avoid a second Authenticated boolean that can disagree with the union. +Do not make a public equality/hash contract for the entire caller or infer equivalence from matching subjects. +Consumers may define small interfaces where actually needed. -**Reasoning:** this prevents the reproduced dual-arm authorization bug at the -construction boundary. An implementable Caller interface or public pointer arms -would require every authorization rule to re-check the union. +**Reasoning:** this prevents the reproduced dual-arm authorization bug at the construction boundary. +An implementable Caller interface or public pointer arms would require every authorization rule to re-check the union. ## I2. Construction is validation, not verification **Question:** what security guarantee does a constructed caller make? -**Recommend:** constructors validate representation and own their inputs. The -producer is responsible for authentication and source attribution. Constructors -do not parse bearer headers, verify signatures, fetch keys, or decide permissions. -Source is useful provenance recorded by trusted application code, not a proof -that hostile code in the same process cannot forge. +**Recommend:** constructors validate representation and own their inputs. +The producer is responsible for authentication and source attribution. +Constructors do not parse bearer headers, verify signatures, fetch keys, or decide permissions. +Source is useful provenance recorded by trusted application code, not a proof that hostile code in the same process cannot forge. -Identity is a snapshot established for a request, with no clock or network work -in its accessors. Expiry checks belong to the authenticating producer's admission -policy. Long-running streams do not silently refresh credentials or acquire new -permissions; any later reauthorization is explicit application policy. +Identity is a snapshot established for a request, with no clock or network work in its accessors. +Expiry checks belong to the authenticating producer's admission policy. +Long-running streams do not silently refresh credentials or acquire new permissions; +any later reauthorization is explicit application policy. Recommended producer shape: @@ -136,19 +134,17 @@ func NewJWT(claims Claims, source Source, opts ...JWTOption) (Caller, error) func NewIAM(principalARN string, source Source, opts ...IAMOption) (Caller, error) ``` -JWT options supply information that is separate from the claim object, notably -the gateway's dedicated scopes collection. They must not override issuer, -subject, or permissions already represented by conflicting source data. Reject -duplicate assignments for identity construction rather than using last-wins -to conceal contradictory assertions. No mutating caller builders or setters. +JWT options supply information that is separate from the claim object, notably the gateway's dedicated scopes collection. +They must not override issuer, subject, or permissions already represented by conflicting source data. +Reject duplicate assignments for identity construction rather than using last-wins to conceal contradictory assertions. +No mutating caller builders or setters. -JWT source may be gateway, verified token, or explicit custom mapping. Initially -IAM source may be gateway or explicit custom mapping; there is no local SigV4 -verifier. Reject unknown sources and impossible source/kind combinations. +JWT source may be gateway, verified token, or explicit custom mapping. +Initially IAM source may be gateway or explicit custom mapping; +there is no local SigV4 verifier. +Reject unknown sources and impossible source/kind combinations. -**Alternative:** an unforgeable-looking internal constructor would make external -verifiers and fixtures awkward while failing to secure against application code -that already controls the authorization pipeline. +**Alternative:** an unforgeable-looking internal constructor would make external verifiers and fixtures awkward while failing to secure against application code that already controls the authorization pipeline. ## I3. JWT principal and permission semantics @@ -166,81 +162,77 @@ that already controls the authorization pipeline. | Cognito groups | Preserve the Cognito-specific name/meaning. Do not reinterpret arbitrary groups/roles claims as Cognito groups. | | Application grants | Produced by application principal resolution in authz, not appended to the authenticated token's claims. | -Use comma-ok accessors for optional facts and collections. A known empty -collection differs from unavailable data. Set-valued authorization checks are -case-sensitive and exact; normalization may deduplicate identical elements but -must not change their spelling or apply wildcard semantics. +Use comma-ok accessors for optional facts and collections. +A known empty collection differs from unavailable data. +Set-valued authorization checks are case-sensitive and exact; +normalization may deduplicate identical elements but must not change their spelling or apply wildcard semantics. -**Issue:** gateway string representations of aud, scp, or cognito:groups do not -provide a general reversible encoding for their original JSON types. Recommend -no bracket stripping, comma splitting, or reparsing a string as embedded JSON. -An actual JSON array can supply a list; a flattened ambiguous value cannot. -OAuth scope strings are a specific documented space-delimited representation, -not a precedent for splitting unrelated claims. +**Issue:** gateway string representations of aud, scp, or cognito:groups do not provide a general reversible encoding for their original JSON types. +Recommend no bracket stripping, comma splitting, or reparsing a string as embedded JSON. +An actual JSON array can supply a list; +a flattened ambiguous value cannot. +OAuth scope strings are a specific documented space-delimited representation, not a precedent for splitting unrelated claims. If a recognized claim has an invalid actual type, reject the producer's input. -If the source has flattened away its type, retain that representation and mark -the normalized collection unavailable. A rule requiring unavailable information -must not authorize. A custom claim schema or local verification can resolve the -missing fidelity later; do not fill the gap with an unverified header JWT. +If the source has flattened away its type, retain that representation and mark the normalized collection unavailable. +A rule requiring unavailable information must not authorize. +A custom claim schema or local verification can resolve the missing fidelity later; +do not fill the gap with an unverified header JWT. ## I4. Owned claims with explicit representation **Question:** can a map[string]any or map[string]string be the shared claims API? -**Recommend:** an immutable `Claims`/`Claim` view, with separate construction from -raw JSON, gateway string maps, and already decoded SDK V1 values. The names and -complete accessor inventory are a follow-on API detail to review before that -part is implemented; the recommended semantics are concrete: - -- Raw claim JSON is validated with direct JSON v2. Preserve exact numeric text, - arrays, objects, null, and booleans. This is fidelity to the received claim - object, not a claim that API Gateway preserved the original JWT bytes. -- Gateway string values remain marked as gateway text. A value resembling JSON - is still text. Scalar claim interpretation must use its declared schema. -- Already decoded SDK values are copied directly without marshaling. Preserve - the available shape. An upstream float64 must not be presented as the original - exact JSON number, even when its current value happens to be integral. -- Validate UTF-8 on directly supplied strings as well as JSON input. Do not - coerce incorrectly typed standard claims or silently rewrite identifiers. -- Expose presence, representation, and checked scalar/list access, not unchecked - assertions. Wrong-type reads must be distinguishable from missing values. -- Copy input maps, slices, and JSON bytes; returned containers/bytes must not - provide mutation access. Immutable views may share private owned backing data. -- Retain unknown claims without promoting them to permissions. Do not store the - bearer token or complete Authorization header in identity. -- Do not expose a generic `Claim[T]` convenience in the first API. It would hide - conversion and fidelity policy before those conversions are well established. -- No implicit JSON export of an entire caller. Default diagnostic formatting - should describe kind/source without dumping claims or identifiers. Explicit - application logging remains application-owned. - -**Remaining resource issue:** cloning a typed V1 map can encounter huge input, -cycles, or arbitrary Go objects. Recommend a closed set of supported JSON-like -types, cycle/depth checks, and a measured claim resource budget before exposing -those constructors. Reject unsupported values without invoking custom marshal -methods. Numeric budget defaults need a separate measured proposal; no AWS -claim-size quota is inferred here. This can be investigated independently while -the caller/context/error core is implemented after approval. +**Recommend:** an immutable `Claims`/`Claim` view, with separate construction from raw JSON, gateway string maps, and already decoded SDK V1 values. +The names and complete accessor inventory are a follow-on API detail to review before that part is implemented; +the recommended semantics are concrete: + +- Raw claim JSON is validated with direct JSON v2. + Preserve exact numeric text, arrays, objects, null, and booleans. + This is fidelity to the received claim object, not a claim that API Gateway preserved the original JWT bytes. +- Gateway string values remain marked as gateway text. + A value resembling JSON is still text. + Scalar claim interpretation must use its declared schema. +- Already decoded SDK values are copied directly without marshaling. + Preserve the available shape. + An upstream float64 must not be presented as the original exact JSON number, even when its current value happens to be integral. +- Validate UTF-8 on directly supplied strings as well as JSON input. + Do not coerce incorrectly typed standard claims or silently rewrite identifiers. +- Expose presence, representation, and checked scalar/list access, not unchecked assertions. + Wrong-type reads must be distinguishable from missing values. +- Copy input maps, slices, and JSON bytes; + returned containers/bytes must not provide mutation access. + Immutable views may share private owned backing data. +- Retain unknown claims without promoting them to permissions. + Do not store the bearer token or complete Authorization header in identity. +- Do not expose a generic `Claim[T]` convenience in the first API. + It would hide conversion and fidelity policy before those conversions are well established. +- No implicit JSON export of an entire caller. + Default diagnostic formatting should describe kind/source without dumping claims or identifiers. + Explicit application logging remains application-owned. + +**Remaining resource issue:** cloning a typed V1 map can encounter huge input, cycles, or arbitrary Go objects. +Recommend a closed set of supported JSON-like types, cycle/depth checks, and a measured claim resource budget before exposing those constructors. +Reject unsupported values without invoking custom marshal methods. +Numeric budget defaults need a separate measured proposal; +no AWS claim-size quota is inferred here. +This can be investigated independently while the caller/context/error core is implemented after approval. ## I5. IAM facts remain exact facts -**Recommend:** retain the exact principal ARN, parsed partition/account/service -and principal form, and optional gateway principal identifiers. Validate the -principal form rather than accepting any syntactically valid resource ARN. +**Recommend:** retain the exact principal ARN, parsed partition/account/service and principal form, and optional gateway principal identifiers. +Validate the principal form rather than accepting any syntactically valid resource ARN. Cross-check an independently supplied caller account ID against the ARN. -Do not use requestContext.accountId as the caller account: it is API-owner -metadata. Do not use access-key ID, source IP, API key, mTLS metadata, or a Cognito -identity-pool ID alone as proof of IAM identity. Do not retain access-key/API-key -values in the shared caller. +Do not use requestContext.accountId as the caller account: it is API-owner metadata. +Do not use access-key ID, source IP, API key, mTLS metadata, or a Cognito identity-pool ID alone as proof of IAM identity. +Do not retain access-key/API-key values in the shared caller. -Keep an STS session ARN as an STS session ARN. Do not manufacture an IAM role ARN -or remove a session name to pretend to recover a stable role identity. Support -for the documented caller forms and their ARN grammar needs explicit fixtures -in the producer implementation; unsupported forms fail rather than falling back -to anonymous. ARN authorization matching and role/session resolution remain -authz policies, not IAM policy evaluation in identity. +Keep an STS session ARN as an STS session ARN. +Do not manufacture an IAM role ARN or remove a session name to pretend to recover a stable role identity. +Support for the documented caller forms and their ARN grammar needs explicit fixtures in the producer implementation; +unsupported forms fail rather than falling back to anonymous. +ARN authorization matching and role/session resolution remain authz policies, not IAM policy evaluation in identity. **Reasoning:** STS documents both the ARN and ID as including session identity; losing that information changes what an application is authorizing. @@ -248,27 +240,25 @@ losing that information changes what an application is authorizing. ## I6. Context installation and invocation isolation **Recommend:** FromContext returns the anonymous zero value when absent. -WithCaller uses a private context key, returns a derived context, and never -mutates shared state. Supplying anonymous to an empty context is a no-op. It -cannot clear an existing nonanonymous caller. Installing another nonanonymous -caller, even one with apparently matching fields, returns identity.ErrConflict. - -Expose identity.ErrInvalidCaller for invalid construction and identity.ErrConflict -for producer collisions. Do not add a HasIdentity API whose presence test can -become an authentication shortcut. Nil context is invalid; installation returns -an error and FromContext requires a nonnil context like ordinary context use. - -**New invocation boundary recommendation:** reject a parent context already -carrying a nonanonymous identity, before running the HTTP handler, on raw and -typed entry points in either gateway mode. Preserve unrelated context values. -This makes accidental reuse of a previous request's caller visible. Native HTTP -middleware is unaffected because it does not pass through a Lambda entry point. +WithCaller uses a private context key, returns a derived context, and never mutates shared state. +Supplying anonymous to an empty context is a no-op. +It cannot clear an existing nonanonymous caller. +Installing another nonanonymous caller, even one with apparently matching fields, returns identity.ErrConflict. + +Expose identity.ErrInvalidCaller for invalid construction and identity.ErrConflict for producer collisions. +Do not add a HasIdentity API whose presence test can become an authentication shortcut. +Nil context is invalid; +installation returns an error and FromContext requires a nonnil context like ordinary context use. + +**New invocation boundary recommendation:** reject a parent context already carrying a nonanonymous identity, before running the HTTP handler, on raw and typed entry points in either gateway mode. +Preserve unrelated context values. +This makes accidental reuse of a previous request's caller visible. +Native HTTP middleware is unaffected because it does not pass through a Lambda entry point. **Alternatives:** inheriting the caller can authorize a different invocation; silently masking it conceals a composition mistake and needs a clearing API. -Explicit support for a preauthenticated invocation could be added later if a -real consumer requires it. Do not infer that authorization from an arbitrary -context supplied to an entry point today. +Explicit support for a preauthenticated invocation could be added later if a real consumer requires it. +Do not infer that authorization from an arbitrary context supplied to an entry point today. ## I7. Gateway recognition: absent, invalid, and unsupported differ @@ -283,97 +273,82 @@ context supplied to an entry point today. | Nonempty custom authorizer context without a configured mapper | Unsupported identity error; never treat principalId alone as native IAM/JWT | | No authenticated caller but sourceIp/userAgent/API-key metadata exists | Anonymous; those fields are not an authentication producer | -With gateway identity disabled, authorizer data does not establish identity and -semantic identity validation is skipped. Raw JSON syntax is still validated by -the existing decoder. Identity opt-in is not a RequireAuthenticated option; +With gateway identity disabled, authorizer data does not establish identity and semantic identity validation is skipped. +Raw JSON syntax is still validated by the existing decoder. +Identity opt-in is not a RequireAuthenticated option; applications protect routes through authn/authz middleware. -**Qualification:** V2 has explicit JWT/IAM/Lambda containers, but V1 has no -reliable universal authorizer-type discriminator. Follow documented native -claims/identity shapes conservatively; principalId/custom indicators take a V1 -event out of native inference. Do not claim this proves deployment settings. -An event producer can forge any event shape; trust in gateway assertions already -requires the intended restricted integration as accepted in decision 0001. - -Unknown extension fields alongside a recognized native assertion are not, by -themselves, another producer. Retain/ignore them according to the metadata -contract without promoting them to permissions. A nonempty authorizer object -with no recognized native producer remains unsupported. This permits documented -AWS field additions without making arbitrary custom contexts authoritative. - -The HTTP API 1.0 documentation supplies a null-authorizer example, not a complete -matrix of JWT/IAM claim encodings. Carry this as a fixture/documentation coverage -gap, especially for flattened collections. Do not invent a V1 array encoding or -silently restrict transport support to solve it. Add independently sourced -fixtures for each supported producer shape before claiming that combination. +**Qualification:** V2 has explicit JWT/IAM/Lambda containers, but V1 has no reliable universal authorizer-type discriminator. +Follow documented native claims/identity shapes conservatively; +principalId/custom indicators take a V1 event out of native inference. +Do not claim this proves deployment settings. +An event producer can forge any event shape; +trust in gateway assertions already requires the intended restricted integration as accepted in decision 0001. + +Unknown extension fields alongside a recognized native assertion are not, by themselves, another producer. +Retain/ignore them according to the metadata contract without promoting them to permissions. +A nonempty authorizer object with no recognized native producer remains unsupported. +This permits documented AWS field additions without making arbitrary custom contexts authoritative. + +The HTTP API 1.0 documentation supplies a null-authorizer example, not a complete matrix of JWT/IAM claim encodings. +Carry this as a fixture/documentation coverage gap, especially for flattened collections. +Do not invent a V1 array encoding or silently restrict transport support to solve it. +Add independently sourced fixtures for each supported producer shape before claiming that combination. ## I8. Local authentication and custom producers -**Recommend:** the default local bearer middleware verifies only when no caller -has been established. If already authenticated input reaches it, fail as an -explicit composition conflict; do not skip based on context presence, replace -the caller, or merge permissions. Applications initially choose one producer per -route through ordinary middleware composition. +**Recommend:** the default local bearer middleware verifies only when no caller has been established. +If already authenticated input reaches it, fail as an explicit composition conflict; +do not skip based on context presence, replace the caller, or merge permissions. +Applications initially choose one producer per route through ordinary middleware composition. -This is deliberately conservative: a gateway-authenticated route normally still -contains its Authorization header. The adapter must not mistake that header for -a second authentication producer. A collision happens when another configured -producer runs, not merely because credentials are present in a header. +This is deliberately conservative: a gateway-authenticated route normally still contains its Authorization header. +The adapter must not mistake that header for a second authentication producer. +A collision happens when another configured producer runs, not merely because credentials are present in a header. -Future gateway fallback or re-verification can be explicit policies with their -own comparison rules. A fallback policy would invoke the local verifier only -for anonymous input. Re-verification must validate its own issuer/client policy -and cannot equate identities by sub/client_id alone. Defer their option names -and credential-conflict behavior until the authn middleware design. +Future gateway fallback or re-verification can be explicit policies with their own comparison rules. +A fallback policy would invoke the local verifier only for anonymous input. +Re-verification must validate its own issuer/client policy and cannot equate identities by sub/client_id alone. +Defer their option names and credential-conflict behavior until the authn middleware design. -Custom-authorizer mapping is also deferred. When added, it should return a -validated Caller through a context-aware function, with no silent fallback and -no automatic extra-claim merge. It may eventually need a distinct custom -principal kind; do not force an arbitrary principalId into IAM or JWT now. +Custom-authorizer mapping is also deferred. +When added, it should return a validated Caller through a context-aware function, with no silent fallback and no automatic extra-claim merge. +It may eventually need a distinct custom principal kind; +do not force an arbitrary principalId into IAM or JWT now. ## Implementation graph and acceptance 1. Approve/revise caller invariants, provenance, context, and error contracts. 2. Implement caller/context/error core and test construction/ownership/conflicts. -3. Review the precise claims accessors, budget measurements, and producer fixture - matrix. Implement shared claim normalization and minimum native producers. +3. Review the precise claims accessors, budget measurements, and producer fixture matrix. + Implement shared claim normalization and minimum native producers. 4. Wire raw and typed buffered invocation using the same producers and errors. -5. Prove registration through the real SDK wrapper; add runnable examples. +5. Prove registration through the real SDK wrapper; + add runnable examples. 6. Design local Cognito verification/JWKS behavior and authz against this core. 7. Reuse the same preparation boundary in the separate streaming entry points. -Do not publish incomplete constructors or placeholder invocation methods to -represent these intermediate steps. The claim contract must be ready before -publishing constructors that depend on it. +Do not publish incomplete constructors or placeholder invocation methods to represent these intermediate steps. +The claim contract must be ready before publishing constructors that depend on it. -Acceptance includes anonymous-to-local-verifier composition, dual-source -rejection, raw/typed equivalence where information survives, explicit loss of -fidelity, copied input/output containers, preserved issuer/client distinctions, -no action/grant mutation of authenticated claims, and no cross-invocation caller -inheritance. No live AWS invocation is needed for this design review. +Acceptance includes anonymous-to-local-verifier composition, dual-source rejection, raw/typed equivalence where information survives, explicit loss of fidelity, copied input/output containers, preserved issuer/client distinctions, no action/grant mutation of authenticated claims, and no cross-invocation caller inheritance. +No live AWS invocation is needed for this design review. ## Resolution The user agreed with all recommendations as a starting point on 2026-09-15. -This accepts I1-I8 and the implementation order. The explicitly deferred claim -accessor inventory, claim resource limits, producer fixture coverage, and later -authn/custom-mapper policies still require their recorded follow-up review. -The claims constructors, immutable views and resource/error foundation are now -implemented under accepted decision 0014. Caller/context construction, JWT -normalization and dedicated-scope accounting are now implemented. The user -approved IAM caller forms in 0015/0017 and the literal user-path refinement. -Native gateway producers are implemented under the approved fixture/recognition -contract in 0018, with raw/typed fidelity qualifications documented separately. - -Accepted follow-up 0016 adds an online STS-verified IAM proof producer alongside -the gateway/custom producers described in I2. Accepted decision 0017 adds -SourceVerifiedIAMProof, restricted to IAM callers. For I8, one explicitly configured selector -may choose IAM proof or OAuth verification per request on the same route. It -still installs only one caller and never retries another credential mechanism -after authentication failure or merges identities. - -The private shared invocation boundary now rejects inherited nonanonymous -callers with ErrIdentity and identity.ErrConflict before running request work. -Tests cover source/kind restrictions, exact IAM facts, scope consistency and -budgets, collection ownership, context collisions and sanitized diagnostics. +This accepts I1-I8 and the implementation order. +The explicitly deferred claim accessor inventory, claim resource limits, producer fixture coverage, and later authn/custom-mapper policies still require their recorded follow-up review. +The claims constructors, immutable views and resource/error foundation are now implemented under accepted decision 0014. +Caller/context construction, JWT normalization and dedicated-scope accounting are now implemented. +The user approved IAM caller forms in 0015/0017 and the literal user-path refinement. +Native gateway producers are implemented under the approved fixture/recognition contract in 0018, with raw/typed fidelity qualifications documented separately. + +Accepted follow-up 0016 adds an online STS-verified IAM proof producer alongside the gateway/custom producers described in I2. +Accepted decision 0017 adds SourceVerifiedIAMProof, restricted to IAM callers. +For I8, one explicitly configured selector may choose IAM proof or OAuth verification per request on the same route. +It still installs only one caller and never retries another credential mechanism after authentication failure or merges identities. + +The private shared invocation boundary now rejects inherited nonanonymous callers with ErrIdentity and identity.ErrConflict before running request work. +Tests cover source/kind restrictions, exact IAM facts, scope consistency and budgets, collection ownership, context collisions and sanitized diagnostics. Full race tests, vet and Linux arm64/amd64 builds pass on Go 1.27.1. diff --git a/docs/decisions/0013-public-invocation-errors.md b/docs/decisions/0013-public-invocation-errors.md index 7fe28e6..eca18a5 100644 --- a/docs/decisions/0013-public-invocation-errors.md +++ b/docs/decisions/0013-public-invocation-errors.md @@ -1,27 +1,24 @@ # 0013: Public invocation error contracts -Status: accepted on 2026-09-15; implemented for existing private transport stages. +Status: accepted on 2026-09-15; +implemented for existing private transport stages. ## Evidence -Decisions 0002, 0004, 0006, and 0009 already settle response ownership, typed -zero responses on failure, sticky writer faults, cancellation precedence, and -the streaming handoff boundary. This proposal gives those outcomes a public Go -contract; it does not reopen them. - -Current code returns sanitized private errors from decode/request conversion, -response snapshots/encoding, cleanup, and streaming. It already preserves -http.ErrNotSupported and http.ErrContentLength where meaningful. Body-forbidden -writes return http.ErrBodyNotAllowed without poisoning an otherwise valid -response. `withInvocation` joins cleanup faults after the operation fault and -uses parent cancellation only when no operation/cleanup error exists. - -AWS documents that a Lambda function error or malformed proxy response produces -an API Gateway 502, while a Lambda API invocation rejection produces 500. A valid -proxy response controls the HTTP status; API Gateway does not retry Lambda -invocations. Go documents errors.Is, errors.AsType, and traversal of joined error -trees. The SDK's Handler interface has one result/error channel for a buffered -invocation, not a separate application HTTP error channel. +Decisions 0002, 0004, 0006, and 0009 already settle response ownership, typed zero responses on failure, sticky writer faults, cancellation precedence, and the streaming handoff boundary. +This proposal gives those outcomes a public Go contract; +it does not reopen them. + +Current code returns sanitized private errors from decode/request conversion, response snapshots/encoding, cleanup, and streaming. +It already preserves http.ErrNotSupported and http.ErrContentLength where meaningful. +Body-forbidden writes return http.ErrBodyNotAllowed without poisoning an otherwise valid response. +`withInvocation` joins cleanup faults after the operation fault and uses parent cancellation only when no operation/cleanup error exists. + +AWS documents that a Lambda function error or malformed proxy response produces an API Gateway 502, while a Lambda API invocation rejection produces 500. +A valid proxy response controls the HTTP status; +API Gateway does not retry Lambda invocations. +Go documents errors.Is, errors.AsType, and traversal of joined error trees. +The SDK's Handler interface has one result/error channel for a buffered invocation, not a separate application HTTP error channel. Sources checked through AWS public MCP and installed Go documentation: @@ -45,22 +42,20 @@ Sources checked through AWS public MCP and installed Go documentation: | Invalid response construction, unsupported committed behavior, resource limit, cleanup failure | Go invocation error; discard buffered result | | Application explicitly writes 500 | Valid HTTP response with nil invocation error if transport completes | -**Reasoning:** an invalid token presented to an explicitly configured verifier -is an application authentication outcome. An inconsistent gateway assertion is -a failure to establish the integration's promised identity. Turning both into -the same Go error would make routine authentication failures appear as gateway -502s. The transport must not manufacture 401/403/503 from arbitrary errors. +**Reasoning:** an invalid token presented to an explicitly configured verifier is an application authentication outcome. +An inconsistent gateway assertion is a failure to establish the integration's promised identity. +Turning both into the same Go error would make routine authentication failures appear as gateway 502s. +The transport must not manufacture 401/403/503 from arbitrary errors. -Authn/authz HTTP status, bearer challenges, and public-route policies still need -their own review. These errors do not settle that middleware's complete API. +Authn/authz HTTP status, bearer challenges, and public-route policies still need their own review. +These errors do not settle that middleware's complete API. ## E2. Stable categories plus one diagnostic type **Question:** export each private error, only strings, or structured categories? -**Recommend:** a small set of sentinels checked with errors.Is, plus an opaque -*edge.InvocationError carrying bounded diagnostic fields. Do not require parsing -Error() text and do not expose decoder or SDK implementation errors. +**Recommend:** a small set of sentinels checked with errors.Is, plus an opaque *edge.InvocationError carrying bounded diagnostic fields. +Do not require parsing Error() text and do not expose decoder or SDK implementation errors. | Proposed sentinel | Meaning | | --- | --- | @@ -73,10 +68,10 @@ Error() text and do not expose decoder or SDK implementation errors. | ErrCleanup | Request-resource cleanup failed | | ErrStream | Stream lifecycle failure or recovered late producer/reporter panic | -Use identity.ErrInvalidCaller and identity.ErrConflict as safe subordinate causes -of ErrIdentity. An unsupported custom producer can additionally match -errors.ErrUnsupported. Do not export one sentinel for every missing event field -or invalid header; those are diagnostics within a category. +Use identity.ErrInvalidCaller and identity.ErrConflict as safe subordinate causes of ErrIdentity. +An unsupported custom producer can additionally match errors.ErrUnsupported. +Do not export one sentinel for every missing event field or invalid header; +those are diagnostics within a category. Proposed diagnostic shape: @@ -88,55 +83,45 @@ func (e *InvocationError) Operation() string func (e *InvocationError) Limit() (name string, maximum int64, ok bool) ``` -Operation uses documented bounded labels: validate, decode, request, identity, -response, encode, cleanup, stream. Limit is present only for a limit failure and -reports its configured maximum and a bounded name such as response_headers, -buffered_body, buffered_envelope, or stream_metadata. Do not add arbitrary field -paths, credential values, request snapshots, or caller identifiers. +Operation uses documented bounded labels: validate, decode, request, identity, response, encode, cleanup, stream. +Limit is present only for a limit failure and reports its configured maximum and a bounded name such as response_headers, buffered_body, buffered_envelope, or stream_metadata. +Do not add arbitrary field paths, credential values, request snapshots, or caller identifiers. The private cause can join a category and explicitly allowed subordinate errors; -Unwrap exposes only that safe tree. Avoid custom Is/As machinery where ordinary -wrapping suffices. Use errors.AsType[*edge.InvocationError] on Go 1.27 for details. -Do not add a generic Result[T], a second numeric error-code registry, or error -methods that prescribe HTTP status or automatic retries. +Unwrap exposes only that safe tree. +Avoid custom Is/As machinery where ordinary wrapping suffices. +Use errors.AsType[*edge.InvocationError] on Go 1.27 for details. +Do not add a generic Result[T], a second numeric error-code registry, or error methods that prescribe HTTP status or automatic retries. -Constructor/configuration errors remain ordinary startup errors, outside -InvocationError. Existing ActionHeader sentinels stay separate because header -selection is an application concern. Failure to call a constructor is an -ErrInvalidInvocation when someone directly invokes an unusable adapter. +Constructor/configuration errors remain ordinary startup errors, outside InvocationError. +Existing ActionHeader sentinels stay separate because header selection is an application concern. +Failure to call a constructor is an ErrInvalidInvocation when someone directly invokes an unusable adapter. -**Tradeoff:** eight broad categories commit less implementation detail than the -current private error list while distinguishing the operational decisions a -caller can reasonably make. Stable limit metadata avoids three more budget -sentinels and distinguishes an adjustable header budget from a payload ceiling. +**Tradeoff:** eight broad categories commit less implementation detail than the current private error list while distinguishing the operational decisions a caller can reasonably make. +Stable limit metadata avoids three more budget sentinels and distinguishes an adjustable header budget from a payload ceiling. ## E3. Preserve useful standard errors; sanitize the entire chain -**Recommend:** preserve errors.Is for context.Canceled, context.DeadlineExceeded, -http.ErrNotSupported, and http.ErrContentLength where they are the actual cause. -Explicitly unsupported HTTP features also remain in ErrResponse when they are -invocation-fatal. A handler probing an absent ResponseController capability and -handling ErrNotSupported does not, by itself, poison the invocation. +**Recommend:** preserve errors.Is for context.Canceled, context.DeadlineExceeded, http.ErrNotSupported, and http.ErrContentLength where they are the actual cause. +Explicitly unsupported HTTP features also remain in ErrResponse when they are invocation-fatal. +A handler probing an absent ResponseController capability and handling ErrNotSupported does not, by itself, poison the invocation. -Keep http.ErrBodyNotAllowed on disallowed Write calls as accepted; it is not an -invocation failure if the response otherwise completes. Keep io.EOF as successful -stream termination. Private pipe teardown errors must not replace the real -cancellation or producer failure. +Keep http.ErrBodyNotAllowed on disallowed Write calls as accepted; +it is not an invocation failure if the response otherwise completes. +Keep io.EOF as successful stream termination. +Private pipe teardown errors must not replace the real cancellation or producer failure. -Do not wrap raw JSON/base64/parser errors, custom mapper errors, OS cleanup paths, -or recovered panic payloads into public invocation errors. Formatting an unsafe -cause with %v still leaks its text; losing Unwrap alone is not sanitization. -Construct safe diagnostics at the originating boundary, not by parsing existing -private error strings at the public entry point. +Do not wrap raw JSON/base64/parser errors, custom mapper errors, OS cleanup paths, or recovered panic payloads into public invocation errors. +Formatting an unsafe cause with %v still leaks its text; +losing Unwrap alone is not sanitization. +Construct safe diagnostics at the originating boundary, not by parsing existing private error strings at the public entry point. -Do not expose arbitrary context.Cause(parent) automatically: it can contain -application secrets or private error types. Preserve the standard parent.Err() -classification instead. Callers still control their original context. +Do not expose arbitrary context.Cause(parent) automatically: it can contain application secrets or private error types. +Preserve the standard parent.Err() classification instead. +Callers still control their original context. -The sanitized-error promise does not apply to an application panic deliberately -re-propagated to the SDK under the already accepted early-panic contract. Edge -does not promise to sanitize SDK/application logging or recover arbitrary -application panics into ordinary invocation errors. +The sanitized-error promise does not apply to an application panic deliberately re-propagated to the SDK under the already accepted early-panic contract. +Edge does not promise to sanitize SDK/application logging or recover arbitrary application panics into ordinary invocation errors. ## E4. Primary failure, cleanup, and cancellation @@ -147,77 +132,67 @@ application panics into ordinary invocation errors. 3. Use parent.Err() if no operation/cleanup failure exists. 4. Normal internal child cancellation after successful work is not an error. -Keep joined faults individually inspectable. errors.AsType sees the primary -InvocationError first; errors.Is can detect secondary ErrCleanup. Error text and -join formatting are diagnostic, not a stability promise. If there is a primary -failure plus concurrent parent cancellation, do not replace that failure with -the cancellation; callers can inspect their context separately. +Keep joined faults individually inspectable. +errors.AsType sees the primary InvocationError first; +errors.Is can detect secondary ErrCleanup. +Error text and join formatting are diagnostic, not a stability promise. +If there is a primary failure plus concurrent parent cancellation, do not replace that failure with the cancellation; +callers can inspect their context separately. -Any nonnil buffered invocation error produces nil raw bytes or a zero typed -response. No caller can use a partially encoded result as success. Cleanup -failure alone still fails the invocation, as already implemented and accepted. +Any nonnil buffered invocation error produces nil raw bytes or a zero typed response. +No caller can use a partially encoded result as success. +Cleanup failure alone still fails the invocation, as already implemented and accepted. -**Alternative:** logging cleanup faults and returning success hides resource -leaks in a reused Lambda process. Always joining cancellation would blur whether -it was the cause or merely concurrent shutdown. +**Alternative:** logging cleanup faults and returning success hides resource leaks in a reused Lambda process. +Always joining cancellation would blur whether it was the cause or merely concurrent shutdown. ## E5. The same vocabulary across raw, typed, and streaming calls -**Recommend:** classify where shared logic detects failure. Raw decoding and -final envelope encoding add their own operations; typed calls retain shared -semantic classifications without a JSON round trip. Never promise a typed call -detects duplicate keys, lost numbers, or the final size of an envelope encoded -later by the SDK. SDK-originated failures outside edge cannot be InvocationError. +**Recommend:** classify where shared logic detects failure. +Raw decoding and final envelope encoding add their own operations; +typed calls retain shared semantic classifications without a JSON round trip. +Never promise a typed call detects duplicate keys, lost numbers, or the final size of an envelope encoded later by the SDK. +SDK-originated failures outside edge cannot be InvocationError. -Before a streaming handoff, return nil reader and the classified error. After -handoff, expose the classified terminal error through the reader and the already -approved reporter; status/body cannot be replaced. Preserve the distinction -between an ordinary returned error and a late stream failure in documentation -and metrics. Do not claim AWS treats them as the same invocation-error metric. +Before a streaming handoff, return nil reader and the classified error. +After handoff, expose the classified terminal error through the reader and the already approved reporter; +status/body cannot be replaced. +Preserve the distinction between an ordinary returned error and a late stream failure in documentation and metrics. +Do not claim AWS treats them as the same invocation-error metric. -Recovered late producer panic matches ErrStream. A reporter panic adds another -sanitized ErrStream diagnostic without replacing the original error, exposing -the panic value, or recursively calling the reporter. Early producer panics -still re-panic on the entry goroutine after cleanup. A future streaming writer -must use this same classification at its response and limit fault sites. +Recovered late producer panic matches ErrStream. +A reporter panic adds another sanitized ErrStream diagnostic without replacing the original error, exposing the panic value, or recursively calling the reporter. +Early producer panics still re-panic on the entry goroutine after cleanup. +A future streaming writer must use this same classification at its response and limit fault sites. No new buffering, SDK patch, or custom runtime is needed for this error contract. -The documented Runtime API header discrepancy remains a separate streaming -release gate from decision 0009. +The documented Runtime API header discrepancy remains a separate streaming release gate from decision 0009. ## Implementation and acceptance - Introduce public categories/type and classify existing private failure sites. Keep writer-local standard errors compatible with their accepted contracts. -- Use safe internal constructors for diagnostics; do not accept arbitrary - untrusted cause strings as diagnostic fields. -- Test errors.Is/AsType after outer wrapping and joined cleanup faults, sanitized - error trees, correct result discard, cancellation precedence, and limits. -- Test malformed event versus malformed application body, 401/403/500 responses, - invalid gateway assertions, default-off authorizer handling, and SDK registration. +- Use safe internal constructors for diagnostics; + do not accept arbitrary untrusted cause strings as diagnostic fields. +- Test errors.Is/AsType after outer wrapping and joined cleanup faults, sanitized error trees, correct result discard, cancellation precedence, and limits. +- Test malformed event versus malformed application body, 401/403/500 responses, invalid gateway assertions, default-off authorizer handling, and SDK registration. - Include raw/typed parity only where the input retains the required information. -- Extend existing stream tests for classified early/late/reporter errors when - wiring streaming; no need to finish streaming before buffered invocation. +- Extend existing stream tests for classified early/late/reporter errors when wiring streaming; + no need to finish streaming before buffered invocation. ## Resolution The user agreed with all recommendations as a starting point on 2026-09-15. This accepts E1-E5, including the public categories and diagnostic methods. -Classification of the existing private transport stages is the first independent -implementation step. Public invocation still depends on the identity work. - -Implemented: eight public categories and InvocationError with Error, Unwrap, -Operation, and Limit. Existing decoder, semantic validation, request conversion, -response snapshots/projection, buffered writer, bounded encoding, cleanup, and -bridge-owned streaming failures now supply classified errors. Private low-level -charging/buffering errors remain allocation-free and receive diagnostics at the -stage that knows the original resource maximum. JSON encoder error trees are -discarded even when their safe limit cause is recognized. - -Validation covers wrapping and joined cleanup inspection, complete exposed error -trees, declared-length errors, original versus remaining header budgets, body -and envelope limits, stream metadata limits, private cancellation causes, late -producer/reporter panic categories, and successful HTTP 401/403/500/503 responses. -Go 1.27.1 tests with race detection, vet, formatting checks, and Linux arm64/amd64 -builds pass. Public identity errors and raw/typed invocation registration tests -remain dependent on the identity producers; they are not claimed as implemented. +Classification of the existing private transport stages is the first independent implementation step. +Public invocation still depends on the identity work. + +Implemented: eight public categories and InvocationError with Error, Unwrap, Operation, and Limit. +Existing decoder, semantic validation, request conversion, response snapshots/projection, buffered writer, bounded encoding, cleanup, and bridge-owned streaming failures now supply classified errors. +Private low-level charging/buffering errors remain allocation-free and receive diagnostics at the stage that knows the original resource maximum. +JSON encoder error trees are discarded even when their safe limit cause is recognized. + +Validation covers wrapping and joined cleanup inspection, complete exposed error trees, declared-length errors, original versus remaining header budgets, body and envelope limits, stream metadata limits, private cancellation causes, late producer/reporter panic categories, and successful HTTP 401/403/500/503 responses. +Go 1.27.1 tests with race detection, vet, formatting checks, and Linux arm64/amd64 builds pass. +Public identity errors and raw/typed invocation registration tests remain dependent on the identity producers; +they are not claimed as implemented. diff --git a/docs/decisions/0014-claims-api.md b/docs/decisions/0014-claims-api.md index bfc3fd3..5a14e7e 100644 --- a/docs/decisions/0014-claims-api.md +++ b/docs/decisions/0014-claims-api.md @@ -1,23 +1,22 @@ # 0014: Owned claims, numeric fidelity, and resource limits -Status: accepted on 2026-09-16; claims constructors/accessors and error foundation -implemented. JWT normalization and dedicated-scope accounting were implemented -with caller construction on 2026-09-16. This completes the claims design follow-up -left open by 0012. +Status: accepted on 2026-09-16; +claims constructors/accessors and error foundation implemented. +JWT normalization and dedicated-scope accounting were implemented with caller construction on 2026-09-16. +This completes the claims design follow-up left open by 0012. ## Evidence and scope -The raw decoder keeps authorizer JSON; SDK V1 events contain map[string]any and -SDK V2 JWT claims contain map[string]string. Typed entry points must preserve -their available data without an envelope JSON round trip. The identity package -must remain independent of AWS. Existing invocation errors report byte limits; +The raw decoder keeps authorizer JSON; +SDK V1 events contain map[string]any and SDK V2 JWT claims contain map[string]string. +Typed entry points must preserve their available data without an envelope JSON round trip. +The identity package must remain independent of AWS. +Existing invocation errors report byte limits; their Limit method cannot accurately report a container-depth limit. -We reviewed the Go rules, Go 1.27 release notes, installed package documentation, -SDK v1.55.0 behavior, and AWS documentation through public AWS MCP. The isolated -[probe](../../internal/claimprobe/probe_test.go) and -[measurements](../benchmarks.md#candidate-claims-storage-2026-09-16) inform resource -policy. The probe is deliberately not a production claims implementation. +We reviewed the Go rules, Go 1.27 release notes, installed package documentation, SDK v1.55.0 behavior, and AWS documentation through public AWS MCP. +The isolated [probe](../../internal/claimprobe/probe_test.go) and [measurements](../benchmarks.md#candidate-claims-storage-2026-09-16) inform resource policy. +The probe is deliberately not a production claims implementation. Sources: @@ -55,207 +54,194 @@ func (c Claim) NumberText() (string, bool) func (c Claim) Float64() (float64, bool) ``` -Claims and Claim have private fields. Zero Claims is an empty object; zero Claim -is invalid, distinct from a present JSON null. ClaimKind distinguishes invalid, -null, boolean, string, number, array and object. Representation distinguishes -unknown, received JSON, supplied decoded values, and gateway text. It describes -the immediate representation, not the original token or verification strength; +Claims and Claim have private fields. +Zero Claims is an empty object; +zero Claim is invalid, distinct from a present JSON null. +ClaimKind distinguishes invalid, null, boolean, string, number, array and object. +Representation distinguishes unknown, received JSON, supplied decoded values, and gateway text. +It describes the immediate representation, not the original token or verification strength; Caller.Source continues to describe the identity producer. -Lookup reports presence; the subsequent checked accessor reports whether that -read is possible. Thus missing, null, and wrong type remain distinguishable -without allocating errors for routine inspection. Names returns sorted names; -Array returns a new slice of immutable views. Object shares immutable backing -data. No getter returns a writable internal map or slice. - -NumberText succeeds for exact numeric representations, including native integer -inputs, and fails for supplied floating-point inputs even if integral. It never -parses strings. Float64 is an explicitly approximate conversion; it returns -false for nonnumbers or overflow/underflow reported by the standard conversion. -Ordinary representable rounding is permitted. Exact consumers use NumberText and -their chosen numeric parser. Text is deliberately not named String: String() has -an established diagnostic convention in Go. - -Reasoning: this supports custom claims without turning identity into a generic -JSON binding library. Reject generic getters, automatic coercion, arbitrary -Decode-into-target hooks, and a mutable map export. A convenience Strings method -can follow demonstrated consumer need; normalized JWT collections already have -their own comma-ok accessors under 0012. +Lookup reports presence; +the subsequent checked accessor reports whether that read is possible. +Thus missing, null, and wrong type remain distinguishable without allocating errors for routine inspection. +Names returns sorted names; +Array returns a new slice of immutable views. +Object shares immutable backing data. +No getter returns a writable internal map or slice. + +NumberText succeeds for exact numeric representations, including native integer inputs, and fails for supplied floating-point inputs even if integral. +It never parses strings. +Float64 is an explicitly approximate conversion; +it returns false for nonnumbers or overflow/underflow reported by the standard conversion. +Ordinary representable rounding is permitted. +Exact consumers use NumberText and their chosen numeric parser. +Text is deliberately not named String: String() has an established diagnostic convention in Go. + +Reasoning: this supports custom claims without turning identity into a generic JSON binding library. +Reject generic getters, automatic coercion, arbitrary Decode-into-target hooks, and a mutable map export. +A convenience Strings method can follow demonstrated consumer need; +normalized JWT collections already have their own comma-ok accessors under 0012. ## C2. Accept SDK number preservation without using its codec -The actual SDK handler probe sends the number 9007199254740993 through a typed -V1 event. Default SDK decoding supplies float64 9007199254740992; +The actual SDK handler probe sends the number 9007199254740993 through a typed V1 event. +Default SDK decoding supplies float64 9007199254740992; lambda.WithUseNumber(true) supplies json.Number containing the original digits. No downstream adapter can recover the lost digit in the default case. -Recommend accepting encoding/json.Number as an explicitly recognized input -DATA TYPE. This requires a narrow encoding/json import, exclusively to identify -the type and convert its underlying string. Validate that string as exactly one -JSON number using jsontext; never call the v1 encoder, decoder, or compatibility -functions. This preserves the user's direct-v2 processing requirement while -supporting the AWS SDK's existing number-preserving option. - -NewClaims accepts only nil, bool, string, built-in signed/unsigned integer types -(excluding uintptr), finite float32/float64, json.Number, map[string]any, -map[string]string, []any and []string, recursively. Custom defined types, -pointers, structs, []byte and custom marshalers are rejected. Type aliases work -as their underlying identical Go type. No arbitrary String or Marshal method is -invoked. Nested string maps passed to NewClaims remain decoded values; +Recommend accepting encoding/json.Number as an explicitly recognized input DATA TYPE. +This requires a narrow encoding/json import, exclusively to identify the type and convert its underlying string. +Validate that string as exactly one JSON number using jsontext; +never call the v1 encoder, decoder, or compatibility functions. +This preserves the user's direct-v2 processing requirement while supporting the AWS SDK's existing number-preserving option. + +NewClaims accepts only nil, bool, string, built-in signed/unsigned integer types (excluding uintptr), finite float32/float64, json.Number, map[string]any, map[string]string, []any and []string, recursively. +Custom defined types, pointers, structs, []byte and custom marshalers are rejected. +Type aliases work as their underlying identical Go type. +No arbitrary String or Marshal method is invoked. +Nested string maps passed to NewClaims remain decoded values; NewTextClaims specifically labels gateway text. -Alternative: reject json.Number and make callers recursively translate SDK -values. That adds work at the exact typed boundary we promised to simplify. -Accepting arbitrary fmt.Stringer values would invoke user code and mistake text -for numbers. Neither is recommended. +Alternative: reject json.Number and make callers recursively translate SDK values. +That adds work at the exact typed boundary we promised to simplify. +Accepting arbitrary fmt.Stringer values would invoke user code and mistake text for numbers. +Neither is recommended. -Raw JSON preserves numeric token text, semantic JSON types, and decoded string -contents. It does not preserve whitespace, object order, or escape spelling. -This representation must never be substituted for JWT signing bytes. ParseClaims -accepts exactly one object and rejects duplicates, invalid UTF-8, null roots and -trailing values. Typed nil root maps mean empty objects; nested typed nil maps -and slices retain their declared object/array shape as empty collections. A nil -interface is null. This is a declared typed-input contract, not reconstruction -of the upstream JSON null that might have produced a nil container. +Raw JSON preserves numeric token text, semantic JSON types, and decoded string contents. +It does not preserve whitespace, object order, or escape spelling. +This representation must never be substituted for JWT signing bytes. +ParseClaims accepts exactly one object and rejects duplicates, invalid UTF-8, null roots and trailing values. +Typed nil root maps mean empty objects; +nested typed nil maps and slices retain their declared object/array shape as empty collections. +A nil interface is null. +This is a declared typed-input contract, not reconstruction of the upstream JSON null that might have produced a nil container. ## C3. Own inputs and bound expansion before copying -Recommend a 256 KiB default weighted budget, configurable to a positive value -no greater than 6 MiB. There is no unlimited setting. This is an engineering -policy, not an AWS JWT-size quota. The upper setting is an explicit application -opt-in and is not a claim that this much authentication data is ordinary. - -Charge 64 bytes for every value occurrence, including root, containers and null, -plus UTF-8 bytes of object names and string values, plus retained exact numeric -text. Native integers charge their decimal representation. Floating-point -storage is covered by the node charge. Count repeated subtrees each time they -will be copied. Unknown claims count equally. Do not refund duplicate values or -claim data omitted from normalized views. - -ParseClaims independently limits input byte length to the same configured -maximum before constructing a decoder, then applies the weighted tree budget. -Whitespace and escapes can therefore exhaust its wire allowance even when the -equivalent typed input fits. Typed input has no available original wire length; +Recommend a 256 KiB default weighted budget, configurable to a positive value no greater than 6 MiB. +There is no unlimited setting. +This is an engineering policy, not an AWS JWT-size quota. +The upper setting is an explicit application opt-in and is not a claim that this much authentication data is ordinary. + +Charge 64 bytes for every value occurrence, including root, containers and null, plus UTF-8 bytes of object names and string values, plus retained exact numeric text. +Native integers charge their decimal representation. +Floating-point storage is covered by the node charge. +Count repeated subtrees each time they will be copied. +Unknown claims count equally. +Do not refund duplicate values or claim data omitted from normalized views. + +ParseClaims independently limits input byte length to the same configured maximum before constructing a decoder, then applies the weighted tree budget. +Whitespace and escapes can therefore exhaust its wire allowance even when the equivalent typed input fits. +Typed input has no available original wire length; we do not manufacture one by marshaling it. -Limit nesting to 64 containers, counting the root as one. This also bounds cyclic -typed input without an allocation-heavy graph traversal. Use subtractive checked -accounting and container-length lower bounds before traversing or allocating. -Preflight typed data before copying, clone strings as well as containers so a -small substring cannot retain an unrelated large backing buffer, and never call -user methods. Inputs must not change concurrently during construction. After -construction, callers may mutate their originals and share claims concurrently. - -The candidate's ordinary fixture retained about 2.6 KiB. At a 256 KiB charge, -distinct names retained about 0.72 MiB and many singleton objects about 1.58 MiB. -Thus the charge is not a heap cap. JSON lexical validation, normalized JWT views, -source event storage and the rest of an invocation cost additional memory. +Limit nesting to 64 containers, counting the root as one. +This also bounds cyclic typed input without an allocation-heavy graph traversal. +Use subtractive checked accounting and container-length lower bounds before traversing or allocating. +Preflight typed data before copying, clone strings as well as containers so a small substring cannot retain an unrelated large backing buffer, and never call user methods. +Inputs must not change concurrently during construction. +After construction, callers may mutate their originals and share claims concurrently. + +The candidate's ordinary fixture retained about 2.6 KiB. +At a 256 KiB charge, distinct names retained about 0.72 MiB and many singleton objects about 1.58 MiB. +Thus the charge is not a heap cap. +JSON lexical validation, normalized JWT views, source event storage and the rest of an invocation cost additional memory. Decoder duplicate detection also allocates before the entire tree is built. Keep the public accounting stable while permitting private storage improvements; repeat allocation measurements against production code before public wiring. -Dedicated gateway scopes must not bypass this budget. Propose -edge.WithIdentityClaimsBudget(bytes int) for gateway construction, and carry the -configured budget and consumed charge privately in Claims. NewJWT's explicitly -supplied scope collection consumes the remaining allowance as an array plus its -strings, even when it agrees with a scope claim. Derived normalized views reuse -immutable strings where possible; their allocation is bounded by the input but -is not part of the advertised heap measurement. Supplying duplicate JWT options -still fails under 0012. Claims resource options are configuration, with last -assignment winning like edge options; nil or invalid options fail construction. +Dedicated gateway scopes must not bypass this budget. +Propose edge.WithIdentityClaimsBudget(bytes int) for gateway construction, and carry the configured budget and consumed charge privately in Claims. +NewJWT's explicitly supplied scope collection consumes the remaining allowance as an array plus its strings, even when it agrees with a scope claim. +Derived normalized views reuse immutable strings where possible; +their allocation is bounded by the input but is not part of the advertised heap measurement. +Supplying duplicate JWT options still fails under 0012. +Claims resource options are configuration, with last assignment winning like edge options; +nil or invalid options fail construction. -Alternative: a raw-byte-only limit misses large typed graphs; an entry-count-only -limit misses large values. A universal heap-byte promise is not supported by the -measurements. Arbitrarily deep or unlimited copying is not recommended. +Alternative: a raw-byte-only limit misses large typed graphs; +an entry-count-only limit misses large values. +A universal heap-byte promise is not supported by the measurements. +Arbitrarily deep or unlimited copying is not recommended. ## C4. Preserve safe, correctly dimensioned errors -Recommend identity.ErrInvalidClaims for malformed JSON, unsupported typed data, -invalid UTF-8, nonfinite numbers and excessive nesting. Recommend -identity.ErrClaimsLimit for wire/weighted byte exhaustion, with an opaque -*identity.ClaimsLimitError exposing Maximum() int64 and unwrapping only to that -sentinel. Construction errors contain no claim names, values, JSON fragments or -wrapped codec errors. Invalid configuration remains a construction error. - -At the invocation boundary, claim failure becomes operation identity with -edge.ErrIdentity. Byte exhaustion additionally matches edge.ErrLimitExceeded -and reports Limit() = ("identity_claims", configuredMaximum, true). Depth failure -has no byte-limit metadata: reporting “64 bytes” would be incorrect. Keep detailed -identity errors within identity and translate deliberately at the edge boundary; +Recommend identity.ErrInvalidClaims for malformed JSON, unsupported typed data, invalid UTF-8, nonfinite numbers and excessive nesting. +Recommend identity.ErrClaimsLimit for wire/weighted byte exhaustion, with an opaque *identity.ClaimsLimitError exposing Maximum() int64 and unwrapping only to that sentinel. +Construction errors contain no claim names, values, JSON fragments or wrapped codec errors. +Invalid configuration remains a construction error. + +At the invocation boundary, claim failure becomes operation identity with edge.ErrIdentity. +Byte exhaustion additionally matches edge.ErrLimitExceeded and reports Limit() = ("identity_claims", configuredMaximum, true). +Depth failure has no byte-limit metadata: reporting “64 bytes” would be incorrect. +Keep detailed identity errors within identity and translate deliberately at the edge boundary; do not introduce an AWS or edge dependency into identity. ## C5. Preserve the gateway qualifications while normalizing JWT facts -Received JSON fidelity does not establish original JWT fidelity. In particular, -a gateway-origin string aud or cognito:groups remains available through Text, -but a normalized collection remains unavailable when the source may have -flattened it. Actual arrays can be interpreted according to the claim schema. -This can cause a group/audience-dependent application rule to deny a legitimate -gateway-authenticated request. Recommend accepting that consequence rather than -inventing an undocumented delimiter or trusting an unverified bearer payload. +Received JSON fidelity does not establish original JWT fidelity. +In particular, a gateway-origin string aud or cognito:groups remains available through Text, but a normalized collection remains unavailable when the source may have flattened it. +Actual arrays can be interpreted according to the claim schema. +This can cause a group/audience-dependent application rule to deny a legitimate gateway-authenticated request. +Recommend accepting that consequence rather than inventing an undocumented delimiter or trusting an unverified bearer payload. Local verification or an explicitly reviewed source schema is the remedy. -Interpret canonical scope strings using OAuth's ASCII-space token grammar, -without Unicode-whitespace splitting or case changes. Preserve the original -claim and deduplicate normalized sets deterministically. A present dedicated -gateway scopes array is separately interpretable; an empty array means known -empty, while absent/null means unavailable. Typed SDK nil loses any distinction -between absent and null; a nonnil empty slice preserves known empty. +Interpret canonical scope strings using OAuth's ASCII-space token grammar, without Unicode-whitespace splitting or case changes. +Preserve the original claim and deduplicate normalized sets deterministically. +A present dedicated gateway scopes array is separately interpretable; +an empty array means known empty, while absent/null means unavailable. +Typed SDK nil loses any distinction between absent and null; +a nonnil empty slice preserves known empty. -AWS documents support for scope or scp in gateway authorization but does not give -a universal reversible flattened scp encoding. Recommend recognizing an actual -string array for scp; retain ambiguous gateway text without manufacturing a list. +AWS documents support for scope or scp in gateway authorization but does not give a universal reversible flattened scp encoding. +Recommend recognizing an actual string array for scp; +retain ambiguous gateway text without manufacturing a list. Any broader issuer-specific scp string convention needs an explicit schema. -Wrong actual types fail under 0012; ambiguity caused by flattening is unavailable. +Wrong actual types fail under 0012; +ambiguity caused by flattening is unavailable. -Retain the accepted rule: prefer dedicated scopes, compare independently -interpretable scope sets, reject disagreement, never union. This is OUR strict -consistency policy, not an AWS-documented guarantee that all authorizer variants -produce equivalent collections. The checked docs establish route validation and -show a scopes array; they do not settle every projection/filtering detail. -Record this as a compatibility qualification in native-producer fixtures. Do not -broaden comparisons to opaque scp text or claim universal gateway coverage. +Retain the accepted rule: prefer dedicated scopes, compare independently interpretable scope sets, reject disagreement, never union. +This is OUR strict consistency policy, not an AWS-documented guarantee that all authorizer variants produce equivalent collections. +The checked docs establish route validation and show a scopes array; +they do not settle every projection/filtering detail. +Record this as a compatibility qualification in native-producer fixtures. +Do not broaden comparisons to opaque scp text or claim universal gateway coverage. ## Review and implementation order -Recommend approval of C1–C4, and confirmation of C5's explicit compatibility -tradeoffs. After approval: +Recommend approval of C1–C4, and confirmation of C5's explicit compatibility tradeoffs. +After approval: -1. Implement the AWS-independent claims/caller/context foundation, with ownership, - numerical-fidelity, budget, cycle, type, and sanitized-error contract tests. +1. Implement the AWS-independent claims/caller/context foundation, with ownership, numerical-fidelity, budget, cycle, type, and sanitized-error contract tests. 2. Specify the native-producer fixture matrix against documented event shapes; expose unresolved IAM/V1 producer-recognition details before implementing them. 3. Implement reviewed gateway producers and wire the raw/typed buffered adapter. -4. Follow with local verification/authz and streaming HTTP writer work in the - accepted order. Do not pick a JWT library or issuer profile in this claims step. +4. Follow with local verification/authz and streaming HTTP writer work in the accepted order. + Do not pick a JWT library or issuer profile in this claims step. -At proposal time no production APIs or behavior had changed. Implementation -status is recorded below; no live AWS resources were invoked. +At proposal time no production APIs or behavior had changed. +Implementation status is recorded below; +no live AWS resources were invoked. ## Resolution -The user approved C1–C5 on 2026-09-16 and authorized implementation, including -the narrow json.Number data-type compatibility and gateway qualifications. +The user approved C1–C5 on 2026-09-16 and authorized implementation, including the narrow json.Number data-type compatibility and gateway qualifications. The separate native-producer fixture review was later completed in decision 0018. ## Implementation status -Implemented in identity: all three claims constructors, checked immutable views, -exact numeric text and explicit approximate conversion, the closed input-type -set including json.Number, strict JSON processing through jsontext, typed/raw -preflight, byte/depth limits, sanitized errors and diagnostic formatting. -Inputs and returned containers have contract tests for ownership; raw numeric -and typed SDK number-preserving paths are tested through the actual SDK wrapper. - -The root adapter records and validates WithIdentityClaimsBudget. A private error -translation boundary classifies claim failures and reports identity_claims byte -limits without misreporting depth as bytes. Both are now wired to raw and typed -invocation through the native gateway producers under accepted decision 0018. - -Production allocation measurements are recorded in docs/benchmarks.md. Race tests, -vet, JSON fuzzing and both Lambda-target builds validate the claims foundation. -Caller construction, dedicated scope accounting and JWT normalization are now -implemented following approval of 0015/0017. WithGatewayScopes owns its supplied -collection, charges the remaining claims allowance and rejects disagreement. -Raw gateway string collections remain unavailable, even when the containing -envelope preserved JSON syntax. Race tests cover the completed foundation. +Implemented in identity: all three claims constructors, checked immutable views, exact numeric text and explicit approximate conversion, the closed input-type set including json.Number, strict JSON processing through jsontext, typed/raw preflight, byte/depth limits, sanitized errors and diagnostic formatting. +Inputs and returned containers have contract tests for ownership; +raw numeric and typed SDK number-preserving paths are tested through the actual SDK wrapper. + +The root adapter records and validates WithIdentityClaimsBudget. +A private error translation boundary classifies claim failures and reports identity_claims byte limits without misreporting depth as bytes. +Both are now wired to raw and typed invocation through the native gateway producers under accepted decision 0018. + +Production allocation measurements are recorded in docs/benchmarks.md. +Race tests, vet, JSON fuzzing and both Lambda-target builds validate the claims foundation. +Caller construction, dedicated scope accounting and JWT normalization are now implemented following approval of 0015/0017. +WithGatewayScopes owns its supplied collection, charges the remaining claims allowance and rejects disagreement. +Raw gateway string collections remain unavailable, even when the containing envelope preserved JSON syntax. +Race tests cover the completed foundation. diff --git a/docs/decisions/0015-iam-caller-forms.md b/docs/decisions/0015-iam-caller-forms.md index 4c1c0d7..43405ad 100644 --- a/docs/decisions/0015-iam-caller-forms.md +++ b/docs/decisions/0015-iam-caller-forms.md @@ -1,25 +1,22 @@ # 0015: Initial IAM caller forms -Status: accepted on 2026-09-16, including the four initial caller forms, with -approval of decision 0017. Constructor/view implementation is complete. +Status: accepted on 2026-09-16, including the four initial caller forms, with approval of decision 0017. +Constructor/view implementation is complete. -Decision 0012 requires validation of an actual caller principal form and explicitly -defers the supported-form fixtures. Implementing NewIAM now reaches that gate. +Decision 0012 requires validation of an actual caller principal form and explicitly defers the supported-form fixtures. +Implementing NewIAM now reaches that gate. Claims implementation under accepted decision 0014 proceeds independently. ## Evidence -The [AWS Principal documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html), -read through public AWS MCP, distinguishes IAM users, account/root principals, -roles, assumed-role sessions, and STS federated-user sessions. It explains that -using assumed-role credentials makes the requester a role session principal. +The [AWS Principal documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html), read through public AWS MCP, distinguishes IAM users, account/root principals, roles, assumed-role sessions, and STS federated-user sessions. +It explains that using assumed-role credentials makes the requester a role session principal. Policy principal syntax also includes service and identity-provider principals; these are not all interchangeable with an authenticated requester's ARN. -The [AWS principal-key table](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_variables.html) -distinguishes root, user, federated user and assumed-role requesters and preserves -session identifiers. Existing edge code has no IAM producer to dictate a broader -compatibility requirement. Identity must remain independent of AWS libraries. +The [AWS principal-key table](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_variables.html) distinguishes root, user, federated user and assumed-role requesters and preserves session identifiers. +Existing edge code has no IAM producer to dictate a broader compatibility requirement. +Identity must remain independent of AWS libraries. ## Recommendation @@ -30,96 +27,81 @@ Initially accept these exact forms in NewIAM: - STS assumed-role session: arn::sts:::assumed-role//. - STS federated-user session: arn::sts:::federated-user/. -Preserve spelling, partition, account and full resource including session. Reject -bare account IDs, IAM role ARNs, wildcard expressions outside literal user paths and policy-only principal forms in this -initial request-caller constructor. Do not transform a role session into a role. -Recognizing root is a representation capability, not permission to perform an -application action. Root policy-principal delegation semantics are not applied. +Preserve spelling, partition, account and full resource including session. +Reject bare account IDs, IAM role ARNs, wildcard expressions outside literal user paths and policy-only principal forms in this initial request-caller constructor. +Do not transform a role session into a role. +Recognizing root is a representation capability, not permission to perform an application action. +Root policy-principal delegation semantics are not applied. -Use read-only IAM accessors PrincipalARN, Partition, AccountID, Service and -PrincipalType, with optional PrincipalID. WithIAMAccountID cross-checks an -independently supplied caller account; WithIAMPrincipalID preserves the optional -opaque identifier without parsing it into an identity. Duplicate assignments -fail under 0012. Never use API-owner account metadata as caller account. +Use read-only IAM accessors PrincipalARN, Partition, AccountID, Service and PrincipalType, with optional PrincipalID. +WithIAMAccountID cross-checks an independently supplied caller account; +WithIAMPrincipalID preserves the optional opaque identifier without parsing it into an identity. +Duplicate assignments fail under 0012. +Never use API-owner account metadata as caller account. -ARN lexical details and field limits must follow the relevant official API -definitions when implemented; this proposal does not invent normalization or -claim a universal API Gateway producer matrix. +ARN lexical details and field limits must follow the relevant official API definitions when implemented; +this proposal does not invent normalization or claim a universal API Gateway producer matrix. ## Alternative and consequence -Accepting every resource-policy Principal value would represent policy subjects -that are not the request caller the constructor describes. Accepting a bare role -ARN could also hide upstream loss of session identity. Recommend adding another -form only when a documented producer needs it, with an explicit fixture. +Accepting every resource-policy Principal value would represent policy subjects that are not the request caller the constructor describes. +Accepting a bare role ARN could also hide upstream loss of session identity. +Recommend adding another form only when a documented producer needs it, with an explicit fixture. -This review gates the IAM constructor and complete caller union; it does not gate -the approved claims API, resource limits, or numerical fidelity implementation. +This review gates the IAM constructor and complete caller union; +it does not gate the approved claims API, resource limits, or numerical fidelity implementation. ## Clarification: validation versus authentication -The user asked what IAM validation can actually establish and whether authn can -authenticate an IAM token. Constructor validation means structural validity and -internal consistency only: ARN components, a supported principal resource form, -required names/session components, and agreement with an independently supplied -caller account. It cannot establish existence, possession of credentials, session -validity, permissions, or that the supplied event came from API Gateway. An ARN -and a self-declared source are identifiers/annotations, not authentication proofs. - -The [IAM identifiers reference](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html) -documents ARN components, blank regions for these IAM/STS forms, and exact -session representations. The initial supported-form restriction is our caller -model policy, not a test proving a principal is real. - -[Gateway IAM authorization](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-access-control-iam.html) -requires signed requests and checks execute-api permission before invoking a -route. Recommend that path for IAM clients of edge's API Gateway integration: -AWS authenticates the request; edge validates and owns the resulting assertion. -This uses built-in AWS_IAM authorization, not a Lambda/JWT authorizer. Trust still -depends on the intended integration and permissions controlling Lambda invocation. +The user asked what IAM validation can actually establish and whether authn can authenticate an IAM token. +Constructor validation means structural validity and internal consistency only: ARN components, a supported principal resource form, required names/session components, and agreement with an independently supplied caller account. +It cannot establish existence, possession of credentials, session validity, permissions, or that the supplied event came from API Gateway. +An ARN and a self-declared source are identifiers/annotations, not authentication proofs. + +The [IAM identifiers reference](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html) documents ARN components, blank regions for these IAM/STS forms, and exact session representations. +The initial supported-form restriction is our caller model policy, not a test proving a principal is real. + +[Gateway IAM authorization](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-access-control-iam.html) requires signed requests and checks execute-api permission before invoking a route. +Recommend that path for IAM clients of edge's API Gateway integration: AWS authenticates the request; +edge validates and owns the resulting assertion. +This uses built-in AWS_IAM authorization, not a Lambda/JWT authorizer. +Trust still depends on the intended integration and permissions controlling Lambda invocation. An STS SessionToken alone is not a standalone bearer authentication protocol. -[Temporary credentials](https://aws.amazon.com/blogs/security/understanding-the-api-options-for-securely-delegating-access-to-your-aws-account/) -include an access key, secret key and session token; the keys sign requests and -the session token accompanies them. Calling -[GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) -with the Lambda's credentials identifies the Lambda's role, not the HTTP caller. - -IAM-backed bearer authentication is possible with a specifically designed proof -protocol. [EKS documents](https://docs.aws.amazon.com/eks/latest/best-practices/identity-and-access-management.html) -a token containing a presigned STS request that its authenticator submits to AWS -for verification. A future edge authn component could use this general approach, -but would need its own explicit protocol, application binding, freshness/replay -policy, restricted STS endpoints and dependency-error handling. An arbitrary -execute-api signature is not interchangeable with an STS-signed proof. - -Recommend keeping that optional online authentication producer separate from -NewIAM and the gateway assertion adapter. It would also require a reviewed source -designation; the current source contract must not mislabel AWS verification as -locally verified JWT authentication. No such producer is approved or implemented. +[Temporary credentials](https://aws.amazon.com/blogs/security/understanding-the-api-options-for-securely-delegating-access-to-your-aws-account/) include an access key, secret key and session token; +the keys sign requests and the session token accompanies them. +Calling [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) with the Lambda's credentials identifies the Lambda's role, not the HTTP caller. + +IAM-backed bearer authentication is possible with a specifically designed proof protocol. +[EKS documents](https://docs.aws.amazon.com/eks/latest/best-practices/identity-and-access-management.html) a token containing a presigned STS request that its authenticator submits to AWS for verification. +A future edge authn component could use this general approach, but would need its own explicit protocol, application binding, freshness/replay policy, restricted STS endpoints and dependency-error handling. +An arbitrary execute-api signature is not interchangeable with an STS-signed proof. + +Recommend keeping that optional online authentication producer separate from NewIAM and the gateway assertion adapter. +It would also require a reviewed source designation; +the current source contract must not mislabel AWS verification as locally verified JWT authentication. +No such producer is approved or implemented. The user's subsequent same-route IAM/OAuth clarification is recorded in 0016. -For that goal, the proposed path is explicit mixed-credential middleware with an -online IAM proof verifier. Built-in AWS_IAM alone does not satisfy the goal. +For that goal, the proposed path is explicit mixed-credential middleware with an online IAM proof verifier. +Built-in AWS_IAM alone does not satisfy the goal. ## Accepted lexical refinement and implementation -On 2026-09-16 the user approved preserving literal `*` and `?` in valid IAM user -paths. [CreateUser](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateUser.html) -permits these characters in its path grammar. Caller identifiers are exact facts, -never policy patterns. Wildcard account IDs, usernames, role names and session -names remain invalid. Follow the formal path pattern's U+0021..U+007E range; +On 2026-09-16 the user approved preserving literal `*` and `?` in valid IAM user paths. +[CreateUser](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateUser.html) permits these characters in its path grammar. +Caller identifiers are exact facts, never policy patterns. +Wildcard account IDs, usernames, role names and session names remain invalid. +Follow the formal path pattern's U+0021..U+007E range; the page's prose mentions DEL but its formal pattern excludes it. -The constructor follows documented name/path lengths: IAM username 1..64 and -path 1..512; role name 1..64; session name 2..64 under -[AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html); -federated username 2..32 under -[GetFederationToken](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetFederationToken.html). +The constructor follows documented name/path lengths: IAM username 1..64 and path 1..512; +role name 1..64; +session name 2..64 under [AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html); +federated username 2..32 under [GetFederationToken](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetFederationToken.html). The account is exactly twelve ASCII digits and the ARN's region is empty. Partition validation is lexical, not an assertion that an AWS partition exists; the authenticating producer must enforce its configured partition. -NewIAM and its read-only view are implemented with account consistency, -duplicate-option rejection, exact session preservation and sanitized diagnostics. +NewIAM and its read-only view are implemented with account consistency, duplicate-option rejection, exact session preservation and sanitized diagnostics. Online verification is not implemented by this constructor. diff --git a/docs/decisions/0016-mixed-iam-oauth-authentication.md b/docs/decisions/0016-mixed-iam-oauth-authentication.md index 88f4856..3d55f6a 100644 --- a/docs/decisions/0016-mixed-iam-oauth-authentication.md +++ b/docs/decisions/0016-mixed-iam-oauth-authentication.md @@ -3,114 +3,91 @@ Status: architecture and exported Go client helper accepted on 2026-09-16. The detailed IAM protocol/API was subsequently approved and implemented under 0017. The shared HTTP selector was accepted and implemented under 0019. -First-party JWT verification and authorization remain separate work. + First-party JWT verification and authorization remain separate work. ## Requirement and correction -The original goal includes accepting IAM credentials or an OAuth bearer token -when calling the same API. Design for the same route and application handler, -without requiring built-in AWS_IAM authorization. The recommendation in 0015 to -prefer AWS_IAM for IAM clients alone does not meet that combined requirement. -Gateway IAM remains an optional deployment path, not a prerequisite or the sole -planned IAM authentication path. +The original goal includes accepting IAM credentials or an OAuth bearer token when calling the same API. +Design for the same route and application handler, without requiring built-in AWS_IAM authorization. +The recommendation in 0015 to prefer AWS_IAM for IAM clients alone does not meet that combined requirement. +Gateway IAM remains an optional deployment path, not a prerequisite or the sole planned IAM authentication path. ## Evidence -- [API Gateway route authorization](https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-apigatewayv2-2018-11-29.html) - selects one authorization type, including NONE, JWT, AWS_IAM or CUSTOM. It is - not a built-in per-request choice between IAM and JWT authorization. -- [SigV4 signing](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html) - derives an HMAC key from the secret access key, date, region and service. Edge - does not possess a caller's IAM secret. An execute-api signature cannot be - transplanted onto an STS request for verification. -- [EKS authentication](https://docs.aws.amazon.com/eks/latest/best-practices/identity-and-access-management.html) - demonstrates tokens containing a presigned STS request submitted to AWS for - verification, followed by separate application authorization. -- [AWS IAM Authenticator](https://github.com/kubernetes-sigs/aws-iam-authenticator#how-does-it-work) - documents client presigning, server validation and submission to STS, and a - signed destination identifier preventing reuse across different clusters. +- [API Gateway route authorization](https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-apigatewayv2-2018-11-29.html) selects one authorization type, including NONE, JWT, AWS_IAM or CUSTOM. + It is not a built-in per-request choice between IAM and JWT authorization. +- [SigV4 signing](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html) derives an HMAC key from the secret access key, date, region and service. + Edge does not possess a caller's IAM secret. + An execute-api signature cannot be transplanted onto an STS request for verification. +- [EKS authentication](https://docs.aws.amazon.com/eks/latest/best-practices/identity-and-access-management.html) demonstrates tokens containing a presigned STS request submitted to AWS for verification, followed by separate application authorization. +- [AWS IAM Authenticator](https://github.com/kubernetes-sigs/aws-iam-authenticator#how-does-it-work) documents client presigning, server validation and submission to STS, and a signed destination identifier preventing reuse across different clusters. This establishes a protocol pattern, not a dependency or wire-format choice. -- [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) - identifies the credentials signing that request and requires no granted - permission for the operation. It does not establish application permissions. +- [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) identifies the credentials signing that request and requires no granted permission for the operation. + It does not establish application permissions. -AWS definitions were checked through public AWS MCP; the authenticator's own -documentation supplies implementation-pattern evidence. At proposal time there -was no authn middleware. The source rules in 0012 permit gateway/custom IAM and locally verified -JWT; online IAM verification requires a provenance refinement before implementation. +AWS definitions were checked through public AWS MCP; +the authenticator's own documentation supplies implementation-pattern evidence. +At proposal time there was no authn middleware. +The source rules in 0012 permit gateway/custom IAM and locally verified JWT; +online IAM verification requires a provenance refinement before implementation. ## Accepted direction -Configure a route without built-in gateway authentication and protect the handler -with explicit authn middleware. Select one configured verifier by an unambiguous -credential format, never by retrying another verifier after authentication fails. +Configure a route without built-in gateway authentication and protect the handler with explicit authn middleware. +Select one configured verifier by an unambiguous credential format, never by retrying another verifier after authentication fails. -OAuth bearer input goes to its configured token verifier. The planned Cognito -path validates JWT access tokens; opaque OAuth tokens need a separately configured -issuer-specific mechanism. IAM clients use their existing AWS credential provider -to sign an STS GetCallerIdentity proof. An exported Go helper included in this -module generates the proof; this is a required deliverable, not an optional -consumer-owned implementation. The secret key stays with the client. +OAuth bearer input goes to its configured token verifier. +The planned Cognito path validates JWT access tokens; +opaque OAuth tokens need a separately configured issuer-specific mechanism. +IAM clients use their existing AWS credential provider to sign an STS GetCallerIdentity proof. +An exported Go helper included in this module generates the proof; +this is a required deliverable, not an optional consumer-owned implementation. +The secret key stays with the client. -The IAM verifier validates the proof contract and submits only an approved STS -request to AWS, using the client's signature rather than the Lambda's credentials. +The IAM verifier validates the proof contract and submits only an approved STS request to AWS, using the client's signature rather than the Lambda's credentials. Identity comes from the verified AWS response, not an ARN asserted by the client. -Either successful verifier constructs one immutable caller; common application -authorization decides whether it may execute the selected action. Successful IAM -authentication alone must not admit every AWS account to the application. +Either successful verifier constructs one immutable caller; +common application authorization decides whether it may execute the selected action. +Successful IAM authentication alone must not admit every AWS account to the application. -This is one configured authentication selector choosing one producer per request, -compatible with the no-identity-merging direction in 0012. Conflicting credentials -must fail rather than select an identity by precedence. +This is one configured authentication selector choosing one producer per request, compatible with the no-identity-merging direction in 0012. +Conflicting credentials must fail rather than select an identity by precedence. -The proof requires signed application/environment binding, bounded freshness, an -explicit replay policy, constrained endpoint/action/method validation and bounded -network work. Dependency failures must remain distinct from invalid credentials. -Wire format, source enum, caching and detailed verification rules remain proposed -work for a concrete protocol review. +The proof requires signed application/environment binding, bounded freshness, an explicit replay policy, constrained endpoint/action/method validation and bounded network work. +Dependency failures must remain distinct from invalid credentials. +Wire format, source enum, caching and detailed verification rules remain proposed work for a concrete protocol review. ## Tradeoffs and alternatives -The IAM path adds an online STS dependency and a client protocol/helper. It is not -drop-in compatibility with generic execute-api SigV4 clients. With middleware -placement, unauthenticated traffic reaches the integration Lambda. +The IAM path adds an online STS dependency and a client protocol/helper. +It is not drop-in compatibility with generic execute-api SigV4 clients. +With middleware placement, unauthenticated traffic reaches the integration Lambda. -A custom Lambda authorizer could host the same verifier selection before the -integration. It changes deployment/caching/assertion transport but does not remove -the proof requirement. Separate IAM/JWT routes could use native authorization and -a common backend, but would not meet the stricter same-route goal. +A custom Lambda authorizer could host the same verifier selection before the integration. +It changes deployment/caching/assertion transport but does not remove the proof requirement. +Separate IAM/JWT routes could use native authorization and a common backend, but would not meet the stricter same-route goal. ## Resolution and deliverables -The user confirmed that the client token is a signed GetCallerIdentity proof -which the server submits to STS to obtain verified identity. The user requires -documentation and a Go helper in the module's exported API. The client-helper -compatibility question is resolved; unchanged execute-api SigV4 clients are not -the contract of this authentication path. +The user confirmed that the client token is a signed GetCallerIdentity proof which the server submits to STS to obtain verified identity. +The user requires documentation and a Go helper in the module's exported API. +The client-helper compatibility question is resolved; +unchanged execute-api SigV4 clients are not the contract of this authentication path. Deliver: -- An exported Go client helper that generates the accepted proof using the - caller's AWS credentials, including temporary credentials where available. -- An authn verifier that validates the proof and forwards the client-signed STS - request without signing it again using server credentials. Construct identity - only from the successfully verified response. -- Explicit selection between IAM proof and configured OAuth bearer verification - on the same route, producing one caller for common application authorization. -- Consumer documentation and runnable client/server examples covering credential - acquisition, matching application binding, token transport and lifetime, - temporary credential handling, and authentication versus authorization. -- Contract tests for client/verifier interoperability, destination binding, - freshness, malformed/conflicting credentials, endpoint restrictions and STS - failure handling. Use local tests; no live AWS invocation is needed by default. - -The next design review will settle helper package placement and signatures, -credential-provider dependencies, versioned wire format and HTTP transport, -application binding, expiry/replay policy, endpoint/region policy, provenance and -error behavior. Acceptance of the architecture does not freeze these details. - -[Decision 0017](0017-iam-proof-protocol.md) subsequently settled those details -and its production helper/verifier are implemented with local validation. -[Decision 0019](0019-http-authentication-selector.md) settled the remaining -shared HTTP selector API and response contract, now implemented with local -validation and synthetic Bearer fixtures. No live STS check has run. +- An exported Go client helper that generates the accepted proof using the caller's AWS credentials, including temporary credentials where available. +- An authn verifier that validates the proof and forwards the client-signed STS request without signing it again using server credentials. + Construct identity only from the successfully verified response. +- Explicit selection between IAM proof and configured OAuth bearer verification on the same route, producing one caller for common application authorization. +- Consumer documentation and runnable client/server examples covering credential acquisition, matching application binding, token transport and lifetime, temporary credential handling, and authentication versus authorization. +- Contract tests for client/verifier interoperability, destination binding, freshness, malformed/conflicting credentials, endpoint restrictions and STS failure handling. + Use local tests; + no live AWS invocation is needed by default. + +The next design review will settle helper package placement and signatures, credential-provider dependencies, versioned wire format and HTTP transport, application binding, expiry/replay policy, endpoint/region policy, provenance and error behavior. +Acceptance of the architecture does not freeze these details. + +[Decision 0017](0017-iam-proof-protocol.md) subsequently settled those details and its production helper/verifier are implemented with local validation. +[Decision 0019](0019-http-authentication-selector.md) settled the remaining shared HTTP selector API and response contract, now implemented with local validation and synthetic Bearer fixtures. +No live STS check has run. diff --git a/docs/decisions/0017-iam-proof-protocol.md b/docs/decisions/0017-iam-proof-protocol.md index 10579a2..ba7d69e 100644 --- a/docs/decisions/0017-iam-proof-protocol.md +++ b/docs/decisions/0017-iam-proof-protocol.md @@ -1,63 +1,54 @@ # 0017: IAM proof protocol and Go helper -Status: accepted on 2026-09-16. The user approved P1-P5 and the four caller forms -in decision 0015. The production iamproof generator and verifier are implemented -after the shared caller and buffered-adapter foundations, with local validation. +Status: accepted on 2026-09-16. +The user approved P1-P5 and the four caller forms in decision 0015. +The production iamproof generator and verifier are implemented after the shared caller and buffered-adapter foundations, with local validation. Common HTTP authentication selection is implemented under decision 0019; live AWS interoperability remains pending. ## Evidence -AWS documentation was checked through public AWS MCP. SDK public API definitions -were checked with the Go documentation skill at the versions below. At initial -proposal time, identity implemented claims; caller construction, native gateway -production and the IAM proof producer have since been implemented. - -- [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) - returns Account, Arn and UserId for the signing credentials, without requiring - a granted permission. Its documented response is XML. It does not grant - permission to execute an application action. -- [Regional STS endpoints](https://docs.aws.amazon.com/sdkref/latest/guide/feature-sts-regionalized-endpoints.html) - are AWS's recommendation. Use an explicit region, not implicit global routing. -- [SDK v4 presigning](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2@v1.47.0/aws/signer/v4#Signer.PresignHTTP) - returns the signed URL and required signed headers. It does not automatically - set expiration; its documentation warns that not every service uses - X-Amz-Expires. Do not infer a hard STS lifetime from that parameter. -- [AWS IAM Authenticator](https://github.com/kubernetes-sigs/aws-iam-authenticator/blob/master/pkg/token/token.go) - provides implementation evidence for a signed destination header and explicit - date checks. Its code notes that STS ignores X-Amz-Expires. That is reference - implementation evidence, not an AWS-documented service guarantee. -- [HTTP API quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html) - cap combined request-line and header-value size at 10,240 bytes. Proof transport - must share that space with the action header and all other request metadata. -- [STS credentials](https://docs.aws.amazon.com/STS/latest/APIReference/API_Credentials.html) - do not have a fixed documented session-token size. A library transport limit - must not be described as AWS's maximum credential size. -- [STS quotas](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html) - document a default shared 600 requests/second per account per Region for - credential-based STS requests, including GetCallerIdentity. -- [STS common errors](https://docs.aws.amazon.com/STS/latest/APIReference/CommonErrors.html) - include expired credentials/requests and unrecognized clients, but also a - throttling error with HTTP status 400. HTTP status alone cannot distinguish - invalid credentials from dependency failure. -- [Go 1.27](https://go.dev/doc/go1.27) and the existing module contract support - using encoding/json/v2 directly. XML responses use encoding/xml; no JSON v1 - shim is needed for the proposed protocol. - -Latest stable modules resolved on 2026-09-16: AWS SDK core v1.47.0, STS v1.51.0, -and config v1.33.5. Lambda Go remains v1.55.0. The [standalone probe](../probes/iamproof/README.md) -uses the first two. They were rechecked as latest stable when added to production -go.mod on 2026-09-16. Explicit module versions provide repeatable builds, not a -permanent freeze on an old SDK. +AWS documentation was checked through public AWS MCP. +SDK public API definitions were checked with the Go documentation skill at the versions below. +At initial proposal time, identity implemented claims; +caller construction, native gateway production and the IAM proof producer have since been implemented. + +- [GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) returns Account, Arn and UserId for the signing credentials, without requiring a granted permission. + Its documented response is XML. + It does not grant permission to execute an application action. +- [Regional STS endpoints](https://docs.aws.amazon.com/sdkref/latest/guide/feature-sts-regionalized-endpoints.html) are AWS's recommendation. + Use an explicit region, not implicit global routing. +- [SDK v4 presigning](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2@v1.47.0/aws/signer/v4#Signer.PresignHTTP) returns the signed URL and required signed headers. + It does not automatically set expiration; + its documentation warns that not every service uses X-Amz-Expires. + Do not infer a hard STS lifetime from that parameter. +- [AWS IAM Authenticator](https://github.com/kubernetes-sigs/aws-iam-authenticator/blob/master/pkg/token/token.go) provides implementation evidence for a signed destination header and explicit date checks. + Its code notes that STS ignores X-Amz-Expires. + That is reference implementation evidence, not an AWS-documented service guarantee. +- [HTTP API quotas](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-quotas.html) cap combined request-line and header-value size at 10,240 bytes. + Proof transport must share that space with the action header and all other request metadata. +- [STS credentials](https://docs.aws.amazon.com/STS/latest/APIReference/API_Credentials.html) do not have a fixed documented session-token size. + A library transport limit must not be described as AWS's maximum credential size. +- [STS quotas](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html) document a default shared 600 requests/second per account per Region for credential-based STS requests, including GetCallerIdentity. +- [STS common errors](https://docs.aws.amazon.com/STS/latest/APIReference/CommonErrors.html) include expired credentials/requests and unrecognized clients, but also a throttling error with HTTP status 400. + HTTP status alone cannot distinguish invalid credentials from dependency failure. +- [Go 1.27](https://go.dev/doc/go1.27) and the existing module contract support using encoding/json/v2 directly. + XML responses use encoding/xml; + no JSON v1 shim is needed for the proposed protocol. + +Latest stable modules resolved on 2026-09-16: AWS SDK core v1.47.0, STS v1.51.0, and config v1.33.5. +Lambda Go remains v1.55.0. +The [standalone probe](../probes/iamproof/README.md) uses the first two. +They were rechecked as latest stable when added to production go.mod on 2026-09-16. +Explicit module versions provide repeatable builds, not a permanent freeze on an old SDK. ## P1. Public package and credential ownership -Recommend one focused `iamproof` companion package for the exported client helper -and server verifier. Keep Caller in AWS-free `identity`; keep HTTP credential -selection in `authn`. This concrete client use justifies an additional package -under the plan's package-boundary rule. Avoid importing IAM SDK implementation -packages from shared selector code: accept a consumer-defined verifier function -there, so a JWT-only application does not link the IAM implementation. +Recommend one focused `iamproof` companion package for the exported client helper and server verifier. +Keep Caller in AWS-free `identity`; +keep HTTP credential selection in `authn`. +This concrete client use justifies an additional package under the plan's package-boundary rule. +Avoid importing IAM SDK implementation packages from shared selector code: accept a consumer-defined verifier function there, so a JWT-only application does not link the IAM implementation. Proposed surface, with opaque concrete Generator, Verifier and Token types: @@ -72,37 +63,42 @@ func WithTimeout(timeout time.Duration) VerifierOption func (v *Verifier) Verify(ctx context.Context, token string) (identity.Caller, error) ``` -Value returns the versioned credential; the HTTP client sends `EdgeIAM ` followed -by that value. ExpiresAt is the earlier of proof expiry and credential expiry -when known, an upper bound rather than a guarantee of future AWS acceptance. -Token's ordinary string/debug formatting is redacted; extracting Value is -explicit. The zero Token is unusable and exposes empty value/zero expiry. - -Use the SDK's CredentialsProvider directly. Applications load/cache/refresh -credentials using their existing SDK configuration; the helper does not invent -another provider hierarchy or call AssumeRole on their behalf. Generate retrieves -credentials each time, so the provider can refresh. The provider may perform I/O; -local presigning itself makes no STS request. Require complete, unexpired -credentials; preserve temporary session tokens. Ignore provider AccountID as -authentication evidence. Configuration and objects are immutable after creation; +Value returns the versioned credential; +the HTTP client sends `EdgeIAM ` followed by that value. +ExpiresAt is the earlier of proof expiry and credential expiry when known, an upper bound rather than a guarantee of future AWS acceptance. +Token's ordinary string/debug formatting is redacted; +extracting Value is explicit. +The zero Token is unusable and exposes empty value/zero expiry. + +Use the SDK's CredentialsProvider directly. +Applications load/cache/refresh credentials using their existing SDK configuration; +the helper does not invent another provider hierarchy or call AssumeRole on their behalf. +Generate retrieves credentials each time, so the provider can refresh. +The provider may perform I/O; +local presigning itself makes no STS request. +Require complete, unexpired credentials; +preserve temporary session tokens. +Ignore provider AccountID as authentication evidence. +Configuration and objects are immutable after creation; concurrent use requires a concurrency-safe provider/transport. -Use SDK v4.Signer for signing and STS's public endpoint resolver for endpoint -metadata. STS PresignGetCallerIdentity is an alternative, but inserting the -application header and fixing signing time/expiry requires middleware hooks. +Use SDK v4.Signer for signing and STS's public endpoint resolver for endpoint metadata. +STS PresignGetCallerIdentity is an alternative, but inserting the application header and fixing signing time/expiry requires middleware hooks. The small, fixed Query request makes the lower-level official signer clearer. -Never implement SigV4 cryptography ourselves. Verifier accepts no credentials -provider and never signs the forwarded request with the Lambda role. +Never implement SigV4 cryptography ourselves. +Verifier accepts no credentials provider and never signs the forwarded request with the Lambda role. -Alternative: put everything in authn or reexport it from edge. That couples -client generation to HTTP middleware or broadens the adapter's root API. +Alternative: put everything in authn or reexport it from edge. +That couples client generation to HTTP middleware or broadens the adapter's root API. ## P2. Wire format and one credential selection -Recommend `Authorization: EdgeIAM v1.`. Bearer continues -to select the configured OAuth verifier. Scheme matching is case insensitive; -the version and encoded token are strict. Repeated, combined, empty, unknown or -malformed credentials fail. Never try another verifier after a rejection. +Recommend `Authorization: EdgeIAM v1.`. +Bearer continues to select the configured OAuth verifier. +Scheme matching is case insensitive; +the version and encoded token are strict. +Repeated, combined, empty, unknown or malformed credentials fail. +Never try another verifier after a rejection. Do not accept a second proof in a cookie, query parameter or alternate header. The JSON object has exactly these fields: @@ -112,65 +108,67 @@ The JSON object has exactly these fields: ``` The signature placeholder above illustrates a field, not a valid test vector. -Require all fields except sessionToken, reject nulls/unknown/duplicate fields, -invalid UTF-8 and trailing data, and require canonical unpadded base64url. -Absent sessionToken means long-lived credentials; a present empty token fails. -Use JSON v2 directly. JSON member order does not affect the AWS signature. - -All remaining request components are fixed: HTTPS GET, regional STS host, `/`, -no body, Action=GetCallerIdentity, Version=2011-06-15, -X-Amz-Algorithm=AWS4-HMAC-SHA256, X-Amz-Expires=60, and -X-Amz-SignedHeaders=host;x-edge-iam-audience. The verifier constructs this request -from trusted configuration plus the validated variable fields. It accepts no -client-supplied URL, method, action, endpoint override or arbitrary headers. - -Credential scope must match the date, configured region, service sts and terminal -aws4_request. Validate syntax without claiming that an access key exists. Only -STS can authenticate it. Reject inputs before I/O where possible. - -Alternative: base64url of a complete presigned URL, as used by established -authenticator protocols. It is easier to inspect but admits more parser states -and expands escaped session tokens. The local probe reconstructs the SDK's signed -request across twelve cases and confirms the audience changes the signature. -A synthetic 4 KiB escape-heavy session token produces a 5,767-byte compact proof -versus 14,175 bytes for an encoded URL. This is a stress measurement, not a claim -about typical AWS tokens. The new wire format is our protocol, not EKS-compatible. +Require all fields except sessionToken, reject nulls/unknown/duplicate fields, invalid UTF-8 and trailing data, and require canonical unpadded base64url. +Absent sessionToken means long-lived credentials; +a present empty token fails. +Use JSON v2 directly. +JSON member order does not affect the AWS signature. + +All remaining request components are fixed: HTTPS GET, regional STS host, `/`, no body, Action=GetCallerIdentity, Version=2011-06-15, X-Amz-Algorithm=AWS4-HMAC-SHA256, X-Amz-Expires=60, and X-Amz-SignedHeaders=host;x-edge-iam-audience. +The verifier constructs this request from trusted configuration plus the validated variable fields. +It accepts no client-supplied URL, method, action, endpoint override or arbitrary headers. + +Credential scope must match the date, configured region, service sts and terminal aws4_request. +Validate syntax without claiming that an access key exists. +Only STS can authenticate it. +Reject inputs before I/O where possible. + +Alternative: base64url of a complete presigned URL, as used by established authenticator protocols. +It is easier to inspect but admits more parser states and expands escaped session tokens. +The local probe reconstructs the SDK's signed request across twelve cases and confirms the audience changes the signature. +A synthetic 4 KiB escape-heavy session token produces a 5,767-byte compact proof versus 14,175 bytes for an encoded URL. +This is a stress measurement, not a claim about typical AWS tokens. +The new wire format is our protocol, not EKS-compatible. ## P3. Destination binding, lifetime and replay Require an explicit, matching application/environment audience at both ends. Use 1-256 visible ASCII bytes without spaces, case sensitive and unnormalized; -deployments should use unique values such as orders.production. Compare the -envelope audience to server configuration before I/O; send the configured value -in X-Edge-IAM-Audience. Its inclusion in SignedHeaders makes changing it invalidate -the signature. A matching plaintext audience by itself proves nothing. - -Recommend a fixed 60-second proof lifetime in protocol v1, with at most 30 seconds -of future clock tolerance and no expired-token grace. Sign whole UTC seconds; -server requires issuedAt <= now+30s and now < issuedAt+60s. Recheck after STS returns -before creating a caller. Future tolerance means the maximum possible local -acceptance interval is 90 seconds, not a promised 60 seconds of wall time under -skew. STS may reject more strictly. Credential expiration may shorten validity. - -This is a bearer authentication proof: it can be reused for the same audience -while fresh. It does not bind the API method, action header or request body and -is not a single-use challenge. Require HTTPS to the application and STS. Client -code may reuse a generated proof until its expiry; every server verification -still calls STS. Do not log proof values, session tokens or signed URLs. - -Alternative: request-body binding or a nonce/challenge store. Either changes the -client flow and adds a separate protocol/state requirement. Recommend the short, -audience-bound bearer contract first, with this limitation explicit rather than -claiming general replay prevention. An STS success is not application authorization. +deployments should use unique values such as orders.production. +Compare the envelope audience to server configuration before I/O; +send the configured value in X-Edge-IAM-Audience. +Its inclusion in SignedHeaders makes changing it invalidate the signature. +A matching plaintext audience by itself proves nothing. + +Recommend a fixed 60-second proof lifetime in protocol v1, with at most 30 seconds of future clock tolerance and no expired-token grace. +Sign whole UTC seconds; +server requires issuedAt <= now+30s and now < issuedAt+60s. +Recheck after STS returns before creating a caller. +Future tolerance means the maximum possible local acceptance interval is 90 seconds, not a promised 60 seconds of wall time under skew. +STS may reject more strictly. +Credential expiration may shorten validity. + +This is a bearer authentication proof: it can be reused for the same audience while fresh. +It does not bind the API method, action header or request body and is not a single-use challenge. +Require HTTPS to the application and STS. +Client code may reuse a generated proof until its expiry; +every server verification still calls STS. +Do not log proof values, session tokens or signed URLs. + +Alternative: request-body binding or a nonce/challenge store. +Either changes the client flow and adds a separate protocol/state requirement. +Recommend the short, audience-bound bearer contract first, with this limitation explicit rather than claiming general replay prevention. +An STS success is not application authorization. ## P4. Endpoints, limits and online verification -Require one explicit regional endpoint per generator/verifier, resolved with -the stable STS SDK metadata. Validate configured region syntax; resolution is not -a promise that an account can use that region. Initial support uses standard -regional HTTPS endpoints; no global, FIPS, dual-stack or arbitrary URL switches. -Do not silently fall back to global STS or another region/partition. The probe -checks commercial, GovCloud and China resolution, not live AWS availability. +Require one explicit regional endpoint per generator/verifier, resolved with the stable STS SDK metadata. +Validate configured region syntax; +resolution is not a promise that an account can use that region. +Initial support uses standard regional HTTPS endpoints; +no global, FIPS, dual-stack or arbitrary URL switches. +Do not silently fall back to global STS or another region/partition. +The probe checks commercial, GovCloud and China resolution, not live AWS availability. Proposed engineering limits, not claimed AWS limits: @@ -181,51 +179,51 @@ Proposed engineering limits, not claimed AWS limits: | Verification network deadline | 5 seconds by default; positive override | Bound STS work; caller's earlier context deadline still wins | | Automatic retries / verification cache | None | Predictable per-request work and fresh AWS verification | -Check the encoded limit before allocation and on generation. Return an explicit -size error from the helper; never truncate session credentials. The 8 KiB policy -does not guarantee that a request with other headers fits its gateway, nor that -every valid STS session fits this transport. The probe's 6 KiB synthetic session -already exceeds it. Endpoint policies/proxies may impose additional URL limits. - -Use an owned HTTP client with redirects disabled, no cookie jar, explicit context -deadline and bounded reads. Close every response body. WithTransport supplies -trusted deployment infrastructure, including custom TLS/proxy settings; it is -not an input taken from a request. A transport that ignores cancellation or -performs retries internally is outside guarantees edge can enforce. - -Read the documented XML response using encoding/xml. Require one expected -response/result, exactly one nonempty Account/Arn/UserId, expected namespaces, -no duplicate identity fields and no second document. Bounded nonidentity metadata -can be ignored for forward compatibility. Cross-check account and partition -against the returned ARN and configured endpoint partition. Reject contradictory -or unsupported identities. Do not assume that setting Accept to JSON is a -documented GetCallerIdentity response contract. +Check the encoded limit before allocation and on generation. +Return an explicit size error from the helper; +never truncate session credentials. +The 8 KiB policy does not guarantee that a request with other headers fits its gateway, nor that every valid STS session fits this transport. +The probe's 6 KiB synthetic session already exceeds it. +Endpoint policies/proxies may impose additional URL limits. + +Use an owned HTTP client with redirects disabled, no cookie jar, explicit context deadline and bounded reads. +Close every response body. +WithTransport supplies trusted deployment infrastructure, including custom TLS/proxy settings; +it is not an input taken from a request. +A transport that ignores cancellation or performs retries internally is outside guarantees edge can enforce. + +Read the documented XML response using encoding/xml. +Require one expected response/result, exactly one nonempty Account/Arn/UserId, expected namespaces, no duplicate identity fields and no second document. +Bounded nonidentity metadata can be ignored for forward compatibility. +Cross-check account and partition against the returned ARN and configured endpoint partition. +Reject contradictory or unsupported identities. +Do not assume that setting Accept to JSON is a documented GetCallerIdentity response contract. No server-side cache initially means an STS round trip for every IAM request. -The shared 600 RPS default makes this unsuitable for unbounded throughput without -capacity planning. Application throttling/admission control remains necessary. -Caching would extend acceptance after credential invalidation and requires its -own TTL, key, eviction and overload policy; review it separately if needed. +The shared 600 RPS default makes this unsuitable for unbounded throughput without capacity planning. +Application throttling/admission control remains necessary. +Caching would extend acceptance after credential invalidation and requires its own TTL, key, eviction and overload policy; +review it separately if needed. ## P5. Caller provenance and errors -Recommend accepting the four initial forms from decision 0015: IAM root/user and -STS assumed-role/federated-user sessions. Preserve the full ARN/session and STS -UserId, cross-check Account, and add SourceVerifiedIAMProof for this producer. -Do not convert a session ARN into a role ARN or infer grants from it. Provenance -records what trusted producer code did; manually constructing the same enum does -not authenticate anything. Root representation does not permit root access. - -Verify returns a caller only after successful STS verification, response checks, -and the final freshness check; every failure returns an anonymous zero caller. -It does not install context. The shared authn selector installs one caller and -honors the existing prohibition on replacing/merging an existing caller. - -Recommend inspectable package errors ErrInvalidProof, ErrUnavailable, -ErrCredentialsUnavailable and ErrProofTooLarge. Constructor configuration errors -are programmer/deployment errors; use a separate ErrInvalidConfiguration. -All error messages/chains are sanitized; do not wrap provider errors, raw XML, -HTTP url.Error values or SDK signing diagnostics containing credential material. +Recommend accepting the four initial forms from decision 0015: IAM root/user and STS assumed-role/federated-user sessions. +Preserve the full ARN/session and STS UserId, cross-check Account, and add SourceVerifiedIAMProof for this producer. +Do not convert a session ARN into a role ARN or infer grants from it. +Provenance records what trusted producer code did; +manually constructing the same enum does not authenticate anything. +Root representation does not permit root access. + +Verify returns a caller only after successful STS verification, response checks, and the final freshness check; +every failure returns an anonymous zero caller. +It does not install context. +The shared authn selector installs one caller and honors the existing prohibition on replacing/merging an existing caller. + +Recommend inspectable package errors ErrInvalidProof, ErrUnavailable, ErrCredentialsUnavailable and ErrProofTooLarge. +Constructor configuration errors are programmer/deployment errors; +use a separate ErrInvalidConfiguration. +All error messages/chains are sanitized; +do not wrap provider errors, raw XML, HTTP url.Error values or SDK signing diagnostics containing credential material. Preserve caller cancellation/deadline via errors.Is without exposing unsafe causes. | Condition | Classification and protected HTTP behavior | @@ -237,18 +235,16 @@ Preserve caller cancellation/deadline via errors.Is without exposing unsafe caus | Authenticated caller denied the selected application action | Authorization denial; 403 | | Client provider retrieval/signing failure or expired/incomplete credentials | Helper error; no proof returned | -Do not classify every AWS 4xx as invalid credentials. Start the invalid-credential -allowlist with documented ExpiredTokenException, IncompleteSignature, -MissingAuthenticationToken, RequestExpired and UnrecognizedClientException; -include InvalidClientTokenId as documented for GetCallerIdentity in -[AWS's EKS troubleshooting reference](https://docs.aws.amazon.com/eks/latest/userguide/troubleshooting.html). -Verify any further service-specific aliases against official sources and fixtures -before adding them. Unrecognized errors fail closed as unavailable; throttling -remains unavailable even with HTTP 400. Caller cancellation returns the context error; +Do not classify every AWS 4xx as invalid credentials. +Start the invalid-credential allowlist with documented ExpiredTokenException, IncompleteSignature, MissingAuthenticationToken, RequestExpired and UnrecognizedClientException; +include InvalidClientTokenId as documented for GetCallerIdentity in [AWS's EKS troubleshooting reference](https://docs.aws.amazon.com/eks/latest/userguide/troubleshooting.html). +Verify any further service-specific aliases against official sources and fixtures before adding them. +Unrecognized errors fail closed as unavailable; +throttling remains unavailable even with HTTP 400. +Caller cancellation returns the context error; writing a response after disconnection follows the HTTP middleware lifecycle. -WWW-Authenticate challenges and the shared selector API are specified by -decision 0019, separately from the proof verifier. These are HTTP failures, not Lambda invocation -errors under decision 0013. +WWW-Authenticate challenges and the shared selector API are specified by decision 0019, separately from the proof verifier. +These are HTTP failures, not Lambda invocation errors under decision 0013. ## Design graph and implementation sequence @@ -268,60 +264,45 @@ flowchart LR ``` 1. Review P1-P5, including the still-pending caller forms in 0015. -2. Complete the shared caller/context foundation and native gateway fixtures, - then the callable buffered adapter in the already accepted order. -3. Implement helper and verifier together with round-trip fixtures, current stable - SDK dependencies and an explicit versioned protocol specification. -4. Test malformed/duplicate/oversized inputs, tampering, wrong region/audience, - clock boundaries, credential expiry, endpoint/redirect constraints, XML - contradictions, cancellation, dependency classification and redacted errors. +2. Complete the shared caller/context foundation and native gateway fixtures, then the callable buffered adapter in the already accepted order. +3. Implement helper and verifier together with round-trip fixtures, current stable SDK dependencies and an explicit versioned protocol specification. +4. Test malformed/duplicate/oversized inputs, tampering, wrong region/audience, clock boundaries, credential expiry, endpoint/redirect constraints, XML contradictions, cancellation, dependency classification and redacted errors. Use synthetic credentials, fake transports and local servers, not live AWS. -5. Review/wire the common authn selector and complete IAM/OAuth client/server - examples plus authz examples. Keep action extraction separate from proof - parsing and require authorization for each selected action. +5. Review/wire the common authn selector and complete IAM/OAuth client/server examples plus authz examples. + Keep action extraction separate from proof parsing and require authorization for each selected action. -The original probe passes local SDK reconstruction and audience-change checks -with race detection. The production package now implements the security -validation above. Neither establishes real STS acceptance, deploys API Gateway -or settles HTTP middleware APIs. A separately authorized integration check can -later validate deployed interoperability. +The original probe passes local SDK reconstruction and audience-change checks with race detection. +The production package now implements the security validation above. +Neither establishes real STS acceptance, deploys API Gateway or settles HTTP middleware APIs. +A separately authorized integration check can later validate deployed interoperability. ## Resolution -The user approved this proposal and decision 0015 on 2026-09-16 and authorized -implementation in the recorded order. Complete the shared caller/context and -buffered adapter foundation before the IAM helper/verifier and authn middleware. +The user approved this proposal and decision 0015 on 2026-09-16 and authorized implementation in the recorded order. +Complete the shared caller/context and buffered adapter foundation before the IAM helper/verifier and authn middleware. The isolated probe remains the only implemented IAM-proof artifact at approval. ## Implementation evidence, 2026-09-16 -The production `iamproof` package implements P1-P5 with direct JSON v2, the SDK -signer, bounded XML verification, caller construction, sanitized errors and -runnable local client/configuration examples. The [consumer guide](../iamproof.md) -records operational constraints and the still-pending common HTTP selector. +The production `iamproof` package implements P1-P5 with direct JSON v2, the SDK signer, bounded XML verification, caller construction, sanitized errors and runnable local client/configuration examples. +The [consumer guide](../iamproof.md) records operational constraints and the still-pending common HTTP selector. -The SDK V2 endpoint result does not expose a partition ID. The implementation -uses the public legacy STS resolver for that metadata and requires agreement -with V2 on endpoint and signing region. This preserves the accepted SDK-metadata -boundary without private imports or a copied partition table. SDK disagreement -fails construction. Fixtures cover all eight partitions present in this release; +The SDK V2 endpoint result does not expose a partition ID. +The implementation uses the public legacy STS resolver for that metadata and requires agreement with V2 on endpoint and signing region. +This preserves the accepted SDK-metadata boundary without private imports or a copied partition table. +SDK disagreement fails construction. +Fixtures cover all eight partitions present in this release; they do not assert service availability or account eligibility. -Two additional rejection codes were checked through AWS MCP before inclusion: -[SignatureDoesNotMatch for GetCallerIdentity](https://docs.aws.amazon.com/eks/latest/userguide/security-iam-troubleshoot.html) -and [ExpiredToken for expired temporary credentials](https://repost.aws/knowledge-center/iam-credentials-token). -Synthetic error fixtures exercise both. Other unrecognized errors remain -unavailable; no status-only invalid-credential classification was introduced. - -Local validation covers official-signer request reconstruction, all four caller -forms, opaque session tokens, wrong audience/scope, tampering, canonical encoding, -malformed/duplicate/null JSON, credential expiry, future/expiry boundaries, -expiry during verification, namespaces/duplicate or contradictory XML identity, -bounded reads and body closure, local TLS/redirect/cookie behavior, caller and -internal deadlines, sanitized failures, provider retrieval and concurrent use. -Initial ten-second fuzz runs completed 464,009 proof-parser and 172,753 XML-parser -executions without failures. These are synthetic/local results, not live STS -acceptance evidence. No automatic retry, result cache or HTTP selector was added. -Full module race tests, vet, formatting checks and Linux arm64/amd64 builds pass -on Go 1.27.1. The existing CI workflow includes this package automatically; no -GitHub CI run has been performed for these changes yet. +Two additional rejection codes were checked through AWS MCP before inclusion: [SignatureDoesNotMatch for GetCallerIdentity](https://docs.aws.amazon.com/eks/latest/userguide/security-iam-troubleshoot.html) and [ExpiredToken for expired temporary credentials](https://repost.aws/knowledge-center/iam-credentials-token). +Synthetic error fixtures exercise both. +Other unrecognized errors remain unavailable; +no status-only invalid-credential classification was introduced. + +Local validation covers official-signer request reconstruction, all four caller forms, opaque session tokens, wrong audience/scope, tampering, canonical encoding, malformed/duplicate/null JSON, credential expiry, future/expiry boundaries, expiry during verification, namespaces/duplicate or contradictory XML identity, bounded reads and body closure, local TLS/redirect/cookie behavior, caller and internal deadlines, sanitized failures, provider retrieval and concurrent use. +Initial ten-second fuzz runs completed 464,009 proof-parser and 172,753 XML-parser executions without failures. +These are synthetic/local results, not live STS acceptance evidence. +No automatic retry, result cache or HTTP selector was added. +Full module race tests, vet, formatting checks and Linux arm64/amd64 builds pass on Go 1.27.1. +The existing CI workflow includes this package automatically; +no GitHub CI run has been performed for these changes yet. diff --git a/docs/decisions/0018-gateway-identity-recognition.md b/docs/decisions/0018-gateway-identity-recognition.md index 72b9a5e..5d08176 100644 --- a/docs/decisions/0018-gateway-identity-recognition.md +++ b/docs/decisions/0018-gateway-identity-recognition.md @@ -1,77 +1,75 @@ # 0018: Native gateway identity recognition and fixture coverage -Status: accepted on 2026-09-16. The user approved G1-G4 and authorized native -gateway extraction and buffered invocation wiring. Both are implemented and tested. +Status: accepted on 2026-09-16. +The user approved G1-G4 and authorized native gateway extraction and buffered invocation wiring. +Both are implemented and tested. This is the producer-fixture follow-up explicitly reserved in 0012 and 0014. ## Evidence and existing boundary -Official AWS documentation was checked through public AWS MCP. Current decode.go -retains raw authorizer JSON separately from the SDK structs. V1 typed events have -an authorizer map; V2 typed events have JWT, IAM and Lambda authorizer members. -The root option still defaults gateway identity off. NewIAM/NewJWT now validate -owned facts, and the shared invocation guard rejects an inherited caller. - -- [REST proxy integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) - documents IAM identity fields, Cognito authorizer.claims, and custom-authorizer - principalId/context. Its example includes null claims/scopes placeholders. -- [HTTP proxy payload formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) - documents both payload versions and authorizer.jwt.claims/scopes in 2.0. The - 1.0 example has null placeholders, not a complete authenticated producer matrix. -- [Context variable definitions](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html) - distinguish API-owner accountId from identity.accountId; identity.caller is the - signing principal, identity.user the principal authorized against resources, - and identity.userArn the effective authenticated user's ARN. -- [AWS Lambda Go V2 definitions](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/apigw.go) - and its [IAM fixture](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/testdata/apigw-v2-request-iam.json) - establish the iam member's userArn, accountId, userId and callerId field names. - That fixture uses a ten-digit account ID: it is serialization/schema evidence, - not a valid identity fixture or proof that two principal IDs are equivalent. - -Gateway assertions are trusted because the application opts into its intended -integration and controls Lambda invocation permissions. Event data cannot prove -its own provenance or reveal every deployment setting. This proposal does not -turn source tags, fields or an Authorization header into cryptographic evidence. +Official AWS documentation was checked through public AWS MCP. +Current decode.go retains raw authorizer JSON separately from the SDK structs. +V1 typed events have an authorizer map; +V2 typed events have JWT, IAM and Lambda authorizer members. +The root option still defaults gateway identity off. +NewIAM/NewJWT now validate owned facts, and the shared invocation guard rejects an inherited caller. + +- [REST proxy integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html) documents IAM identity fields, Cognito authorizer.claims, and custom-authorizer principalId/context. + Its example includes null claims/scopes placeholders. +- [HTTP proxy payload formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) documents both payload versions and authorizer.jwt.claims/scopes in 2.0. + The 1.0 example has null placeholders, not a complete authenticated producer matrix. +- [Context variable definitions](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html) distinguish API-owner accountId from identity.accountId; + identity.caller is the signing principal, identity.user the principal authorized against resources, and identity.userArn the effective authenticated user's ARN. +- [AWS Lambda Go V2 definitions](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/apigw.go) and its [IAM fixture](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/testdata/apigw-v2-request-iam.json) establish the iam member's userArn, accountId, userId and callerId field names. + That fixture uses a ten-digit account ID: it is serialization/schema evidence, not a valid identity fixture or proof that two principal IDs are equivalent. + +Gateway assertions are trusted because the application opts into its intended integration and controls Lambda invocation permissions. +Event data cannot prove its own provenance or reveal every deployment setting. +This proposal does not turn source tags, fields or an Authorization header into cryptographic evidence. ## G1. Recognize shapes without guessing the API product -Recommend these mappings; all paths are under requestContext: +Recommend these mappings; +all paths are under requestContext: | Payload shape | IAM caller | JWT caller | | --- | --- | --- | | V1 / 1.0 | identity.userArn; optional identity.accountId and identity.user | authorizer.claims plus optional authorizer.scopes | | V2 / 2.0 | authorizer.iam.userArn; optional iam.accountId and iam.userId | authorizer.jwt.claims plus optional jwt.scopes | -Use the selected shape consistently for both raw and typed inputs. Do not infer -REST versus HTTP API from payload version, invent another HTTP 1.0 claim encoding, -or probe arbitrary nested claim maps until something looks like a caller. +Use the selected shape consistently for both raw and typed inputs. +Do not infer REST versus HTTP API from payload version, invent another HTTP 1.0 claim encoding, or probe arbitrary nested claim maps until something looks like a caller. -For V1 native claims, source documentation directly establishes the REST Cognito -path. For HTTP 1.0, the structure is documented but the authenticated matrix is -not fully established. Recommend implementing the same explicit shape contract -with synthetic fixtures and documenting that qualification. Do not advertise -deployed coverage of every HTTP 1.0 authorizer combination. An unexpected nonempty -authorizer fails as unsupported; it must not become anonymous. +For V1 native claims, source documentation directly establishes the REST Cognito path. +For HTTP 1.0, the structure is documented but the authenticated matrix is not fully established. +Recommend implementing the same explicit shape contract with synthetic fixtures and documenting that qualification. +Do not advertise deployed coverage of every HTTP 1.0 authorizer combination. +An unexpected nonempty authorizer fails as unsupported; +it must not become anonymous. -Alternative: restrict the whole V1 transport to REST, or infer identity from token -headers when the assertion differs. The former discards an accepted transport -surface and the latter changes the trust boundary. Neither is recommended. +Alternative: restrict the whole V1 transport to REST, or infer identity from token headers when the assertion differs. +The former discards an accepted transport surface and the latter changes the trust boundary. +Neither is recommended. ## G2. Effective user fields and partial IAM assertions -Require the exact ARN for IAM construction. Cross-check a nonempty account field -from the identity/iam object; never pass the API-owner accountId as caller account. -Preserve V1 user or V2 userId as the optional opaque principal ID. Do not substitute -caller/callerId or require equality with it: the documented roles differ. +Require the exact ARN for IAM construction. +Cross-check a nonempty account field from the identity/iam object; +never pass the API-owner accountId as caller account. +Preserve V1 user or V2 userId as the optional opaque principal ID. +Do not substitute caller/callerId or require equality with it: the documented roles differ. If user/userId is missing, leave PrincipalID unavailable rather than inventing it. -For V1, nonempty userArn, user or caller signals an IAM candidate. A candidate with -missing/invalid userArn fails with ErrIdentity and identity.ErrInvalidCaller. -Account/access-key/Cognito-pool/network/API-key/mTLS metadata alone does not -establish IAM identity. It remains metadata; authorization sees anonymous. -For V2, a nonnull iam object is a candidate even when empty. Missing ARN fails. -Validate consumed scalar fields as strings or null; reject wrong types without -printing their contents. Raw empty optional strings match the SDK's empty values. +For V1, nonempty userArn, user or caller signals an IAM candidate. +A candidate with missing/invalid userArn fails with ErrIdentity and identity.ErrInvalidCaller. +Account/access-key/Cognito-pool/network/API-key/mTLS metadata alone does not establish IAM identity. +It remains metadata; +authorization sees anonymous. +For V2, a nonnull iam object is a candidate even when empty. +Missing ARN fails. +Validate consumed scalar fields as strings or null; +reject wrong types without printing their contents. +Raw empty optional strings match the SDK's empty values. This is a field-mapping policy, not a claim that a valid ARN authenticates itself. The four accepted NewIAM forms and their literal path semantics apply unchanged. @@ -91,71 +89,63 @@ Recommend the following precise presence policy: | Multiple native/custom candidates | ErrIdentity with identity.ErrConflict | | Other nonempty unrecognized authorizer | ErrIdentity with errors.ErrUnsupported | -The V2 rule clarifies 0012's null-placeholder language: a nonnull tagged producer -object expresses more intent than the documented V1 placeholder. Treat an empty -tagged assertion as broken integration data, rather than silently unauthenticated. -Null V2 members are absent. Unknown sibling metadata alongside a supported native -candidate is ignored for identity, as accepted in 0012; explicit native/custom -producer markers still collide. Inspect all markers before selecting a producer. +The V2 rule clarifies 0012's null-placeholder language: a nonnull tagged producer object expresses more intent than the documented V1 placeholder. +Treat an empty tagged assertion as broken integration data, rather than silently unauthenticated. +Null V2 members are absent. +Unknown sibling metadata alongside a supported native candidate is ignored for identity, as accepted in 0012; +explicit native/custom producer markers still collide. +Inspect all markers before selecting a producer. -Alternative: interpret every empty object as anonymous. That can conceal a -configured producer that failed to supply its required facts. Recommend failing -closed for explicit tagged producers while keeping the documented anonymous -events valid. +Alternative: interpret every empty object as anonymous. +That can conceal a configured producer that failed to supply its required facts. +Recommend failing closed for explicit tagged producers while keeping the documented anonymous events valid. -With gateway identity disabled, none of these fields produces a caller and no -identity interpretation runs. Structural event JSON/type checks still apply as -already specified; opting out does not make malformed event JSON valid. +With gateway identity disabled, none of these fields produces a caller and no identity interpretation runs. +Structural event JSON/type checks still apply as already specified; +opting out does not make malformed event JSON valid. ## G4. Preserve fidelity and make test coverage honest -Raw claims use ParseClaims on the retained claim-object bytes; V1 typed claims use -NewClaims directly; V2 typed claims use NewTextClaims. Do not serialize typed events -to recover unavailable presence or numeric information. Normalize all of them -with SourceGatewayAssertion and the accepted C5 collection qualifications. +Raw claims use ParseClaims on the retained claim-object bytes; +V1 typed claims use NewClaims directly; +V2 typed claims use NewTextClaims. +Do not serialize typed events to recover unavailable presence or numeric information. +Normalize all of them with SourceGatewayAssertion and the accepted C5 collection qualifications. Dedicated scopes use WithGatewayScopes and the configured remaining claims budget. Claim budget exhaustion maps to ErrIdentity/ErrLimitExceeded and identity_claims. -SDK typed decoding may already discard unknown authorizer members and collapse -null/absent scalar fields. Preserve raw/typed equivalence where the SDK retained -the distinction, and explicitly test/document the exceptions. Prefer the raw -entry point when full wire-shape inspection is required. Do not manufacture an -unknown custom producer from a zero SDK description after its fields were lost. +SDK typed decoding may already discard unknown authorizer members and collapse null/absent scalar fields. +Preserve raw/typed equivalence where the SDK retained the distinction, and explicitly test/document the exceptions. +Prefer the raw entry point when full wire-shape inspection is required. +Do not manufacture an unknown custom producer from a zero SDK description after its fields were lost. Prepare independently authored fixtures labeled by evidence level: - Documentation-shaped anonymous V1/HTTP 1.0 placeholders and V2 JWT objects; replace documentation placeholder claims with valid synthetic iss/sub/client_id. -- SDK-shaped V2 IAM with valid twelve-digit caller account, explicit cross-account - API owner, all four principal forms, and distinct userId/callerId values. -- REST Cognito/IAM cases using documented field meanings; label constructed - values synthetic, never as captured live traffic. -- HTTP 1.0 native cases labeled shape-contract tests, with deployed coverage - unverified; include an unexpected-shape rejection case. -- Adversarial collisions, partial/empty assertions, custom/unknown producers, - invalid types, claim/scope budget boundaries, flattened permissions and inherited - caller rejection, in both gateway modes and raw/typed paths where representable. - -The fixture suite must prove no handler runs after identity failure and no -identity is derived from a bearer header. Test actual SDK registration only after -the shared producers are reviewed and wired. No live deployment is required by -default; a future separately authorized capture can improve the coverage matrix. +- SDK-shaped V2 IAM with valid twelve-digit caller account, explicit cross-account API owner, all four principal forms, and distinct userId/callerId values. +- REST Cognito/IAM cases using documented field meanings; + label constructed values synthetic, never as captured live traffic. +- HTTP 1.0 native cases labeled shape-contract tests, with deployed coverage unverified; + include an unexpected-shape rejection case. +- Adversarial collisions, partial/empty assertions, custom/unknown producers, invalid types, claim/scope budget boundaries, flattened permissions and inherited caller rejection, in both gateway modes and raw/typed paths where representable. + +The fixture suite must prove no handler runs after identity failure and no identity is derived from a bearer header. +Test actual SDK registration only after the shared producers are reviewed and wired. +No live deployment is required by default; +a future separately authorized capture can improve the coverage matrix. ## Resolution -The user approved G1-G4 on 2026-09-16 and authorized implementation. Existing -identity constructors, native extraction and buffered public invocation are now -implemented. Raw and typed paths share marker discovery and construction policy; -typed events are never serialized to inspect them. Scope preflight prevents large -temporary collections from bypassing the configured budget before NewJWT checks -their combined charge with claims. - -The [fixture inventory](../gateway-fixtures.md) labels documented shapes, SDK -schema evidence, synthetic/adversarial cases and unverified deployed coverage. -Tests exercise all four IAM forms, optional effective-user IDs, source conflicts, -raw numeric fidelity, flattened V2 text, owned inputs and discarded unknown SDK -fields. Actual Lambda SDK wrappers cover raw object and both typed registrations, -including V1 number preservation. Full race tests, vet and Lambda builds pass. -The raw identity-boundary fuzz target completed 482,700 synthetic executions in -an initial ten-second run without failures. Public raw/typed allocation baselines -are recorded in docs/benchmarks.md, including binary and near-limit payloads. +The user approved G1-G4 on 2026-09-16 and authorized implementation. +Existing identity constructors, native extraction and buffered public invocation are now implemented. +Raw and typed paths share marker discovery and construction policy; +typed events are never serialized to inspect them. +Scope preflight prevents large temporary collections from bypassing the configured budget before NewJWT checks their combined charge with claims. + +The [fixture inventory](../gateway-fixtures.md) labels documented shapes, SDK schema evidence, synthetic/adversarial cases and unverified deployed coverage. +Tests exercise all four IAM forms, optional effective-user IDs, source conflicts, raw numeric fidelity, flattened V2 text, owned inputs and discarded unknown SDK fields. +Actual Lambda SDK wrappers cover raw object and both typed registrations, including V1 number preservation. +Full race tests, vet and Lambda builds pass. +The raw identity-boundary fuzz target completed 482,700 synthetic executions in an initial ten-second run without failures. +Public raw/typed allocation baselines are recorded in docs/benchmarks.md, including binary and near-limit payloads. diff --git a/docs/decisions/0019-http-authentication-selector.md b/docs/decisions/0019-http-authentication-selector.md index 49c3954..6528fb8 100644 --- a/docs/decisions/0019-http-authentication-selector.md +++ b/docs/decisions/0019-http-authentication-selector.md @@ -1,41 +1,30 @@ # 0019: HTTP authentication selection and challenges -Status: accepted on 2026-09-16. The user approved A1-A6 and authorized -implementation after reviewing this design. Configuration, header selection, -verification/result checks, errors, middleware and examples are implemented. +Status: accepted on 2026-09-16. +The user approved A1-A6 and authorized implementation after reviewing this design. +Configuration, header selection, verification/result checks, errors, middleware and examples are implemented. ## Evidence and existing contracts -- Decisions 0012/0016/0017 already require one explicit producer, no identity - merging, scheme-based selection, and no fallback after verification fails. +- Decisions 0012/0016/0017 already require one explicit producer, no identity merging, scheme-based selection, and no fallback after verification fails. Gateway identity remains disabled by default. - `identity/context.go` rejects every replacement of an established caller. - `iamproof.Verifier.Verify` returns a caller without installing it, preserves - caller cancellation and distinguishes invalid proof from unavailable STS. + `iamproof.Verifier.Verify` returns a caller without installing it, preserves caller cancellation and distinguishes invalid proof from unavailable STS. - `action.go` already handles case aliases and duplicate/comma-bearing values. - `http.Header.Get`/`Values` alone cannot inspect noncanonical map aliases; the - installed Go docs explicitly require direct map access for those keys. -- [AWS payload-format documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), - read through AWS public MCP, says HTTP API payload 2.0 combines duplicate - headers with commas. Payload 1.0 has multivalue headers. This is API Gateway's - event format, not a version of our module. -- [RFC 9110 sections 11.1, 11.3 and 11.6](https://www.rfc-editor.org/rfc/rfc9110.html#section-11) - define case-insensitive schemes, mandatory applicable challenges for 401, - and the WWW-Authenticate list grammar. Decision 0010 and existing response - tests already support separate V1 challenges and correct V2 combination. -- [RFC 6750 sections 2.1 and 3](https://www.rfc-editor.org/rfc/rfc6750.html#section-3) - define Bearer credential syntax and distinguish malformed requests (400), - invalid tokens (401), and inadequate scope (403). Missing credentials and - unsupported authentication methods receive challenges without error details. + `http.Header.Get`/`Values` alone cannot inspect noncanonical map aliases; + the installed Go docs explicitly require direct map access for those keys. +- [AWS payload-format documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), read through AWS public MCP, says HTTP API payload 2.0 combines duplicate headers with commas. + Payload 1.0 has multivalue headers. + This is API Gateway's event format, not a version of our module. +- [RFC 9110 sections 11.1, 11.3 and 11.6](https://www.rfc-editor.org/rfc/rfc9110.html#section-11) define case-insensitive schemes, mandatory applicable challenges for 401, and the WWW-Authenticate list grammar. + Decision 0010 and existing response tests already support separate V1 challenges and correct V2 combination. +- [RFC 6750 sections 2.1 and 3](https://www.rfc-editor.org/rfc/rfc6750.html#section-3) define Bearer credential syntax and distinguish malformed requests (400), invalid tokens (401), and inadequate scope (403). + Missing credentials and unsupported authentication methods receive challenges without error details. A realm parameter provides a concrete challenge without disclosing policy. -- [AWS HTTP API CORS documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-cors.html), - read through AWS public MCP, describes automatic preflight responses when CORS - is configured and separate OPTIONS routing considerations. CORS is deployment - or outer HTTP middleware policy, not an authentication bypass to infer here. -- Go rules favor a small config struct when consumers set a few explicit - dependencies, concrete immutable implementations, and consumer-owned - interfaces. Go 1.27's request-header value-count limit does not replace these - checks for Lambda-created or manually constructed http.Header values. +- [AWS HTTP API CORS documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-cors.html), read through AWS public MCP, describes automatic preflight responses when CORS is configured and separate OPTIONS routing considerations. + CORS is deployment or outer HTTP middleware policy, not an authentication bypass to infer here. +- Go rules favor a small config struct when consumers set a few explicit dependencies, concrete immutable implementations, and consumer-owned interfaces. + Go 1.27's request-header value-count limit does not replace these checks for Lambda-created or manually constructed http.Header values. ## Design graph @@ -62,13 +51,13 @@ flowchart TD Authz -->|Deny| Forbidden[403 owned by authorization layer] ``` -The action order is illustrative, not enforced by authn. A consumer may perform -cheap action parsing earlier, but must authorize and execute the same selection. +The action order is illustrative, not enforced by authn. +A consumer may perform cheap action parsing earlier, but must authorize and execute the same selection. ## A1. Public boundary and configuration -Recommend an opaque immutable `authn.Authenticator`, explicit verifier functions -and one small configuration value. Proposed signatures: +Recommend an opaque immutable `authn.Authenticator`, explicit verifier functions and one small configuration value. +Proposed signatures: ```go type VerifyFunc func(context.Context, string) (identity.Caller, error) @@ -91,92 +80,89 @@ func (e *Error) StatusCode() int func (e *Error) Challenges() []string ``` -At least one verifier is required; nil disables that scheme. New validates and -copies configuration, performs no I/O and does not call verifiers. Empty Realm -defaults to `edge`; explicit realms contain 1-256 printable ASCII bytes, with -quotes/backslashes correctly escaped in challenges. Realm is a display/protection -label, not the IAM proof audience or a JWT issuer restriction. +At least one verifier is required; +nil disables that scheme. +New validates and copies configuration, performs no I/O and does not call verifiers. +Empty Realm defaults to `edge`; +explicit realms contain 1-256 printable ASCII bytes, with quotes/backslashes correctly escaped in challenges. +Realm is a display/protection label, not the IAM proof audience or a JWT issuer restriction. -Authenticate reads headers without modifying them, calls at most one verifier, -and returns a caller without installing context or writing a response. Every -failure returns an anonymous zero caller. Callers must not mutate the header map -concurrently. Handler validates its nonnil next handler, including typed nils, -and provides required-authentication middleware using Authenticate and -identity.WithCaller. Nil/zero Authenticators are configuration errors. +Authenticate reads headers without modifying them, calls at most one verifier, and returns a caller without installing context or writing a response. +Every failure returns an anonymous zero caller. +Callers must not mutate the header map concurrently. +Handler validates its nonnil next handler, including typed nils, and provides required-authentication middleware using Authenticate and identity.WithCaller. +Nil/zero Authenticators are configuration errors. Authenticator is safe for concurrent use when its configured functions are. Consumers may define a narrow interface with Authenticate for their dispatcher; there is no need for a provider-owned interface or another header container. -The lower-level method is the customization boundary for application error -envelopes; Challenges returns a copy. No global registry or callback that must -both write a response and correctly stop the handler chain. +The lower-level method is the customization boundary for application error envelopes; +Challenges returns a copy. +No global registry or callback that must both write a response and correctly stop the handler chain. Alternative: variadic verifier-registration options or one generic scheme map. -The struct makes the two supported mechanisms visible, avoids repeated -registration semantics, and does not imply an extensible authentication plugin -framework. The root edge package keeps its existing options API. +The struct makes the two supported mechanisms visible, avoids repeated registration semantics, and does not imply an extensible authentication plugin framework. +The root edge package keeps its existing options API. ## A2. Strict header selection and a separate field-size limit -Recommend accepting credentials only from Authorization. Inspect all ASCII -case-equivalent keys, including noncanonical Go map keys. No represented values -(absent/nil/empty slices) means missing; one empty represented value is malformed. -Repeated values fail even if identical. Any comma fails rather than selecting -one credential from Gateway's combined representation. Multiplicity has priority -over scheme/credential interpretation; size exhaustion may fail first. - -Trim outer SP/HTAB, recognize schemes with ASCII case folding, require one or -more literal spaces between scheme and credential, and preserve credential case -and bytes. Internal tabs/whitespace and control/non-ASCII header bytes fail. -Configured schemes require a nonempty token68-shaped credential (ASCII letters, -digits, `-._~+/`, with `=` permitted only at the end); invalid header grammar is +Recommend accepting credentials only from Authorization. +Inspect all ASCII case-equivalent keys, including noncanonical Go map keys. +No represented values (absent/nil/empty slices) means missing; +one empty represented value is malformed. +Repeated values fail even if identical. +Any comma fails rather than selecting one credential from Gateway's combined representation. +Multiplicity has priority over scheme/credential interpretation; +size exhaustion may fail first. + +Trim outer SP/HTAB, recognize schemes with ASCII case folding, require one or more literal spaces between scheme and credential, and preserve credential case and bytes. +Internal tabs/whitespace and control/non-ASCII header bytes fail. +Configured schemes require a nonempty token68-shaped credential (ASCII letters, digits, `-._~+/`, with `=` permitted only at the end); +invalid header grammar is 400. Do not assume Bearer is a JWT or decode claims to select a verifier. -EdgeIAM receives its versioned proof unchanged; its verifier owns stricter -JSON/base64/version checks, whose rejection is 401. Likewise, a grammar-valid -Bearer string containing invalid JWT contents reaches the selected verifier and -can yield 401. A structurally valid unsupported or disabled -scheme is a 401, with no verifier call. Malformed generic scheme framing is 400. - -Only header credentials participate in authentication. Do not read request -bodies, cookies or query strings to discover another credential, and never -fall back to them. Their presence does not independently establish identity; + EdgeIAM receives its versioned proof unchanged; + its verifier owns stricter JSON/base64/version checks, whose rejection is 401. + Likewise, a grammar-valid Bearer string containing invalid JWT contents reaches the selected verifier and can yield 401. + A structurally valid unsupported or disabled scheme is a 401, with no verifier call. + Malformed generic scheme framing is 400. + +Only header credentials participate in authentication. +Do not read request bodies, cookies or query strings to discover another credential, and never fall back to them. +Their presence does not independently establish identity; applications must not compose a second producer that authenticates them. -Recommend MaxAuthorizationBytes default 16 KiB, configurable from 1 byte to -1 MiB; zero selects the default, negatives or larger values fail construction. -Charge represented Authorization value bytes before trimming, allocation or -verification; do not concatenate values. This is a library work bound, not an -AWS limit or a limit on all request headers. It includes scheme and whitespace. -The independent iamproof 8 KiB credential limit remains fixed: a field under -this outer bound can still contain an oversized/invalid IAM proof and yield 401. - -Reasoning: the bound must fit the full approved IAM proof, scheme and framing, -while allowing larger Bearer deployments to opt in explicitly. AWS or the native -HTTP server can impose a smaller total header limit before authn is reached. +Recommend MaxAuthorizationBytes default 16 KiB, configurable from 1 byte to 1 MiB; +zero selects the default, negatives or larger values fail construction. +Charge represented Authorization value bytes before trimming, allocation or verification; +do not concatenate values. +This is a library work bound, not an AWS limit or a limit on all request headers. +It includes scheme and whitespace. +The independent iamproof 8 KiB credential limit remains fixed: a field under this outer bound can still contain an oversized/invalid IAM proof and yield 401. + +Reasoning: the bound must fit the full approved IAM proof, scheme and framing, while allowing larger Bearer deployments to opt in explicitly. +AWS or the native HTTP server can impose a smaller total header limit before authn is reached. Duplicates discarded by an earlier proxy cannot be recovered or detected. ## A3. Error interoperability without SDK coupling Recommend these inspectable authn sentinels: -`ErrInvalidConfiguration`, `ErrMissingCredentials`, `ErrMalformedCredentials`, -`ErrUnsupportedScheme`, `ErrHeaderTooLarge`, `ErrInvalidCredentials`, -`ErrUnavailable`, and `ErrVerifierContract`. +`ErrInvalidConfiguration`, `ErrMissingCredentials`, `ErrMalformedCredentials`, `ErrUnsupportedScheme`, `ErrHeaderTooLarge`, `ErrInvalidCredentials`, `ErrUnavailable`, and `ErrVerifierContract`. -Use identity.ErrConflict for an existing caller; do not add a synonymous authn -sentinel. Runtime HTTP failures use the private-state Error wrapper above. -Its error chain contains only the sanitized category. Caller cancellation returns -ctx.Err directly, never context.Cause or a wrapped provider diagnostic. +Use identity.ErrConflict for an existing caller; +do not add a synonymous authn sentinel. +Runtime HTTP failures use the private-state Error wrapper above. +Its error chain contains only the sanitized category. +Caller cancellation returns ctx.Err directly, never context.Cause or a wrapped provider diagnostic. -Verifier functions must classify a definite credential rejection with -authn.ErrInvalidCredentials. Explicit ErrUnavailable or any otherwise unknown -error becomes sanitized ErrUnavailable. If an error matches both categories, -unavailable wins. A verifier context error while the request context is still -live is also unavailable: it is an internal timeout, not caller cancellation. +Verifier functions must classify a definite credential rejection with authn.ErrInvalidCredentials. +Explicit ErrUnavailable or any otherwise unknown error becomes sanitized ErrUnavailable. +If an error matches both categories, unavailable wins. +A verifier context error while the request context is still live is also unavailable: it is an internal timeout, not caller cancellation. Any nonnil error discards a simultaneously returned caller. -Keep authn free of iamproof and AWS SDK imports. Recommend a documented explicit -adapter for the already implemented verifier: +Keep authn free of iamproof and AWS SDK imports. +Recommend a documented explicit adapter for the already implemented verifier: ```go verifyIAM := func(ctx context.Context, token string) (identity.Caller, error) { @@ -188,45 +174,41 @@ verifyIAM := func(ctx context.Context, token string) (identity.Caller, error) { } ``` -The authenticator checks request cancellation independently; other returned -errors are sanitized. Passing proofVerifier.Verify directly is deliberately -insufficient for classifying its package-specific invalid-proof sentinel and -would yield 503 for that error. Documentation and examples must make the adapter -visible rather than suggest direct wiring. The first-party Bearer verifier can -return authn's categories directly when implemented. +The authenticator checks request cancellation independently; +other returned errors are sanitized. +Passing proofVerifier.Verify directly is deliberately insufficient for classifying its package-specific invalid-proof sentinel and would yield 503 for that error. +Documentation and examples must make the adapter visible rather than suggest direct wiring. +The first-party Bearer verifier can return authn's categories directly when implemented. Alternatives: importing iamproof into authn couples JWT-only binaries to STS; making iamproof depend on HTTP authn reverses its transport-independent boundary; -a new exported errors package or reflective classification protocol adds machinery -for a mapping currently expressible in a few lines. This explicit adapter is the -main ergonomic tradeoff proposed for review. +a new exported errors package or reflective classification protocol adds machinery for a mapping currently expressible in a few lines. +This explicit adapter is the main ergonomic tradeoff proposed for review. ## A4. Success invariants and producer composition -Require Bearer success to return KindJWT/SourceLocallyVerifiedToken and EdgeIAM -success to return KindIAM/SourceVerifiedIAMProof. Anonymous success, wrong kind, -gateway/custom provenance or another invalid result is ErrVerifierContract/500, -not a client credential rejection. Initial Bearer support therefore produces the -existing validated token identity; it does not add a new opaque-token principal -kind. The function must perform actual verification, not merely parse claims. - -Check an already established caller before parsing credentials or running any -verifier. Return identity.ErrConflict/500 even if credentials are absent or the -apparently same identity would result. This is a deployment/composition fault, -not an instruction for the client to obtain a different token. No skip, replace, -merge or fallback option initially. This implements the previously accepted -producer policy; the HTTP classification is new here. - -Recheck caller cancellation after verification and before installing/passing -control. Handler installs once in a derived context, leaves the original request -and headers intact, and preserves unrelated context values. It invokes next -exactly once only after success. Provider panics propagate under normal HTTP/ -edge lifecycle policy; do not disguise application bugs as invalid credentials. +Require Bearer success to return KindJWT/SourceLocallyVerifiedToken and EdgeIAM success to return KindIAM/SourceVerifiedIAMProof. +Anonymous success, wrong kind, gateway/custom provenance or another invalid result is ErrVerifierContract/500, not a client credential rejection. +Initial Bearer support therefore produces the existing validated token identity; +it does not add a new opaque-token principal kind. +The function must perform actual verification, not merely parse claims. + +Check an already established caller before parsing credentials or running any verifier. +Return identity.ErrConflict/500 even if credentials are absent or the apparently same identity would result. +This is a deployment/composition fault, not an instruction for the client to obtain a different token. +No skip, replace, merge or fallback option initially. +This implements the previously accepted producer policy; +the HTTP classification is new here. + +Recheck caller cancellation after verification and before installing/passing control. +Handler installs once in a derived context, leaves the original request and headers intact, and preserves unrelated context values. +It invokes next exactly once only after success. +Provider panics propagate under normal HTTP/edge lifecycle policy; +do not disguise application bugs as invalid credentials. ## A5. Default HTTP outcomes and challenges -Recommend the following precise HTTP mapping, refining decision 0017's broad -malformed-credential wording at the HTTP framing boundary: +Recommend the following precise HTTP mapping, refining decision 0017's broad malformed-credential wording at the HTTP framing boundary: | Condition | HTTP | WWW-Authenticate | | --- | --- | --- | @@ -245,100 +227,92 @@ WWW-Authenticate: Bearer realm="edge" WWW-Authenticate: EdgeIAM realm="edge" ``` -Bearer comes first for client compatibility. Only advertise enabled mechanisms. -Use realm on every challenge. Do not invent OAuth error parameters for EdgeIAM, -echo token details, disclose issuer/account restrictions, or claim scopes are -insufficient at the authentication layer. No error_description, error_uri, -automatic Retry-After or WWW-Authenticate on dependency/internal failures. +Bearer comes first for client compatibility. +Only advertise enabled mechanisms. +Use realm on every challenge. +Do not invent OAuth error parameters for EdgeIAM, echo token details, disclose issuer/account restrictions, or claim scopes are insufficient at the authentication layer. +No error_description, error_uri, automatic Retry-After or WWW-Authenticate on dependency/internal failures. -The built-in handler writes fixed generic text using net/http conventions, -sets Cache-Control: no-store, and preserves unrelated outer middleware headers -such as CORS. It owns/replaces WWW-Authenticate on its failure responses; stale -challenges from outer middleware must not survive a 500/503/431. It performs no -logging. Error.StatusCode/Challenges support consumers writing their own JSON -response using encoding/json/v2 without exposing credentials or raw causes. +The built-in handler writes fixed generic text using net/http conventions, sets Cache-Control: no-store, and preserves unrelated outer middleware headers such as CORS. +It owns/replaces WWW-Authenticate on its failure responses; +stale challenges from outer middleware must not survive a 500/503/431. +It performs no logging. +Error.StatusCode/Challenges support consumers writing their own JSON response using encoding/json/v2 without exposing credentials or raw causes. For caller cancellation/deadline, Authenticate preserves the context error. -Handler never invokes next and attempts a generic 503 without a challenge; an -already disconnected transport may reject the write. This avoids an unwritten -handler accidentally becoming an implicit 200 on an otherwise live native HTTP -connection. Edge's invocation cancellation rules still suppress a usable Lambda -response when its parent context is canceled. Do not promise wire delivery. +Handler never invokes next and attempts a generic 503 without a challenge; +an already disconnected transport may reject the write. +This avoids an unwritten handler accidentally becoming an implicit 200 on an otherwise live native HTTP connection. +Edge's invocation cancellation rules still suppress a usable Lambda response when its parent context is canceled. +Do not promise wire delivery. -This is an accepted HTTP-level refinement, not a change to the existing -iamproof error contract. Malformed proof/token content still yields 401; malformed -Authorization framing yields 400. Authorization owns permission denial and 403. +This is an accepted HTTP-level refinement, not a change to the existing iamproof error contract. +Malformed proof/token content still yields 401; +malformed Authorization framing yields 400. +Authorization owns permission denial and 403. ## A6. Routes, action dispatch and streaming -Recommend required authentication only in the initial Handler. Public routes -omit this middleware; do not add an optional-authentication mode until its -missing-versus-invalid and existing-caller semantics have a concrete consumer. -No automatic OPTIONS bypass: configure CORS/preflight at the gateway or outside -the authentication middleware. Health checks are explicit public routes. +Recommend required authentication only in the initial Handler. +Public routes omit this middleware; +do not add an optional-authentication mode until its missing-versus-invalid and existing-caller semantics have a concrete consumer. +No automatic OPTIONS bypass: configure CORS/preflight at the gateway or outside the authentication middleware. +Health checks are explicit public routes. -Keep action extraction, extra headers, registry lookup and authorization in the -consumer's ordinary HTTP pipeline. Demonstrate both identity kinds reaching one -dispatcher, with each selected action independently authorized. IAM verification -does not grant all authenticated AWS accounts access. Do not bind verifier -selection to an action or pass the whole HTTP request to VerifyFunc. +Keep action extraction, extra headers, registry lookup and authorization in the consumer's ordinary HTTP pipeline. +Demonstrate both identity kinds reaching one dispatcher, with each selected action independently authorized. +IAM verification does not grant all authenticated AWS accounts access. +Do not bind verifier selection to an action or pass the whole HTTP request to VerifyFunc. Authenticate and authorize before any response commitment or streaming flush. -The resulting caller is a snapshot; this middleware does not refresh credentials -or silently reauthorize during a long stream. Applications needing continuous -authorization own a separate explicit policy. +The resulting caller is a snapshot; +this middleware does not refresh credentials or silently reauthorize during a long stream. +Applications needing continuous authorization own a separate explicit policy. -HTTPS termination is a deployment requirement. Do not require r.TLS to be set: -API Gateway adaptation is not a live TLS server connection, and forwarded scheme -headers are not sufficient evidence to authenticate a caller. +HTTPS termination is a deployment requirement. +Do not require r.TLS to be set: API Gateway adaptation is not a live TLS server connection, and forwarded scheme headers are not sufficient evidence to authenticate a caller. ## Implementation after approval 1. Implement pure configuration/selection/error handling with no SDK dependency. 2. Implement the low-level method and required-authentication HTTP wrapper. -3. Test header aliases, multiplicity, empty values, comma folding, whitespace, - grammar, byte limits, disabled schemes, exactly-one calls and no fallback. -4. Test error normalization, caller-plus-error, wrong success kind/source, - existing caller conflicts, cancellation, safe diagnostics and context isolation. +3. Test header aliases, multiplicity, empty values, comma folding, whitespace, grammar, byte limits, disabled schemes, exactly-one calls and no fallback. +4. Test error normalization, caller-plus-error, wrong success kind/source, existing caller conflicts, cancellation, safe diagnostics and context isolation. 5. Test challenges/error responses on native HTTP plus raw/typed Gateway V1/V2; retain the actual IAM generator/verifier path with local synthetic STS fixtures. - Inputs rejected earlier by transport validation cannot be claimed to reach - the HTTP middleware; retain that distinction in the fixture expectations. -6. Add runnable examples for the IAM error adapter, a consumer-provided Bearer - verifier, custom JSON failures and one action dispatcher. Label synthetic - token verifiers in tests/examples; never present them as production JWT checks. -7. Run race/vet/Lambda builds and targeted parser fuzzing. Separately review - Cognito/JWT/JWKS verification and authz; neither is approved implicitly here. + Inputs rejected earlier by transport validation cannot be claimed to reach the HTTP middleware; + retain that distinction in the fixture expectations. +6. Add runnable examples for the IAM error adapter, a consumer-provided Bearer verifier, custom JSON failures and one action dispatcher. + Label synthetic token verifiers in tests/examples; + never present them as production JWT checks. +7. Run race/vet/Lambda builds and targeted parser fuzzing. + Separately review Cognito/JWT/JWKS verification and authz; + neither is approved implicitly here. ## Resolution -The user approved A1-A6 on 2026-09-16, including the public config/API, explicit -IAM error adapter, 400/401/431 distinction and default cancellation response. -Implement within these boundaries; Cognito/JWKS and authz remain separate reviews. +The user approved A1-A6 on 2026-09-16, including the public config/API, explicit IAM error adapter, 400/401/431 distinction and default cancellation response. +Implement within these boundaries; +Cognito/JWKS and authz remain separate reviews. ## Implementation evidence -The production authn package implements A1-A6. Dependency inspection confirms -only standard-library and identity imports; no iamproof or AWS SDK dependency -is introduced. The explicit IAM error adapter is documented and tested, including -the qualified 503 behavior when consumers omit it. - -Local tests cover case aliases, empty/repeated/comma-combined fields, grammar and -size limits, scheme selection, no fallback, error precedence and sanitization, -wrong caller kinds/sources, gateway/local conflicts, cancellation, context and -challenge ownership, concurrency, and propagation of verifier panics. Actual -IAM generation/verification uses synthetic STS transport fixtures through native -HTTP and all raw/typed Gateway entry points. Both caller kinds reach one consumer -dispatcher with independent action permission checks. Challenge combination -works in payload 2.0 without changing the existing header contract. Inputs that -transport validation rejects earlier remain outside the HTTP middleware contract. - -Runnable examples cover the explicit error adapter, synthetic consumer Bearer -verification, a shared action dispatcher and direct JSON v2 failure responses. +The production authn package implements A1-A6. +Dependency inspection confirms only standard-library and identity imports; +no iamproof or AWS SDK dependency is introduced. +The explicit IAM error adapter is documented and tested, including the qualified 503 behavior when consumers omit it. + +Local tests cover case aliases, empty/repeated/comma-combined fields, grammar and size limits, scheme selection, no fallback, error precedence and sanitization, wrong caller kinds/sources, gateway/local conflicts, cancellation, context and challenge ownership, concurrency, and propagation of verifier panics. +Actual IAM generation/verification uses synthetic STS transport fixtures through native HTTP and all raw/typed Gateway entry points. +Both caller kinds reach one consumer dispatcher with independent action permission checks. +Challenge combination works in payload 2.0 without changing the existing header contract. +Inputs that transport validation rejects earlier remain outside the HTTP middleware contract. + +Runnable examples cover the explicit error adapter, synthetic consumer Bearer verification, a shared action dispatcher and direct JSON v2 failure responses. They clearly distinguish synthetic token fixtures from production verification. The [consumer guide](../authn.md) records deployment and composition constraints. -Full module race tests, vet, formatting checks and Linux arm64/amd64 builds pass -on Go 1.27.1. A ten-second Authorization fuzz run completed 726,457 executions -without failures. No live AWS verification, resource deployment or GitHub CI run -was performed. Existing CI automatically includes the new package. +Full module race tests, vet, formatting checks and Linux arm64/amd64 builds pass on Go 1.27.1. +A ten-second Authorization fuzz run completed 726,457 executions without failures. +No live AWS verification, resource deployment or GitHub CI run was performed. +Existing CI automatically includes the new package. diff --git a/docs/decisions/0020-cognito-verification.md b/docs/decisions/0020-cognito-verification.md index 9d78632..58264b6 100644 --- a/docs/decisions/0020-cognito-verification.md +++ b/docs/decisions/0020-cognito-verification.md @@ -1,50 +1,44 @@ # 0020: Cognito access-token verification and JWKS ownership -Status: accepted; verifier, key cache, examples and local validation implemented. +Status: accepted; +verifier, key cache, examples and local validation implemented. Reviewed against local code and official sources on 2026-09-16, with Go 1.27.1. ## Evidence and scope -The implemented `authn.VerifyFunc` already accepts a credential and returns one -`identity.Caller`. `authn.Authenticator` owns scheme selection, challenges and -context installation. `identity.ParseClaims` retains exact JSON number text; +The implemented `authn.VerifyFunc` already accepts a credential and returns one `identity.Caller`. +`authn.Authenticator` owns scheme selection, challenges and context installation. +`identity.ParseClaims` retains exact JSON number text; `identity.NewJWT` validates identity shape but explicitly does not authenticate. -We can add a verifier without changing these boundaries or importing AWS SDKs -into authn. Decisions 0012, 0014, 0016 and 0019 remain in force. +We can add a verifier without changing these boundaries or importing AWS SDKs into authn. +Decisions 0012, 0014, 0016 and 0019 remain in force. AWS sources were consulted through the public AWS MCP documentation tools: -- [Verifying Cognito JWTs](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html): - Cognito uses RS256, separate access/ID signing keys, `kid` selection and JWKS. - Validate issuer, expiry, token use, and the appropriate client claim. AWS - recommends caching keys, refreshing periodically and fetching on an unknown key. -- [Access-token claims](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html): - `client_id` identifies the app client; `aud` is the API resource when bound. - Scopes and groups are distinct. Do not impose a UUID grammar on `sub`. -- [Resource servers and resource binding](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html): - resource binding is available in supported user authorization flows, not - client-credentials M2M grants or SDK authentication models. Requiring `aud` - universally would exclude legitimate intended consumers. -- [Issuer configuration](https://docs.aws.amazon.com/help-panel/cognito/latest/console/hp-issuer.html) - and [AWS's multi-Region architecture guidance](https://aws.amazon.com/blogs/security/architecting-resilient-authentication-with-amazon-cognito-multi-region-replication/): - both original `cognito-idp` and updated `issuer-cognito-idp` issuer URLs exist. - Updated issuers support replicated key availability; issuer equality remains - exact. Do not bake the original hostname convention into the verifier API. -- [Token revocation](https://docs.aws.amazon.com/cognito/latest/developerguide/token-revocation.html): - revoked tokens can still pass offline signature/expiration verification. - -[JWT BCP](https://www.rfc-editor.org/rfc/rfc8725.html) supports explicit algorithm, -issuer, audience and token-profile restrictions. [JWS](https://www.rfc-editor.org/rfc/rfc7515.html) -defines the compact signing input and critical-header handling. -[JWA](https://datatracker.ietf.org/doc/html/rfc7518#section-3.3) defines RS256, -requires RSA keys of at least 2048 bits, and defines unsigned `n`/`e` encoding. -[JWT](https://www.rfc-editor.org/rfc/rfc7519.html) allows fractional NumericDate -values: the integer-only proposal below is our narrower Cognito policy, not a -universal JWT rule. [JWK](https://www.rfc-editor.org/rfc/rfc7517.html) supplies the -key metadata model; our ambiguity and resource limits are additional policy. - -AWS does not prescribe our cache durations, limits, failure classification or -public Go API. Those are proposed engineering decisions below. +- [Verifying Cognito JWTs](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html): Cognito uses RS256, separate access/ID signing keys, `kid` selection and JWKS. + Validate issuer, expiry, token use, and the appropriate client claim. + AWS recommends caching keys, refreshing periodically and fetching on an unknown key. +- [Access-token claims](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html): `client_id` identifies the app client; + `aud` is the API resource when bound. + Scopes and groups are distinct. + Do not impose a UUID grammar on `sub`. +- [Resource servers and resource binding](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html): resource binding is available in supported user authorization flows, not client-credentials M2M grants or SDK authentication models. + Requiring `aud` universally would exclude legitimate intended consumers. +- [Issuer configuration](https://docs.aws.amazon.com/help-panel/cognito/latest/console/hp-issuer.html) and [AWS's multi-Region architecture guidance](https://aws.amazon.com/blogs/security/architecting-resilient-authentication-with-amazon-cognito-multi-region-replication/): both original `cognito-idp` and updated `issuer-cognito-idp` issuer URLs exist. + Updated issuers support replicated key availability; + issuer equality remains exact. + Do not bake the original hostname convention into the verifier API. +- [Token revocation](https://docs.aws.amazon.com/cognito/latest/developerguide/token-revocation.html): revoked tokens can still pass offline signature/expiration verification. + +[JWT BCP](https://www.rfc-editor.org/rfc/rfc8725.html) supports explicit algorithm, issuer, audience and token-profile restrictions. +[JWS](https://www.rfc-editor.org/rfc/rfc7515.html) defines the compact signing input and critical-header handling. +[JWA](https://datatracker.ietf.org/doc/html/rfc7518#section-3.3) defines RS256, requires RSA keys of at least 2048 bits, and defines unsigned `n`/`e` encoding. +[JWT](https://www.rfc-editor.org/rfc/rfc7519.html) allows fractional NumericDate values: the integer-only proposal below is our narrower Cognito policy, not a universal JWT rule. +[JWK](https://www.rfc-editor.org/rfc/rfc7517.html) supplies the key metadata model; +our ambiguity and resource limits are additional policy. + +AWS does not prescribe our cache durations, limits, failure classification or public Go API. +Those are proposed engineering decisions below. ## Design graph @@ -65,8 +59,8 @@ flowchart TD Middleware --> Authorization[Consumer action selection and authorization] ``` -The graph's pre-verification claim checks can only reject. No unverified claim -becomes an identity, permission, network destination or additional trust anchor. +The graph's pre-verification claim checks can only reject. +No unverified claim becomes an identity, permission, network destination or additional trust anchor. All paths that construct a caller require a verified signature. ## C1. A concrete Cognito verifier, with explicit trust configuration @@ -96,46 +90,50 @@ func (v *CognitoVerifier) Verify(ctx context.Context, token string) (identity.Ca func (v *CognitoVerifier) Warm(ctx context.Context) error ``` -Construction validates/copies config without I/O. The opaque verifier is safe for -concurrent use; do not copy it after construction. Reuse one instance per issuer -and policy. `Warm` optionally obtains a fresh snapshot before accepting traffic; +Construction validates/copies config without I/O. +The opaque verifier is safe for concurrent use; +do not copy it after construction. +Reuse one instance per issuer and policy. +`Warm` optionally obtains a fresh snapshot before accepting traffic; it obeys refresh/backoff gates and does not force a refresh of a fresh snapshot. It establishes key availability, not that any particular token will be accepted. No public cache interface, global cache, background refresh loop or Close method. -Require one exact nonempty HTTPS issuer and at least one distinct, nonempty -ClientID. Reject duplicate client policies, even identical ones. Issuer URLs must -have a host and nonempty pool path, with no credentials, query, fragment, escaped -path, dot segments or trailing slash. Derive exactly -`Issuer + "/.well-known/jwks.json"`; do not run discovery or read URLs from tokens. -Do not rewrite or case-fold issuer/audience identifiers. Invalid configuration, -nil contexts and nil/zero verifiers return `ErrInvalidConfiguration`. - -Nil Transport selects the standard default transport; a typed-nil transport is -invalid configuration. Configuration slices are owned copies; a supplied -transport remains a shared caller-owned dependency. - -The issuer is a trusted operator-supplied endpoint, not proof that a URL belongs -to AWS. Avoid an embedded AWS hostname/partition table. This supports either -Cognito issuer format without bringing endpoint SDKs into authn. The default -transport verifies TLS normally; a custom transport is a trusted dependency. +Require one exact nonempty HTTPS issuer and at least one distinct, nonempty ClientID. +Reject duplicate client policies, even identical ones. +Issuer URLs must have a host and nonempty pool path, with no credentials, query, fragment, escaped path, dot segments or trailing slash. +Derive exactly `Issuer + "/.well-known/jwks.json"`; +do not run discovery or read URLs from tokens. +Do not rewrite or case-fold issuer/audience identifiers. +Invalid configuration, nil contexts and nil/zero verifiers return `ErrInvalidConfiguration`. + +Nil Transport selects the standard default transport; +a typed-nil transport is invalid configuration. +Configuration slices are owned copies; +a supplied transport remains a shared caller-owned dependency. + +The issuer is a trusted operator-supplied endpoint, not proof that a URL belongs to AWS. +Avoid an embedded AWS hostname/partition table. +This supports either Cognito issuer format without bringing endpoint SDKs into authn. +The default transport verifies TLS normally; +a custom transport is a trusted dependency. Do not accept an untrusted request value as configuration. -Initially one exact issuer per verifier and access tokens only. Migration between -issuer URLs requires explicit configuration changes or consumer-owned routing -over a finite configured issuer set; never retry arbitrary issuers after failure. -Generic OAuth providers, ID tokens, multi-issuer routing and discovery need their -own reviewed profiles. The existing VerifyFunc still permits other providers. +Initially one exact issuer per verifier and access tokens only. +Migration between issuer URLs requires explicit configuration changes or consumer-owned routing over a finite configured issuer set; +never retry arbitrary issuers after failure. +Generic OAuth providers, ID tokens, multi-issuer routing and discovery need their own reviewed profiles. +The existing VerifyFunc still permits other providers. -Alternative: pool ID plus region hides issuer evolution; a generic JWT engine -expands the algorithm/claim surface before we have another concrete provider. -Keeping this boundary in authn also lets consumers wire `Bearer: verifier.Verify` -directly, without the error adapter required by iamproof. +Alternative: pool ID plus region hides issuer evolution; +a generic JWT engine expands the algorithm/claim surface before we have another concrete provider. +Keeping this boundary in authn also lets consumers wire `Bearer: verifier.Verify` directly, without the error adapter required by iamproof. ## C2. Client and resource audience are independent restrictions Require `token_use == "access"` and an exact allowed `client_id` for every token. -Do not use `aud` as a fallback client ID. No implicit allow-all-clients switch. +Do not use `aud` as a fallback client ID. +No implicit allow-all-clients switch. | Client policy | Absent aud | Exact matching aud | Other aud | | --- | --- | --- | --- | @@ -144,33 +142,34 @@ Do not use `aud` as a fallback client ID. No implicit allow-all-clients switch. | Audience empty; AllowUnbound true | Accept | Reject | Reject | | Audience empty; AllowUnbound false | Invalid configuration | Invalid configuration | Invalid configuration | -Recommend a nonempty string `aud` when present, matching Cognito's single-resource -binding profile. Arrays, null and empty strings fail. General JWT permits arrays; +Recommend a nonempty string `aud` when present, matching Cognito's single-resource binding profile. +Arrays, null and empty strings fail. +General JWT permits arrays; supporting that wider representation is not required for this initial profile. -Audience identifiers are exact strings; do not equate the configured resource -with the HTTP Host, request URL, client ID or IAM proof audience automatically. +Audience identifiers are exact strings; +do not equate the configured resource with the HTTP Host, request URL, client ID or IAM proof audience automatically. -Use separate client entries for a resource-bound user client and an unbound M2M -client. Enabling AllowUnbound with an Audience deliberately permits both bound -and unbound flows for that client; prefer separate clients where practical. +Use separate client entries for a resource-bound user client and an unbound M2M client. +Enabling AllowUnbound with an Audience deliberately permits both bound and unbound flows for that client; +prefer separate clients where practical. AllowUnbound never means accept any audience supplied in the token. -Reasoning: this preserves M2M and SDK user authentication while making the loss -of resource binding visible in configuration. Unbound tokens can otherwise be -replayed across APIs trusting the same issuer/client. Applications should use -dedicated clients and resource-specific scopes and authorize each action. +Reasoning: this preserves M2M and SDK user authentication while making the loss of resource binding visible in configuration. +Unbound tokens can otherwise be replayed across APIs trusting the same issuer/client. +Applications should use dedicated clients and resource-specific scopes and authorize each action. Successful authentication alone grants no action access. -Require client_id even when sub exists; permit absent sub, but a present sub must -be nonempty text. Do not infer human/M2M identity from the presence or format of -sub. Preserve scopes/groups/custom claims through the existing identity model, -including its rejection of malformed/conflicting normalized facts. Missing -scopes grant nothing; do not add implicit scope requirements to authentication. +Require client_id even when sub exists; +permit absent sub, but a present sub must be nonempty text. +Do not infer human/M2M identity from the presence or format of sub. +Preserve scopes/groups/custom claims through the existing identity model, including its rejection of malformed/conflicting normalized facts. +Missing scopes grant nothing; +do not add implicit scope requirements to authentication. ## C3. Direct JSON v2 plus a narrow, fixed RS256 verification path -Latest stable module versions were queried and their downloaded source inspected -on 2026-09-16. No dependency was added to this repository: +Latest stable module versions were queried and their downloaded source inspected on 2026-09-16. +No dependency was added to this repository: | Candidate | Inspected ordinary parsing path | | --- | --- | @@ -178,63 +177,60 @@ on 2026-09-16. No dependency was added to this repository: | lestrrat-go/jwx/v3 v3.3.0 | `internal/json/stdlib.go` wraps encoding/json; alternative codec is not stdlib JSON v2 | | go-jose/go-jose/v4 v4.1.5 | `jws.go` uses its own JSON fork | -Sources: [jwt parser](https://github.com/golang-jwt/jwt/blob/v5.3.1/parser.go), -[jwx codec](https://github.com/lestrrat-go/jwx/blob/v3.3.0/internal/json/stdlib.go), -[go-jose parser](https://github.com/go-jose/go-jose/blob/v4.1.5/jws.go). +Sources: [jwt parser](https://github.com/golang-jwt/jwt/blob/v5.3.1/parser.go), [jwx codec](https://github.com/lestrrat-go/jwx/blob/v3.3.0/internal/json/stdlib.go), [go-jose parser](https://github.com/go-jose/go-jose/blob/v4.1.5/jws.go). These are codec compatibility findings, not claims that the libraries are unsafe. -Using only jwt's RSA Verify method would still leave us owning JSON/JOSE/claims -and add a dependency around the same standard-library RSA operation. - -Recommend direct `encoding/json/v2`/`jsontext` for all three JSON objects: header, -claims and JWKS. Use `crypto/sha256` and -`rsa.VerifyPKCS1v15(key, crypto.SHA256, digest, signature)` for the cryptographic -operation, verified against installed Go documentation. We own the narrow JOSE -validation, not RSA arithmetic. Require independent known-answer tests in -addition to locally generated round trips. This is real security maintenance; -revisit dependency selection before adding algorithms or generic JOSE features. +Using only jwt's RSA Verify method would still leave us owning JSON/JOSE/claims and add a dependency around the same standard-library RSA operation. -Require exactly three nonempty compact segments; canonical unpadded base64url -with no whitespace or ignored CR/LF. Reject duplicate JSON members, invalid UTF-8, -trailing JSON and nonobject header/claims. Verify the original encoded -`header.payload` bytes; never reserialize the signing input. +Recommend direct `encoding/json/v2`/`jsontext` for all three JSON objects: header, claims and JWKS. +Use `crypto/sha256` and `rsa.VerifyPKCS1v15(key, crypto.SHA256, digest, signature)` for the cryptographic operation, verified against installed Go documentation. +We own the narrow JOSE validation, not RSA arithmetic. +Require independent known-answer tests in addition to locally generated round trips. +This is real security maintenance; +revisit dependency selection before adding algorithms or generic JOSE features. -Require exact `alg: "RS256"`, a nonempty bounded opaque kid, and absent typ or -`typ: "JWT"`. Reject crit, b64, embedded keys, key/certificate URLs, certificate -chains, compression and nested-content declarations in the token header. This -profile implements none of those optional features. Ignore other noncritical -extensions within the header bound. Never treat kid as a URL or filesystem path. +Require exactly three nonempty compact segments; +canonical unpadded base64url with no whitespace or ignored CR/LF. +Reject duplicate JSON members, invalid UTF-8, trailing JSON and nonobject header/claims. +Verify the original encoded `header.payload` bytes; +never reserialize the signing input. + +Require exact `alg: "RS256"`, a nonempty bounded opaque kid, and absent typ or `typ: "JWT"`. +Reject crit, b64, embedded keys, key/certificate URLs, certificate chains, compression and nested-content declarations in the token header. +This profile implements none of those optional features. +Ignore other noncritical extensions within the header bound. +Never treat kid as a URL or filesystem path. Do not retry a known key after signature failure or try every key in the set. -JWKS must be one object with a bounded keys array. Reject duplicate nonempty kids -across the set, including otherwise unsupported key types, to avoid ambiguity. -Ignore explicitly unsupported key types/algorithms/usages; validate every key -eligible for RS256 verification before publishing the snapshot. Require at least -one usable key. For eligible RSA keys require nonempty kid and canonical unsigned -n/e, odd modulus of 2048..4096 bits, odd exponent in 3..2^31-1, no private RSA -parameters, optional alg equal to RS256, optional use equal to sig, and optional -key_ops exactly ["verify"]. Malformed eligible keys invalidate the fetched set; -never partially publish it. Unknown metadata stays bounded and cannot add trust. - -The 4096-bit ceiling, strict typ/aud/header profile and duplicate-kid rejection -are our resource/ambiguity policies, not claimed AWS service limits. A future -Cognito change outside this profile must produce a clear reviewed compatibility -change, not silently broaden algorithms or trust. +JWKS must be one object with a bounded keys array. +Reject duplicate nonempty kids across the set, including otherwise unsupported key types, to avoid ambiguity. +Ignore explicitly unsupported key types/algorithms/usages; +validate every key eligible for RS256 verification before publishing the snapshot. +Require at least one usable key. +For eligible RSA keys require nonempty kid and canonical unsigned n/e, odd modulus of 2048..4096 bits, odd exponent in 3..2^31-1, no private RSA parameters, optional alg equal to RS256, optional use equal to sig, and optional key_ops exactly ["verify"]. +Malformed eligible keys invalidate the fetched set; +never partially publish it. +Unknown metadata stays bounded and cannot add trust. -## C4. Time, bounds and error contract +The 4096-bit ceiling, strict typ/aud/header profile and duplicate-kid rejection are our resource/ambiguity policies, not claimed AWS service limits. +A future Cognito change outside this profile must produce a clear reviewed compatibility change, not silently broaden algorithms or trust. -Require exp and iat; nbf is optional. Recommend plain, nonnegative integer JSON -seconds in 0..253402300799 (through year 9999), parsed from exact number text -without float64. Reject strings, fractions, exponent notation, null and overflow. -AWS describes Unix timestamps and publishes integer examples; it does not -guarantee this lexical restriction. It is an explicit narrow-profile choice to -avoid rounding and date arithmetic surprises, with a compatibility cost. +## C4. Time, bounds and error contract -Require exp > iat and, when present, nbf < exp. With leeway L, accept only while -now < exp + L, iat <= now + L, and nbf <= now + L. Zero ClockSkew means zero -leeway; allow an explicit 0..5 minutes. Do not default to extending token life or -infer the issuer's configured maximum lifetime. Recheck times after network and -signature work. Use wall time for token dates and elapsed time for local cache -intervals; Lambda thaw must not revive expired tokens or cache entries. +Require exp and iat; +nbf is optional. +Recommend plain, nonnegative integer JSON seconds in 0..253402300799 (through year 9999), parsed from exact number text without float64. +Reject strings, fractions, exponent notation, null and overflow. +AWS describes Unix timestamps and publishes integer examples; +it does not guarantee this lexical restriction. +It is an explicit narrow-profile choice to avoid rounding and date arithmetic surprises, with a compatibility cost. + +Require exp > iat and, when present, nbf < exp. +With leeway L, accept only while now < exp + L, iat <= now + L, and nbf <= now + L. Zero ClockSkew means zero leeway; +allow an explicit 0..5 minutes. +Do not default to extending token life or infer the issuer's configured maximum lifetime. +Recheck times after network and signature work. +Use wall time for token dates and elapsed time for local cache intervals; +Lambda thaw must not revive expired tokens or cache entries. Proposed bounds/defaults, owned by this module: @@ -248,26 +244,27 @@ Proposed bounds/defaults, owned by this module: | CacheTTL | 15 minutes; explicit 1 minute..24 hours | | Timeout | 5 seconds; explicit positive duration up to 1 minute | -Zero selects defaults except ClockSkew. Negative/out-of-range config fails. -Token size and weighted claims budget are independent. The outer Authorization -limit includes the scheme and can reject a token before this verifier; document -coordinating those limits. Preserve the identity nesting limit (64) and impose -the same maximum JSON nesting depth on header/JWKS, including ignored fields. - -Bad token syntax, limits, profile, claims, signature or definitively missing key -return `ErrInvalidCredentials`; dependency/network/JWKS failures return -`ErrUnavailable`. These are sanitized categories, with no token, claims, kid, -provider body or raw transport error in their error chain. Nil caller on every -failure means the zero identity.Caller value. Preserve the caller's ctx.Err, -never context.Cause. Return a caller with SourceLocallyVerifiedToken only after -all checks. Verify neither installs context nor writes HTTP; the existing -selector handles 401/503 and composition conflicts. +Zero selects defaults except ClockSkew. +Negative/out-of-range config fails. +Token size and weighted claims budget are independent. +The outer Authorization limit includes the scheme and can reject a token before this verifier; +document coordinating those limits. +Preserve the identity nesting limit (64) and impose the same maximum JSON nesting depth on header/JWKS, including ignored fields. + +Bad token syntax, limits, profile, claims, signature or definitively missing key return `ErrInvalidCredentials`; +dependency/network/JWKS failures return `ErrUnavailable`. +These are sanitized categories, with no token, claims, kid, provider body or raw transport error in their error chain. +Nil caller on every failure means the zero identity.Caller value. +Preserve the caller's ctx.Err, never context.Cause. +Return a caller with SourceLocallyVerifiedToken only after all checks. +Verify neither installs context nor writes HTTP; +the existing selector handles 401/503 and composition conflicts. ## C5. Cache freshness, rotation and outage behavior -Keep an immutable key snapshot per verifier, replaced atomically after a fully -validated successful response. Cache keys only: no token/caller/result cache and -no unbounded per-kid negative-cache map. No union with keys omitted by a new set. +Keep an immutable key snapshot per verifier, replaced atomically after a fully validated successful response. +Cache keys only: no token/caller/result cache and no unbounded per-kid negative-cache map. +No union with keys omitted by a new set. | State | Recommended behavior | | --- | --- | @@ -280,136 +277,119 @@ no unbounded per-kid negative-cache map. No union with keys omitted by a new set | Known key but bad signature | 401; no refresh triggered by signature failure | | Refresh fails after snapshot expires | 503; do not use expired keys | -TTL is measured from completion of a successful validated fetch. Default 15 -minutes bounds how long removed keys can remain trusted without another fetch. -On-demand expiry refresh satisfies periodic freshness without a Lambda-hostile -background timer. Do not serve expired keys during outages initially. This trades -availability for a finite key-trust interval; accepting expired keys would need -a separate explicit policy. Offline verification still is not token revocation. +TTL is measured from completion of a successful validated fetch. +Default 15 minutes bounds how long removed keys can remain trusted without another fetch. +On-demand expiry refresh satisfies periodic freshness without a Lambda-hostile background timer. +Do not serve expired keys during outages initially. +This trades availability for a finite key-trust interval; +accepting expired keys would need a separate explicit policy. +Offline verification still is not token revocation. Separate three clocks: 1. Snapshot TTL: whether an existing key is usable. -2. Unknown-key cooldown: no such refresh within 30 seconds of a successful fetch - or another unknown-key refresh attempt. It is per verifier, not per kid. -3. Failure backoff: after failed fetches, wait 5, 10, 20, 40, then 60 seconds, - capped at 60, measured from completion; a success resets the sequence. Apply - this to cold/expired caches too, fixing the old Beakley cold-cache fetch storm. - -When both gates apply, honor the later time. Waiters join an existing fetch -before considering gates; do not launch a queued retry inside one Verify call. -Other requests using still-fresh known keys do not wait on that fetch. Refresh -publication precedes waking waiters, and waiters check the new snapshot and their -own token times. Retain the latest failed-fetch state for missing-key error -classification until a successful fetch replaces it. - -A genuinely new key can be rejected during the 30-second successful-fetch -cooldown. A same-kid replacement can wait until TTL expiry because invalid -signatures do not trigger refresh. These are deliberate bounded rotation -tradeoffs; AWS's documented unknown-kid rotation path is supported. These limits -apply per verifier/process, not across a Lambda fleet; do not claim fleet-wide -rate limiting or an AWS key-retention guarantee. +2. Unknown-key cooldown: no such refresh within 30 seconds of a successful fetch or another unknown-key refresh attempt. + It is per verifier, not per kid. +3. Failure backoff: after failed fetches, wait 5, 10, 20, 40, then 60 seconds, capped at 60, measured from completion; + a success resets the sequence. + Apply this to cold/expired caches too, fixing the old Beakley cold-cache fetch storm. + +When both gates apply, honor the later time. +Waiters join an existing fetch before considering gates; +do not launch a queued retry inside one Verify call. +Other requests using still-fresh known keys do not wait on that fetch. +Refresh publication precedes waking waiters, and waiters check the new snapshot and their own token times. +Retain the latest failed-fetch state for missing-key error classification until a successful fetch replaces it. + +A genuinely new key can be rejected during the 30-second successful-fetch cooldown. +A same-kid replacement can wait until TTL expiry because invalid signatures do not trigger refresh. +These are deliberate bounded rotation tradeoffs; +AWS's documented unknown-kid rotation path is supported. +These limits apply per verifier/process, not across a Lambda fleet; +do not claim fleet-wide rate limiting or an AWS key-retention guarantee. ## C6. Network and cancellation ownership -Use an owned http.Client around the supplied concurrent-safe RoundTripper, with -normal default TLS behavior, no cookie jar, redirects or application retries. -Accept HTTP 200 only; bound and close bodies on every path. One GET targets the -configured endpoint; never send credentials/tokens or forward request headers. -Keep cache lifetime an explicit key-trust policy, not a general HTTP cache: -do not extend it from Cache-Control, stale responses, 304 or failed requests. +Use an owned http.Client around the supplied concurrent-safe RoundTripper, with normal default TLS behavior, no cookie jar, redirects or application retries. +Accept HTTP 200 only; +bound and close bodies on every path. +One GET targets the configured endpoint; +never send credentials/tokens or forward request headers. +Keep cache lifetime an explicit key-trust policy, not a general HTTP cache: do not extend it from Cache-Control, stale responses, 304 or failed requests. No initial ETag/conditional-request implementation. -At most one fetch is in flight per verifier. It has its own background context -and configured timeout, with no caller context values. Each waiter can cancel -independently and immediately return ctx.Err. A canceled waiter must not abort -other waiters' fetch. Recommend letting the shared fetch finish within its bound -even if all waiters leave; it can populate the cache for subsequent requests. -This avoids one initiating request owning everyone's availability and bounds -remaining work without a permanent goroutine or reference-counted cancellation -protocol. Lambda can freeze that work; it is not a durable background job. - -Custom transports must honor context cancellation; arbitrary user code cannot -be forcibly stopped by this library. Do not spawn replacement fetches around a -transport still running after its deadline. Reject late successful publication -after the fetch deadline and return caller cancellation ahead of other outcomes. +At most one fetch is in flight per verifier. +It has its own background context and configured timeout, with no caller context values. +Each waiter can cancel independently and immediately return ctx.Err. +A canceled waiter must not abort other waiters' fetch. +Recommend letting the shared fetch finish within its bound even if all waiters leave; +it can populate the cache for subsequent requests. +This avoids one initiating request owning everyone's availability and bounds remaining work without a permanent goroutine or reference-counted cancellation protocol. +Lambda can freeze that work; +it is not a durable background job. + +Custom transports must honor context cancellation; +arbitrary user code cannot be forcibly stopped by this library. +Do not spawn replacement fetches around a transport still running after its deadline. +Reject late successful publication after the fetch deadline and return caller cancellation ahead of other outcomes. The verifier owns neither shutdown nor mutation of a supplied transport. ## C7. Offline identity and downstream policy -Document that this verifies signatures and configured token claims, not current -user enablement, sign-out, Cognito revocation or application permissions. Do not -add GetUser/introspection calls implicitly: that would change latency, scope and -M2M compatibility. Future revocation integration is a separate explicit boundary. +Document that this verifies signatures and configured token claims, not current user enablement, sign-out, Cognito revocation or application permissions. +Do not add GetUser/introspection calls implicitly: that would change latency, scope and M2M compatibility. +Future revocation integration is a separate explicit boundary. Authentication occurs before action authorization and before streaming commits. -The caller remains an invocation snapshot; no automatic token renewal or -mid-stream reauthentication. Preserve the approved separation of scopes, groups -and application-resolved grants. Never reconcile claims from an unverified token -with an independently asserted gateway identity. +The caller remains an invocation snapshot; +no automatic token renewal or mid-stream reauthentication. +Preserve the approved separation of scopes, groups and application-resolved grants. +Never reconcile claims from an unverified token with an independently asserted gateway identity. ## Implementation and acceptance after approval -1. Pure config, compact parsing, exact time/profile validation and JSON v2 JWKS - validation. Add independent RFC RS256 known-answer coverage for the signature - primitive, plus signed Cognito-shaped synthetic fixtures for the full profile. -2. Cache state machine and bounded network ownership. Deterministic tests must - cover cold concurrent success/failure, expiry, rotation/removal, unknown-kid - churn, cooldown/backoff intersections, canceled initiator/other waiters/all - waiters, late transport success and absence of indefinite stale use. -3. Connect Verify/Warm and sanitized categories. Test algorithm confusion, - duplicate/invalid JSON, base64 variants, token-controlled URLs, time bounds, - ambiguous/malformed JWKS, bad signatures without refetch, client/audience - combinations, ID-token rejection and M2M-shaped identities. -4. Replace the illustrative synthetic Bearer verification in integration tests - with actual cryptographic verification against local JWKS fixtures. Retain - real IAM helper/local STS composition and native/raw/typed Gateway coverage. -5. Add runnable configuration examples for resource-bound users and explicitly - unbound M2M clients. Explain revocation, transport ownership, rotation delay, - cache outage behavior, issuer migration and required action authorization. -6. Run race/vet/Linux builds and focused token/JWKS fuzzing. Use Go 1.27 in-memory - HTTP and synctest where appropriate. No live AWS resources or tokens required. +1. Pure config, compact parsing, exact time/profile validation and JSON v2 JWKS validation. + Add independent RFC RS256 known-answer coverage for the signature primitive, plus signed Cognito-shaped synthetic fixtures for the full profile. +2. Cache state machine and bounded network ownership. + Deterministic tests must cover cold concurrent success/failure, expiry, rotation/removal, unknown-kid churn, cooldown/backoff intersections, canceled initiator/other waiters/all waiters, late transport success and absence of indefinite stale use. +3. Connect Verify/Warm and sanitized categories. + Test algorithm confusion, duplicate/invalid JSON, base64 variants, token-controlled URLs, time bounds, ambiguous/malformed JWKS, bad signatures without refetch, client/audience combinations, ID-token rejection and M2M-shaped identities. +4. Replace the illustrative synthetic Bearer verification in integration tests with actual cryptographic verification against local JWKS fixtures. + Retain real IAM helper/local STS composition and native/raw/typed Gateway coverage. +5. Add runnable configuration examples for resource-bound users and explicitly unbound M2M clients. + Explain revocation, transport ownership, rotation delay, cache outage behavior, issuer migration and required action authorization. +6. Run race/vet/Linux builds and focused token/JWKS fuzzing. + Use Go 1.27 in-memory HTTP and synctest where appropriate. + No live AWS resources or tokens required. Claims about deployed interoperability remain qualified until separately tested. -The initial design review changed no runtime behavior. Implementation evidence -following approval is recorded below; module dependencies remain unchanged. +The initial design review changed no runtime behavior. +Implementation evidence following approval is recorded below; +module dependencies remain unchanged. ## Resolution -The user approved C1-C7 on 2026-09-16, including the narrow stdlib/JSON v2 RS256 -implementation, explicit per-client unbound allowance, strict profile limits, -15-minute cache with no expired-key fallback, and shared fetch cancellation -ownership. Implement and validate within these boundaries. +The user approved C1-C7 on 2026-09-16, including the narrow stdlib/JSON v2 RS256 implementation, explicit per-client unbound allowance, strict profile limits, 15-minute cache with no expired-key fallback, and shared fetch cancellation ownership. +Implement and validate within these boundaries. ## Implementation evidence -authn now implements C1-C7 through NewCognitoVerifier, Verify and Warm, using -stdlib cryptography and direct JSON v2 with no new module dependency. Config is -validated without I/O and client policies are copied. Verification preserves -exact claim numbers, requires the reviewed token profile and returns only a -locally verified caller. Token time and the selected snapshot's key expiry are -checked before returning identity, including after signature/identity work. - -The cache owns one bounded shared fetch, atomic complete snapshots, successful -refresh cooldown and cold/expired failure backoff. Cancellation remains private -to each waiter. A transport that ignores cancellation cannot cause replacement -fetch accumulation or publish late success. Completion versus context-cleanup -signals prefer the already published result, avoiding spurious unavailability. - -Tests include the independent RFC 7515 A.2 RSA vector, signed synthetic claims, -client/audience combinations, malformed/ambiguous JSON and keys, exact header, -nesting, response and key-count bounds, integer dates, signature tampering, -removal/rotation/outages, 64 simultaneous cold waiters, exponential backoff, -known-key availability during refresh, independent/all-waiter cancellation, -body deadlines and a deliberately noncompliant late transport. Actual local -TLS transport is covered. Native in-memory HTTP plus all raw/typed Gateway -integration fixtures now use genuine RS256 verification against local JWKS, -alongside the existing IAM helper/local STS path. - -Full module race tests, vet, formatting checks and Linux arm64/amd64 builds pass -on Go 1.27.1. Final fixed-fixture fuzz runs completed 136,129 compact-token cases -and 280,903 JWKS cases without failures. The synthetic public-key/token fixture -contains no private key and gives every fuzz worker identical input. The -[consumer guide](../cognito.md) and runnable configuration example document -binding, limits, cache/cancellation ownership, revocation and deployment scope. +authn now implements C1-C7 through NewCognitoVerifier, Verify and Warm, using stdlib cryptography and direct JSON v2 with no new module dependency. +Config is validated without I/O and client policies are copied. +Verification preserves exact claim numbers, requires the reviewed token profile and returns only a locally verified caller. +Token time and the selected snapshot's key expiry are checked before returning identity, including after signature/identity work. + +The cache owns one bounded shared fetch, atomic complete snapshots, successful refresh cooldown and cold/expired failure backoff. +Cancellation remains private to each waiter. +A transport that ignores cancellation cannot cause replacement fetch accumulation or publish late success. +Completion versus context-cleanup signals prefer the already published result, avoiding spurious unavailability. + +Tests include the independent RFC 7515 A.2 RSA vector, signed synthetic claims, client/audience combinations, malformed/ambiguous JSON and keys, exact header, nesting, response and key-count bounds, integer dates, signature tampering, removal/rotation/outages, 64 simultaneous cold waiters, exponential backoff, known-key availability during refresh, independent/all-waiter cancellation, body deadlines and a deliberately noncompliant late transport. +Actual local TLS transport is covered. +Native in-memory HTTP plus all raw/typed Gateway integration fixtures now use genuine RS256 verification against local JWKS, alongside the existing IAM helper/local STS path. + +Full module race tests, vet, formatting checks and Linux arm64/amd64 builds pass on Go 1.27.1. +Final fixed-fixture fuzz runs completed 136,129 compact-token cases and 280,903 JWKS cases without failures. +The synthetic public-key/token fixture contains no private key and gives every fuzz worker identical input. +The [consumer guide](../cognito.md) and runnable configuration example document binding, limits, cache/cancellation ownership, revocation and deployment scope. No live Cognito, STS, AWS deployment or GitHub CI run was performed. diff --git a/docs/decisions/0021-authorization-rules.md b/docs/decisions/0021-authorization-rules.md index 0c91aff..b5e2355 100644 --- a/docs/decisions/0021-authorization-rules.md +++ b/docs/decisions/0021-authorization-rules.md @@ -1,53 +1,43 @@ # 0021: Application authorization rules and dispatcher integration -Status: accepted; rules, examples and local validation implemented. +Status: accepted; +rules, examples and local validation implemented. The user approved Z1–Z7 on 2026-09-17. Reviewed on 2026-09-16 against the implemented identity/authn/edge boundaries. ## Evidence and scope -Decisions 0012/0014 preserve immutable caller facts and distinguish missing or -uninterpretable collections. Decisions 0019/0020 establish one authenticated -caller before application dispatch. `action.go` already selects a header value -without owning a dispatcher. `authn/gateway_test.go` currently performs explicit -consumer-owned authorization for both IAM and JWT callers. This review replaces -that ad hoc rule logic with reusable predicates, not an application framework. +Decisions 0012/0014 preserve immutable caller facts and distinguish missing or uninterpretable collections. +Decisions 0019/0020 establish one authenticated caller before application dispatch. +`action.go` already selects a header value without owning a dispatcher. +`authn/gateway_test.go` currently performs explicit consumer-owned authorization for both IAM and JWT callers. +This review replaces that ad hoc rule logic with reusable predicates, not an application framework. Official AWS sources checked through the public AWS MCP documentation tools: -- [GetCallerIdentity](https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sts-2011-06-15.html): - no permission to perform that operation is required; even an explicit deny - does not prevent obtaining the identity information. Our STS proof cannot - establish permission to invoke an application action or an execute-api route. -- [Cognito resource servers/scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html): - the resource server checks scopes for the protected operation. Scope strings - identify permissions by application policy, not by a built-in edge action map. -- [Cognito groups](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html): - group membership and IAM-role assignment/selection are distinct mechanisms. +- [GetCallerIdentity](https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sts-2011-06-15.html): no permission to perform that operation is required; + even an explicit deny does not prevent obtaining the identity information. + Our STS proof cannot establish permission to invoke an application action or an execute-api route. +- [Cognito resource servers/scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html): the resource server checks scopes for the protected operation. + Scope strings identify permissions by application policy, not by a built-in edge action map. +- [Cognito groups](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html): group membership and IAM-role assignment/selection are distinct mechanisms. A cognito:groups value is not itself an IAM permission or an application grant. -- [IAM role principals](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html): - IAM can bind a role ARN in a policy to its unique principal ID, so deleting and - recreating the role changes that trust relationship. Plain ARN/name comparisons - in our library do not provide that protection or IAM policy evaluation. -- [Role paths](https://aws.amazon.com/blogs/security/how-to-enforce-creation-of-roles-in-a-specific-path-use-iam-role-naming-in-hierarchy-models/): - role names are unique within an account even across paths. Paths remain part - of IAM role ARNs; do not invent one from an STS session ARN. -- [AssumedRoleUser](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumedRoleUser.html) - and [AssumeRole](https://docs.aws.amazon.com/cli/latest/reference/sts/assume-role.html): - session identity includes the session name; it is not proof of the human or - workload that originally obtained the role session. Existing identity validation - already distinguishes the supported IAM principal forms. - -[RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html#section-3) distinguishes -authentication failure from insufficient privilege and specifies Bearer -challenges. Error/HTTP ownership must retain that distinction. Official installed -[Go context documentation](https://pkg.go.dev/context#Context), the previously -loaded Go rules and Go 1.27 notes support explicit context-aware callbacks, -consumer-owned interfaces and direct JSON v2 in response examples. +- [IAM role principals](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html): IAM can bind a role ARN in a policy to its unique principal ID, so deleting and recreating the role changes that trust relationship. + Plain ARN/name comparisons in our library do not provide that protection or IAM policy evaluation. +- [Role paths](https://aws.amazon.com/blogs/security/how-to-enforce-creation-of-roles-in-a-specific-path-use-iam-role-naming-in-hierarchy-models/): role names are unique within an account even across paths. + Paths remain part of IAM role ARNs; + do not invent one from an STS session ARN. +- [AssumedRoleUser](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumedRoleUser.html) and [AssumeRole](https://docs.aws.amazon.com/cli/latest/reference/sts/assume-role.html): session identity includes the session name; + it is not proof of the human or workload that originally obtained the role session. + Existing identity validation already distinguishes the supported IAM principal forms. + +[RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html#section-3) distinguishes authentication failure from insufficient privilege and specifies Bearer challenges. +Error/HTTP ownership must retain that distinction. +Official installed [Go context documentation](https://pkg.go.dev/context#Context), the previously loaded Go rules and Go 1.27 notes support explicit context-aware callbacks, consumer-owned interfaces and direct JSON v2 in response examples. The old Beakley volume is not mounted at its recorded path in this session. -This review uses its persisted findings (especially dual-arm identity and glob -matching) plus our current code; no old source or tests were copied. +This review uses its persisted findings (especially dual-arm identity and glob matching) plus our current code; +no old source or tests were copied. ## Design graph @@ -67,9 +57,8 @@ flowchart TD Execute --> Commit[Response or stream commitment] ``` -No rule reads an action header, changes identity, dispatches a handler or retries -authentication. Validation/selection order can vary, but the selected action, -target and authorization result must govern the operation actually executed. +No rule reads an action header, changes identity, dispatches a handler or retries authentication. +Validation/selection order can vary, but the selected action, target and authorization result must govern the operation actually executed. ## Z1. Small concrete rules with explicit request facts @@ -91,24 +80,26 @@ func Any(rules ...Rule) (Rule, error) func (r Rule) Authorize(ctx context.Context, request Request) error ``` -Authorize returns nil only for an explicit allow. Request is passed by value; -Caller is already immutable, and Action/Resource are exact strings. Action must -be nonempty UTF-8, without whitespace trimming or case normalization by authz. -Resource is optional UTF-8: an empty value means the policy does not require a -resource identifier. A resource-aware custom check must reject an absent target. +Authorize returns nil only for an explicit allow. +Request is passed by value; +Caller is already immutable, and Action/Resource are exact strings. +Action must be nonempty UTF-8, without whitespace trimming or case normalization by authz. +Resource is optional UTF-8: an empty value means the policy does not require a resource identifier. +A resource-aware custom check must reject an absent target. Resource is an application identifier, not automatically an ARN, path or URL. -Consumers populate these values after their own parsing/canonicalization. The -core does not read identity from context as a competing source: pass the already -established caller explicitly. It neither replaces caller context nor caches a -permission result. Construct rules once; copies share private immutable nodes. +Consumers populate these values after their own parsing/canonicalization. +The core does not read identity from context as a competing source: pass the already established caller explicitly. +It neither replaces caller context nor caches a permission result. +Construct rules once; +copies share private immutable nodes. Custom callbacks must be concurrency-safe when rules are shared. -Reasoning: action dispatch is a primary consumer use case; explicit action and -target avoid hiding authorization parameters in context values. A generic facts -map, request body, http.Request dependency or exported mutable policy tree would -add ownership and interpretation problems. Consumers can define an Authorize -interface where needed; the producer need not export a redundant interface. +Reasoning: action dispatch is a primary consumer use case; +explicit action and target avoid hiding authorization parameters in context values. +A generic facts map, request body, http.Request dependency or exported mutable policy tree would add ownership and interpretation problems. +Consumers can define an Authorize interface where needed; +the producer need not export a redundant interface. ## Z2. Positive predicates with exact, issuer-qualified comparisons @@ -124,66 +115,68 @@ func IAMPrincipal(principalARN string) (Rule, error) func IAMRoleSessions(partition, accountID, roleName string) (Rule, error) ``` -JWTScopes and CognitoGroups require **all** supplied values. Use Any of separate -rules when one alternative suffices. Constructors require nonempty identifiers, -valid UTF-8 and at least one required value; scopes use the existing OAuth -scope-token grammar. Duplicate set values may be deduplicated without changing -spelling. Empty or unknown source lists and SourceNone are invalid configuration. +JWTScopes and CognitoGroups require **all** supplied values. +Use Any of separate rules when one alternative suffices. +Constructors require nonempty identifiers, valid UTF-8 and at least one required value; +scopes use the existing OAuth scope-token grammar. +Duplicate set values may be deduplicated without changing spelling. +Empty or unknown source lists and SourceNone are invalid configuration. Sources permits any one explicitly supplied nonanonymous source. -Wrong caller kind, missing facts and unavailable/flattened collections do not -match. A missing collection is not a network outage and does not become an empty -set that can satisfy a negated rule. Never split flattened Cognito groups or -parse them as embedded JSON. A literal scope such as `orders/*` is an exact scope -name; it does not authorize other scope strings. Matching is case-sensitive. - -Always qualify JWT subject/client/scope/group rules with exact issuer. Client -matching does not assert a machine grant; subject matching does not establish a -human identity. Scopes, groups and resolved application grants never substitute -for each other. These rules use established facts; they do not independently -enforce token_use, signature, lifetime, revocation or audience. Those remain -the configured authentication producer's responsibility, including on gateway -routes. Combine rules when the application requires additional claim restrictions. +Wrong caller kind, missing facts and unavailable/flattened collections do not match. +A missing collection is not a network outage and does not become an empty set that can satisfy a negated rule. +Never split flattened Cognito groups or parse them as embedded JSON. +A literal scope such as `orders/*` is an exact scope name; +it does not authorize other scope strings. +Matching is case-sensitive. + +Always qualify JWT subject/client/scope/group rules with exact issuer. +Client matching does not assert a machine grant; +subject matching does not establish a human identity. +Scopes, groups and resolved application grants never substitute for each other. +These rules use established facts; +they do not independently enforce token_use, signature, lifetime, revocation or audience. +Those remain the configured authentication producer's responsibility, including on gateway routes. +Combine rules when the application requires additional claim restrictions. By default the predicates accept any supported producer of the matching kind. -Applications may put Sources in an outer All to restrict provenance. Source is -trusted-code attribution, not an unforgeable credential. Do not silently exclude -gateway/custom producers that the existing identity contract supports. -Sources alone is a broad allow for callers from those producers; examples must -combine it with the actual permission rule rather than present it as sufficient -application authorization. - -Alternative: bare scope/group/subject matching is shorter but permits accidental -collisions across issuers. Separate AnyScopes/AllScopes variants multiply the API; +Applications may put Sources in an outer All to restrict provenance. +Source is trusted-code attribution, not an unforgeable credential. +Do not silently exclude gateway/custom producers that the existing identity contract supports. +Sources alone is a broad allow for callers from those producers; +examples must combine it with the actual permission rule rather than present it as sufficient application authorization. + +Alternative: bare scope/group/subject matching is shorter but permits accidental collisions across issuers. +Separate AnyScopes/AllScopes variants multiply the API; the two general combinators provide the intended distinction. ## Z3. IAM exact principals and explicit role-session families -IAMPrincipal accepts the same supported caller ARN forms as identity.NewIAM and -compares the entire ARN literally. In particular: +IAMPrincipal accepts the same supported caller ARN forms as identity.NewIAM and compares the entire ARN literally. +In particular: - A root ARN matches that root caller only, not every principal in the account. - A user path's literal `*` or `?` remains literal, following decision 0015. - An assumed-role ARN matches the full session name, not all sessions of a role. - Bare IAM role ARNs are not request-caller ARNs and are invalid here. -IAMRoleSessions is the explicit broader operation. Require exact partition, -twelve-digit account and a valid role name. Match only an already validated -assumed-role caller with those components and any valid session name. Reject -wildcard configuration and role paths; do not replace `sts` with `iam` or return -a reconstructed role ARN. The authenticated caller retains its complete ARN. +IAMRoleSessions is the explicit broader operation. +Require exact partition, twelve-digit account and a valid role name. +Match only an already validated assumed-role caller with those components and any valid session name. +Reject wildcard configuration and role paths; +do not replace `sts` with `iam` or return a reconstructed role ARN. +The authenticated caller retains its complete ARN. -This is a **role-name policy**. Recreating a role with the same name/account can -match again. It does not bind a role's unique ID, identify the original human, -evaluate a role's permission boundary/session policy, or prove execute-api -permission. Applications needing lifecycle-sensitive identity must resolve and -compare their explicitly enrolled stable identifiers. PrincipalID remains an -optional opaque fact; do not introduce automatic parsing into identity here. +This is a **role-name policy**. +Recreating a role with the same name/account can match again. +It does not bind a role's unique ID, identify the original human, evaluate a role's permission boundary/session policy, or prove execute-api permission. +Applications needing lifecycle-sensitive identity must resolve and compare their explicitly enrolled stable identifiers. +PrincipalID remains an optional opaque fact; +do not introduce automatic parsing into identity here. -No path.Match, regex, ARN glob engine, IAM policy parser, account-wide allow -helper or inferred administrator/root override initially. A custom check can -express additional application policy explicitly. This avoids reproducing the -old separator/wildcard ambiguity while supporting practical varying sessions. +No path.Match, regex, ARN glob engine, IAM policy parser, account-wide allow helper or inferred administrator/root override initially. +A custom check can express additional application policy explicitly. +This avoids reproducing the old separator/wildcard ambiguity while supporting practical varying sessions. ## Z4. Ordered combinators, with errors stopping evaluation @@ -194,105 +187,98 @@ Recommend ordinary left-to-right short circuit evaluation: | All | Stop: no match | Continue; match after last child | Stop: failure | | Any | Continue; no match after last child | Stop: match | Stop: failure | -An observed dependency failure never becomes a nonmatch followed by trying -another permission path. Discard a true result accompanied by an error. Require -nonempty combinators and valid child rules at construction; never allow the -vacuous truth of an empty All. A zero Rule is unconfigured and cannot allow. +An observed dependency failure never becomes a nonmatch followed by trying another permission path. +Discard a true result accompanied by an error. +Require nonempty combinators and valid child rules at construction; +never allow the vacuous truth of an empty All. +A zero Rule is unconfigured and cannot allow. This is ordered evaluation: reordering may change work and error outcomes. For example, Any(allow, unavailable) allows without calling its second child; -Any(unavailable, allow) fails. This is intentional short circuit behavior, not -a claim that all possible branch errors have been evaluated. Keep mandatory -tenant, suspension or revocation checks outside alternative allow branches: +Any(unavailable, allow) fails. +This is intentional short circuit behavior, not a claim that all possible branch errors have been evaluated. +Keep mandatory tenant, suspension or revocation checks outside alternative allow branches: `All(mandatoryApplicationGuard, Any(jwtPermissionPath, iamPermissionPath))` -Each alternative should start with cheap applicable identity restrictions before -calling a provider. No parallel evaluation, retries, hidden caching, or promises -that callbacks execute for audit purposes. Reusing a check in multiple evaluated -positions can call it more than once; callbacks must tolerate this. +Each alternative should start with cheap applicable identity restrictions before calling a provider. +No parallel evaluation, retries, hidden caching, or promises that callbacks execute for audit purposes. +Reusing a check in multiple evaluated positions can call it more than once; +callbacks must tolerate this. -Alternative: continue after an error to seek an independent allow improves some -outage cases but can authorize after a failed application-policy dependency. -Evaluating every branch to give errors absolute precedence loses short-circuit -isolation and can turn unrelated services into mandatory dependencies. Prefer -the ordered contract and explicit policy composition initially. +Alternative: continue after an error to seek an independent allow improves some outage cases but can authorize after a failed application-policy dependency. +Evaluating every branch to give errors absolute precedence loses short-circuit isolation and can turn unrelated services into mandatory dependencies. +Prefer the ordered contract and explicit policy composition initially. -Do not add Not/None initially. Without a richer missing-fact model, negating a -failed match could authorize callers whose scopes/groups are unavailable. -Public/anonymous routes intentionally bypass authorization; no AllowAll escape -hatch is needed in the initial protected-route API. +Do not add Not/None initially. +Without a richer missing-fact model, negating a failed match could authorize callers whose scopes/groups are unavailable. +Public/anonymous routes intentionally bypass authorization; +no AllowAll escape hatch is needed in the initial protected-route API. ## Z5. Sanitized outcomes and bounded evaluation -Recommend sentinels ErrInvalidConfiguration, ErrInvalidRequest, -ErrUnauthenticated, ErrDenied and ErrUnavailable, inspected with errors.Is. -No public mutable Decision struct with an allow bit that can disagree with an -error, and no raw callback error chain or claims in diagnostics. - -Authorize checks configuration/non-nil context, then caller cancellation, -request string validity, and a nonanonymous caller before invoking any check. -Anonymous requests always return ErrUnauthenticated; even a permissive custom -callback cannot authorize them. A zero rule or nil check fails configuration -validation. A valid rule with a final nonmatch returns ErrDenied. - -Custom checks use false,nil for ordinary denial. **Any nonnil callback error is -sanitized to ErrUnavailable**, including an internal context error while the -request context remains live. Callback-returned package sentinels do not bypass -that contract. Recheck caller cancellation between checks and before returning; -ctx.Err wins over results and provider diagnostics, never context.Cause. Panics -from trusted callbacks propagate; do not disguise programmer bugs as denial. - -Checks run synchronously with the caller's context. Consumers own provider -timeouts and must honor cancellation; the library cannot preempt arbitrary Go -code. No goroutine per check and no callback invocation at construction. - -Recommend maximum policy depth 32 and maximum 1,024 expanded node visits per -constructed rule. Count reused subtrees at every occurrence using checked -arithmetic. These are policy-configuration work bounds, not AWS limits or limits -on callback internals. Constructors copy supplied slices, reject oversized trees -and permit immutable sharing. No global policy registry or automatic logging. +Recommend sentinels ErrInvalidConfiguration, ErrInvalidRequest, ErrUnauthenticated, ErrDenied and ErrUnavailable, inspected with errors.Is. +No public mutable Decision struct with an allow bit that can disagree with an error, and no raw callback error chain or claims in diagnostics. + +Authorize checks configuration/non-nil context, then caller cancellation, request string validity, and a nonanonymous caller before invoking any check. +Anonymous requests always return ErrUnauthenticated; +even a permissive custom callback cannot authorize them. +A zero rule or nil check fails configuration validation. +A valid rule with a final nonmatch returns ErrDenied. + +Custom checks use false,nil for ordinary denial. +**Any nonnil callback error is sanitized to ErrUnavailable**, including an internal context error while the request context remains live. +Callback-returned package sentinels do not bypass that contract. +Recheck caller cancellation between checks and before returning; +ctx.Err wins over results and provider diagnostics, never context.Cause. +Panics from trusted callbacks propagate; +do not disguise programmer bugs as denial. + +Checks run synchronously with the caller's context. +Consumers own provider timeouts and must honor cancellation; +the library cannot preempt arbitrary Go code. +No goroutine per check and no callback invocation at construction. + +Recommend maximum policy depth 32 and maximum 1,024 expanded node visits per constructed rule. +Count reused subtrees at every occurrence using checked arithmetic. +These are policy-configuration work bounds, not AWS limits or limits on callback internals. +Constructors copy supplied slices, reject oversized trees and permit immutable sharing. +No global policy registry or automatic logging. ## Z6. Application principal/grant resolution as a consumer-owned check -Recommend supporting resolution immediately through CheckFunc, while deferring -a universal exported Principal/Grant/Resolver model until a concrete application -schema is reviewed. This is an explicit narrowing of the first authz milestone, -not an implicit decision that token groups are sufficient application grants. - -The consumer's check receives caller, action and resource. It may resolve an -application principal, tenant membership and resource-scoped grants in one -context-aware service call. It returns false,nil for a known unregistered, -suspended or unauthorized principal, and an error when the decision cannot be -made. The server chooses which grant service is authoritative. Never source -these grants from caller-supplied headers or overwrite authenticated claims. - -Use issuer plus subject for subject identity, issuer plus client_id for an -explicitly supported client identity, and an intentionally chosen IAM enrollment -key. Do not fall back from a missing subject to treating client_id as a user; -do not infer human/M2M status from subject presence. Account/session names and -email addresses are not automatic global application-principal identifiers. - -Keep one logical grant lookup inside one check. The core does not guarantee -deduplication across checks or install a second context principal. If execution -needs the resolved object, the application can resolve it once before invoking -its policy and retain that immutable object in its own request execution plan. -An exported resolver/cache abstraction, grant wildcards and logging hooks need -a later reviewed consumer requirement, rather than speculative fields now. +Recommend supporting resolution immediately through CheckFunc, while deferring a universal exported Principal/Grant/Resolver model until a concrete application schema is reviewed. +This is an explicit narrowing of the first authz milestone, not an implicit decision that token groups are sufficient application grants. + +The consumer's check receives caller, action and resource. +It may resolve an application principal, tenant membership and resource-scoped grants in one context-aware service call. +It returns false,nil for a known unregistered, suspended or unauthorized principal, and an error when the decision cannot be made. +The server chooses which grant service is authoritative. +Never source these grants from caller-supplied headers or overwrite authenticated claims. + +Use issuer plus subject for subject identity, issuer plus client_id for an explicitly supported client identity, and an intentionally chosen IAM enrollment key. +Do not fall back from a missing subject to treating client_id as a user; +do not infer human/M2M status from subject presence. +Account/session names and email addresses are not automatic global application-principal identifiers. + +Keep one logical grant lookup inside one check. +The core does not guarantee deduplication across checks or install a second context principal. +If execution needs the resolved object, the application can resolve it once before invoking its policy and retain that immutable object in its own request execution plan. +An exported resolver/cache abstraction, grant wildcards and logging hooks need a later reviewed consumer requirement, rather than speculative fields now. ## Z7. HTTP ownership and action/resource consistency -Recommend no authz.Handler factory in the first cut. Provide a runnable ordinary -HTTP dispatcher example using existing authn middleware and ActionHeader. The -same registry entry owns its Rule and handler; select it once, authorize its -action/target, then execute that entry. Unknown actions never fall through to an -unprotected default. The consumer chooses its 400/404 response policy. +Recommend no authz.Handler factory in the first cut. +Provide a runnable ordinary HTTP dispatcher example using existing authn middleware and ActionHeader. +The same registry entry owns its Rule and handler; +select it once, authorize its action/target, then execute that entry. +Unknown actions never fall through to an unprotected default. +The consumer chooses its 400/404 response policy. -This makes custom headers first-class through net/http without an additional -header container, mutable context action or second action-parser callback. -Do not bind checks to rereadable raw headers. Object-level authorization must -use the same target object/version as execution; transactional consistency for -mutable application data remains the application's responsibility. +This makes custom headers first-class through net/http without an additional header container, mutable context action or second action-parser callback. +Do not bind checks to rereadable raw headers. +Object-level authorization must use the same target object/version as execution; +transactional consistency for mutable application data remains the application's responsibility. HTTP examples should distinguish: @@ -304,64 +290,53 @@ HTTP examples should distinguish: | Configuration/invalid authorization request | 500; parse bad client input earlier into an appropriate 4xx | | Caller cancellation | Do not execute; best-effort 503, subject to transport cancellation | -For Bearer-protected resources, include the applicable Bearer challenge on -denial as required by RFC 6750. Use a generic realm challenge when the denial -reason is not exposed; insufficient_scope and a scope hint are only appropriate -when the application can accurately describe that requirement. Do not label -every IAM/group/tenant denial as insufficient_scope or invent EdgeIAM OAuth -parameters. The core does not guess an authentication scheme from Caller.Kind. +For Bearer-protected resources, include the applicable Bearer challenge on denial as required by RFC 6750. +Use a generic realm challenge when the denial reason is not exposed; +insufficient_scope and a scope hint are only appropriate when the application can accurately describe that requirement. +Do not label every IAM/group/tenant denial as insufficient_scope or invent EdgeIAM OAuth parameters. +The core does not guess an authentication scheme from Caller.Kind. Examples know their configured route authentication and own header formatting. -Preserve CORS headers, avoid credentials/identifiers in responses, and use direct -JSON v2 for JSON examples. An error response does not execute an action. Finish -authorization before any response commitment or streaming flush. This proposal -does not add mid-stream reauthorization or a framework-owned audit stream. +Preserve CORS headers, avoid credentials/identifiers in responses, and use direct JSON v2 for JSON examples. +An error response does not execute an action. +Finish authorization before any response commitment or streaming flush. +This proposal does not add mid-stream reauthorization or a framework-owned audit stream. ## Implementation and validation -Implemented on 2026-09-17 in `authz`: explicit request facts, immutable Check, -All/Any, all seven predicate constructors, sanitized errors and policy tree -bounds. Production imports only the standard library and identity. IAM policy -configuration reuses identity.NewIAM's grammar; the role-family constructor -validates a sample session and never exposes or fabricates an IAM role ARN. - -Contract tests cover the criteria below, including concurrent reuse, copied -configuration, expanded shared-tree bounds, literal IAM paths/scopes, unavailable -gateway collections, identity-source and issuer boundaries, callback error -sanitization and cancellation. Application fixtures verify grant lookup counts, -403 versus 503, safe JSON v2 envelopes and Bearer challenges, CORS preservation, -and authorization/execution of the same action/resource despite header mutation. -Cognito signature verification and IAM-proof processing run before policy in -native HTTP plus five raw/typed Gateway paths, using synthetic JWKS/STS responses. +Implemented on 2026-09-17 in `authz`: explicit request facts, immutable Check, All/Any, all seven predicate constructors, sanitized errors and policy tree bounds. +Production imports only the standard library and identity. +IAM policy configuration reuses identity.NewIAM's grammar; +the role-family constructor validates a sample session and never exposes or fabricates an IAM role ARN. + +Contract tests cover the criteria below, including concurrent reuse, copied configuration, expanded shared-tree bounds, literal IAM paths/scopes, unavailable gateway collections, identity-source and issuer boundaries, callback error sanitization and cancellation. +Application fixtures verify grant lookup counts, 403 versus 503, safe JSON v2 envelopes and Bearer challenges, CORS preservation, and authorization/execution of the same action/resource despite header mutation. +Cognito signature verification and IAM-proof processing run before policy in native HTTP plus five raw/typed Gateway paths, using synthetic JWKS/STS responses. Cancellation during policy evaluation is also checked across those paths. Full race tests, vet, formatting and Linux arm64/amd64 builds pass on Go 1.27.1. -A 20-second policy fuzz run completed 45,257 cases without failure. The -[consumer guide](../authz.md) and [runnable examples](../../authz/example_test.go) -document HTTP ownership, resource consistency and grant resolution. No live AWS -calls, deployment or GitHub CI run occurred. Public streaming and generic -principal/grant models remain deferred as described in this decision. +A 20-second policy fuzz run completed 45,257 cases without failure. +The [consumer guide](../authz.md) and [runnable examples](../../authz/example_test.go) document HTTP ownership, resource consistency and grant resolution. +No live AWS calls, deployment or GitHub CI run occurred. +Public streaming and generic principal/grant models remain deferred as described in this decision. Completed acceptance criteria: 1. Implement core immutable rules, constructor validation and sanitized outcomes. 2. Add exact JWT/IAM/source predicates and ordered All/Any with bounded trees. -3. Test empty/zero/nil rules, anonymous/custom-check bypass attempts, source/kind - gates, both callback result/error combinations, cancellation and error order. -4. Test unavailable gateway collections, cross-issuer collisions, literal scope - punctuation, exact IAM paths/session/root, role/account/partition boundaries - and explicit role-name recreation semantics. Fuzz policy input validation. -5. Add consumer-owned grant lookup and resource authorization fixtures, including - 403 versus 503, zero provider calls for inapplicable branches, and no grants - being injected into token identity. Test the exact selected action/target. -6. Exercise IAM and cryptographically verified Cognito callers with native HTTP - plus raw/typed Gateway fixtures. Cover safe challenges and error envelopes. -7. Add the consumer guide/examples; run race/vet/Lambda builds. No live AWS calls. +3. Test empty/zero/nil rules, anonymous/custom-check bypass attempts, source/kind gates, both callback result/error combinations, cancellation and error order. +4. Test unavailable gateway collections, cross-issuer collisions, literal scope punctuation, exact IAM paths/session/root, role/account/partition boundaries and explicit role-name recreation semantics. + Fuzz policy input validation. +5. Add consumer-owned grant lookup and resource authorization fixtures, including 403 versus 503, zero provider calls for inapplicable branches, and no grants being injected into token identity. + Test the exact selected action/target. +6. Exercise IAM and cryptographically verified Cognito callers with native HTTP plus raw/typed Gateway fixtures. + Cover safe challenges and error envelopes. +7. Add the consumer guide/examples; + run race/vet/Lambda builds. + No live AWS calls. ## Resolution -The user approved Z1–Z7 on 2026-09-17. The accepted choices are explicit action/resource inputs, -exact issuer-qualified predicates, a deliberately named role-session-family -check, ordered error-stopping combinators, and consumer-owned grant resolution -and HTTP dispatch for the initial release. These contracts are now implemented -and locally validated without expanding the approved public API. +The user approved Z1–Z7 on 2026-09-17. +The accepted choices are explicit action/resource inputs, exact issuer-qualified predicates, a deliberately named role-session-family check, ordered error-stopping combinators, and consumer-owned grant resolution and HTTP dispatch for the initial release. +These contracts are now implemented and locally validated without expanding the approved public API. diff --git a/docs/decisions/0022-streaming-writer-review.md b/docs/decisions/0022-streaming-writer-review.md index ef11b27..f34ca8c 100644 --- a/docs/decisions/0022-streaming-writer-review.md +++ b/docs/decisions/0022-streaming-writer-review.md @@ -1,54 +1,46 @@ # 0022: Streaming writer completion and SDK compatibility review -Status: S1 and S2 approved by the user on 2026-09-17; implemented and locally validated. -S3 retains the existing deployment release gate. The user authorized completing -the streaming writer and approved public entry points under these refinements. +Status: S1 and S2 approved by the user on 2026-09-17; +implemented and locally validated. +S3 retains the existing deployment release gate. +The user authorized completing the streaming writer and approved public entry points under these refinements. ## Current boundaries and official evidence -Decisions 0009/0010 already approve the separate adapter, raw Handle and typed -HandleV1, one producer, bounded sniffing, multivalue JSON v2 metadata, handoff, -cleanup, Flush/FlushError and terminal-error reporting. Decision 0013 supplies -sanitized error categories; decision 0021 now supplies application authorization. +Decisions 0009/0010 already approve the separate adapter, raw Handle and typed HandleV1, one producer, bounded sniffing, multivalue JSON v2 metadata, handoff, cleanup, Flush/FlushError and terminal-error reporting. +Decision 0013 supplies sanitized error categories; +decision 0021 now supplies application authorization. -Implemented foundations are `stream.go`, `stream_encoding.go`, -`response_headers.go`, `adapter_invoke.go` and `invocation.go`. They provide the -bridge, framing, header ownership, request conversion, identity and cleanup. -The HTTP writer and approved public wiring are now implemented in -`streaming_writer.go` and `streaming_adapter.go`. No new event types, -authentication APIs or framework-owned dispatcher were needed. +Implemented foundations are `stream.go`, `stream_encoding.go`, `response_headers.go`, `adapter_invoke.go` and `invocation.go`. +They provide the bridge, framing, header ownership, request conversion, identity and cleanup. +The HTTP writer and approved public wiring are now implemented in `streaming_writer.go` and `streaming_adapter.go`. +No new event types, authentication APIs or framework-owned dispatcher were needed. Official documentation was checked through `aws-proxy-public` on 2026-09-17: -- [API Gateway streaming](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) - remains REST-only for HTTP_PROXY/AWS_PROXY integrations, with transfer mode - STREAM. Request streaming is unsupported. Gateway does not perform content - encoding for streams; compression belongs in the integration. A disconnected - client or timed-out connection need not stop Lambda execution. -- [Lambda proxy streaming format](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) - requires InvokeWithResponseStream, the streaming integration URI, JSON metadata - and eight NUL bytes followed by raw payload. Multivalue headers can carry every - extra field, including cookies. If Content-Length and Transfer-Encoding are - both absent, Gateway adds chunked transfer encoding. -- The same guide still leaves decimal/binary KB and delimiter start/end - accounting unspecified. The approved 16,000-byte complete-prefix bound remains - edge's conservative policy; there is no reason to reopen or relax it. -- [Lambda custom runtimes](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html) - still require the streaming-mode and chunked-encoding headers and closing the - underlying response connection. Midstream errors use declared runtime trailers. - -Both the Go module proxy and AWS's GitHub latest-release endpoint still report -[aws-lambda-go v1.55.0](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0), -published 2026-08-27, as latest stable. It is already required here; no dependency -update is needed. The downloaded SDK's runtime client and invoke loop were read. - -Go rules, cached official Go 1.27 notes, and installed Go 1.27.1 godoc/source were -reviewed. [ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter) permits -logical commitment before physical transmission and documents sniffing, header -suppression and buffered length inference. -[ResponseController.Flush](https://pkg.go.dev/net/http#ResponseController.Flush) -prefers FlushError over Flusher and can follow wrapper Unwrap methods. Source -comparisons used Go 1.27.1's GOROOT as reported by go env. +- [API Gateway streaming](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) remains REST-only for HTTP_PROXY/AWS_PROXY integrations, with transfer mode STREAM. + Request streaming is unsupported. + Gateway does not perform content encoding for streams; + compression belongs in the integration. + A disconnected client or timed-out connection need not stop Lambda execution. +- [Lambda proxy streaming format](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) requires InvokeWithResponseStream, the streaming integration URI, JSON metadata and eight NUL bytes followed by raw payload. + Multivalue headers can carry every extra field, including cookies. + If Content-Length and Transfer-Encoding are both absent, Gateway adds chunked transfer encoding. +- The same guide still leaves decimal/binary KB and delimiter start/end accounting unspecified. + The approved 16,000-byte complete-prefix bound remains edge's conservative policy; + there is no reason to reopen or relax it. +- [Lambda custom runtimes](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html) still require the streaming-mode and chunked-encoding headers and closing the underlying response connection. + Midstream errors use declared runtime trailers. + +Both the Go module proxy and AWS's GitHub latest-release endpoint still report [aws-lambda-go v1.55.0](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0), published 2026-08-27, as latest stable. +It is already required here; +no dependency update is needed. +The downloaded SDK's runtime client and invoke loop were read. + +Go rules, cached official Go 1.27 notes, and installed Go 1.27.1 godoc/source were reviewed. +[ResponseWriter](https://pkg.go.dev/net/http#ResponseWriter) permits logical commitment before physical transmission and documents sniffing, header suppression and buffered length inference. +[ResponseController.Flush](https://pkg.go.dev/net/http#ResponseController.Flush) prefers FlushError over Flusher and can follow wrapper Unwrap methods. +Source comparisons used Go 1.27.1's GOROOT as reported by go env. ## State graph: applying existing approvals @@ -69,196 +61,177 @@ stateDiagram-v2 ``` Committed means the application's status/header snapshot cannot change. -Published means the bridge acknowledged reader ownership, not that the SDK or -remote client consumed a byte. This handoff determines the failure path. +Published means the bridge acknowledged reader ownership, not that the SDK or remote client consumed a byte. +This handoff determines the failure path. The following apply the accepted design rather than reopen it: -1. WriteHeader freezes the response but need not publish. Empty Write commits - 200 if needed and accepts zero bytes without requiring publication or sniffing. - Later ordinary header changes are ignored; trailer attempts remain faults. -2. Retain at most 512 bytes only when sniffing is needed. Publish when that prefix - fills, explicitly flushes or the handler finishes. No timer, heartbeat or body - queue. With a supplied/suppressed type or actual encoding, the first nonempty - Write can publish immediately. -3. Flush commits 200 if needed, finalizes sniffing from pending bytes and drains - them after publication. Empty Flush publishes without inventing a type; later - writes cannot revise it. Repeated Flush without pending bytes only checks - cancellation and recorded failure. It cannot promise client receipt or chunk - boundaries. Compression wrappers must flush their encoder first. -4. FlushError returns failures. Flush uses the same implementation and retains - failures for finalization. The first writer fault remains sticky if ignored. +1. WriteHeader freezes the response but need not publish. + Empty Write commits 200 if needed and accepts zero bytes without requiring publication or sniffing. + Later ordinary header changes are ignored; + trailer attempts remain faults. +2. Retain at most 512 bytes only when sniffing is needed. + Publish when that prefix fills, explicitly flushes or the handler finishes. + No timer, heartbeat or body queue. + With a supplied/suppressed type or actual encoding, the first nonempty Write can publish immediately. +3. Flush commits 200 if needed, finalizes sniffing from pending bytes and drains them after publication. + Empty Flush publishes without inventing a type; + later writes cannot revise it. + Repeated Flush without pending bytes only checks cancellation and recorded failure. + It cannot promise client receipt or chunk boundaries. + Compression wrappers must flush their encoder first. +4. FlushError returns failures. + Flush uses the same implementation and retains failures for finalization. + The first writer fault remains sticky if ignored. ResponseController-compatible wrappers preserve FlushError or Unwrap support. -5. One handler goroutine owns the writer. Header/Write/Flush are not promised - concurrent safety; Read/Close synchronization remains the bridge's concern. +5. One handler goroutine owns the writer. + Header/Write/Flush are not promised concurrent safety; + Read/Close synchronization remains the bridge's concern. 6. Validate a complete Write against declared length before copying/forwarding. - Reject an overrun without forwarding its excess; retain accurate counts for - partial pipe writes. Short non-HEAD bodies fail at completion, directly before - handoff or terminally afterward. Reaching declared length does not mean the - handler has completed or replace Flush as an explicit publication request. -7. HEAD accepts/counts representation writes but emits no body. Honor a supplied - representation length without requiring an omitted HEAD body to fill it. - Preserve the accepted 204/205/304 suppression and nonfatal ErrBodyNotAllowed - behavior, including the documented 205 difference from net/http. -8. Before publication, faults return nil reader plus the classified error. After - publication, no replacement status/body is possible; cleanup precedes terminal - error/reporting. Existing operation/cleanup faults precede concurrent transport - cancellation. This differs intentionally from authz's callback error boundary. + Reject an overrun without forwarding its excess; + retain accurate counts for partial pipe writes. + Short non-HEAD bodies fail at completion, directly before handoff or terminally afterward. + Reaching declared length does not mean the handler has completed or replace Flush as an explicit publication request. +7. HEAD accepts/counts representation writes but emits no body. + Honor a supplied representation length without requiring an omitted HEAD body to fill it. + Preserve the accepted 204/205/304 suppression and nonfatal ErrBodyNotAllowed behavior, including the documented 205 difference from net/http. +8. Before publication, faults return nil reader plus the classified error. + After publication, no replacement status/body is possible; + cleanup precedes terminal error/reporting. + Existing operation/cleanup faults precede concurrent transport cancellation. + This differs intentionally from authz's callback error boundary. 9. Reuse existing constructor/options rules and the approved streaming reporter. - It receives the invocation context, not an inferred request principal. Factor - request preparation without changing buffered semantics or original-body and - multipart ownership. Default-off gateway identity and inherited-caller - rejection apply to streaming too. -10. Raw Handle rejects explicitly versioned HTTP API envelopes. Typed V1 cannot - prove REST deployment configuration. Preserve that qualification and the SDK - capture-versus-edge JSON v2 distinction in registration examples. + It receives the invocation context, not an inferred request principal. + Factor request preparation without changing buffered semantics or original-body and multipart ownership. + Default-off gateway identity and inherited-caller rejection apply to streaming too. +10. Raw Handle rejects explicitly versioned HTTP API envelopes. + Typed V1 cannot prove REST deployment configuration. + Preserve that qualification and the SDK capture-versus-edge JSON v2 distinction in registration examples. ## S1. Accepted: never infer streaming Content-Length -Earlier approvals prohibit whole-body buffering and inference after handoff, -but do not settle whether a tiny response completing before handoff should gain -an inferred length. Buffered edge and native net/http can infer one. +Earlier approvals prohibit whole-body buffering and inference after handoff, but do not settle whether a tiny response completing before handoff should gain an inferred length. +Buffered edge and native net/http can infer one. -**Recommend:** preserve valid caller-supplied Content-Length when the method and -status permit it; otherwise omit it consistently, including small completed -responses, empty responses and HEAD. Respect explicit suppression. Do not generate -Transfer-Encoding metadata; Gateway owns its transport framing. +**Recommend:** preserve valid caller-supplied Content-Length when the method and status permit it; +otherwise omit it consistently, including small completed responses, empty responses and HEAD. +Respect explicit suppression. +Do not generate Transfer-Encoding metadata; +Gateway owns its transport framing. -**Reasoning:** AWS explicitly supports omitted length. Inferring it only before -publication makes output depend on sniffing, Content-Type presence and Flush -placement. One rule keeps small and large responses on a predictable streaming -contract without extra buffering or configuration. +**Reasoning:** AWS explicitly supports omitted length. +Inferring it only before publication makes output depend on sniffing, Content-Type presence and Flush placement. +One rule keeps small and large responses on a predictable streaming contract without extra buffering or configuration. -**Alternative:** infer from the small prefix on successful completion before -handoff, closer to net/http's optimization. This saves chunk framing in some -cases but creates timing-dependent headers and an additional HEAD rule. +**Alternative:** infer from the small prefix on successful completion before handoff, closer to net/http's optimization. +This saves chunk framing in some cases but creates timing-dependent headers and an additional HEAD rule. -**Consequence:** an unflushed tiny stream may omit a length that buffered edge or -native HTTP would infer. Applications needing a representation length supply -one explicitly. Buffered behavior is unchanged. This is edge policy, not an AWS -prohibition on inferred lengths. +**Consequence:** an unflushed tiny stream may omit a length that buffered edge or native HTTP would infer. +Applications needing a representation length supply one explicitly. +Buffered behavior is unchanged. +This is edge policy, not an AWS prohibition on inferred lengths. ## S2. Accepted: share a nonblank Content-Encoding sniffing test -Before this refinement, `bufferedWriter.finish` suppressed sniffing when Content-Encoding -has any values, including a single empty string. Go 1.27.1 checks for a nonblank -encoding. The local HTTP probe confirms an empty Content-Encoding permits HTML -content detection, while an actual encoding suppresses it. +Before this refinement, `bufferedWriter.finish` suppressed sniffing when Content-Encoding has any values, including a single empty string. +Go 1.27.1 checks for a nonblank encoding. +The local HTTP probe confirms an empty Content-Encoding permits HTML content detection, while an actual encoding suppresses it. -**Recommend:** use a small shared private predicate in both writers. Suppress -sniffing if any committed, trimmed Content-Encoding value is nonempty. Nil/empty -slices and all-empty values do not suppress it. Preserve the field through -existing snapshot rules, without introducing an encoding grammar parser. +**Recommend:** use a small shared private predicate in both writers. +Suppress sniffing if any committed, trimmed Content-Encoding value is nonempty. +Nil/empty slices and all-empty values do not suppress it. +Preserve the field through existing snapshot rules, without introducing an encoding grammar parser. Content-Type's explicit nil/empty-slice suppression remains unchanged. -**Reasoning:** an empty encoding does not say the bytes are encoded. Conversely, -a later nonempty repeated value must still suppress sniffing even if the first -value is blank; looking only at Header.Get misses that defensive multivalue case. +**Reasoning:** an empty encoding does not say the bytes are encoded. +Conversely, a later nonempty repeated value must still suppress sniffing even if the first value is blank; +looking only at Header.Get misses that defensive multivalue case. One helper prevents drift between the two writers. -**Alternative:** retain the any-value check in both writers. It preserves the -current edge quirk but loses intended Go compatibility for empty encoding fields. +**Alternative:** retain the any-value check in both writers. +It preserves the current edge quirk but loses intended Go compatibility for empty encoding fields. A universal encoding parser would be unnecessary additional scope. -**Consequence:** buffered responses with absent Content-Type and explicitly empty -Content-Encoding may now gain a detected type. Actual encodings, explicit type -suppression and header representation remain unchanged. This compatibility -correction was approved on 2026-09-17. +**Consequence:** buffered responses with absent Content-Type and explicitly empty Content-Encoding may now gain a detected type. +Actual encodings, explicit type suppression and header representation remain unchanged. +This compatibility correction was approved on 2026-09-17. ## S3. SDK issue: retain the existing deployment release gate -All five existing local Runtime API cases were rerun with race detection against -v1.55.0. They still show incremental raw/typed delivery, reader closure and late -error trailers. Edge's byte-plus-error normalization remains necessary to preserve -data that the SDK's errorCapturingReader otherwise discards. +All five existing local Runtime API cases were rerun with race detection against v1.55.0. +They still show incremental raw/typed delivery, reader closure and late error trailers. +Edge's byte-plus-error normalization remains necessary to preserve data that the SDK's errorCapturingReader otherwise discards. The extended probe also observes: - Lambda-Runtime-Function-Response-Mode is absent. - The response request does not ask to close its connection. -- All five cases reused the response TCP connection for the subsequent /next - request. Closing the returned io.ReadCloser is distinct from closing that - Runtime API connection. - -Both observations differ from the current custom-runtime guide. The mock accepts -them to observe SDK behavior. This proves neither rejection nor successful -streaming by the real Lambda/API Gateway services. - -[Upstream issue 500](https://github.com/aws/aws-lambda-go/issues/500) raised the -mode-header question. Collaborator comments support the reader/lambdaurl design -but do not answer it. Closing that issue does not resolve the mismatch or verify -today's REST API Gateway behavior. - -**Recommendation remains SDK reuse.** Continue the locally testable implementation -after S1/S2 approval, retaining the deployment gate for both header and connection -handling. Do not patch the module cache, change global HTTP transport, fork the SDK -or add a custom runtime as an implicit workaround. - -Resolution still needs authoritative AWS clarification, a suitable stable SDK -change or a separately approved deployed integration test. Such a test should -check client-observed incremental delivery and truncation/error behavior with the -exact SDK and REST integration configuration. This review used no account APIs, -deployments or upstream posts. Deployment support remains unverified. +- All five cases reused the response TCP connection for the subsequent /next request. + Closing the returned io.ReadCloser is distinct from closing that Runtime API connection. + +Both observations differ from the current custom-runtime guide. +The mock accepts them to observe SDK behavior. +This proves neither rejection nor successful streaming by the real Lambda/API Gateway services. + +[Upstream issue 500](https://github.com/aws/aws-lambda-go/issues/500) raised the mode-header question. +Collaborator comments support the reader/lambdaurl design but do not answer it. +Closing that issue does not resolve the mismatch or verify today's REST API Gateway behavior. + +**Recommendation remains SDK reuse.** +Continue the locally testable implementation after S1/S2 approval, retaining the deployment gate for both header and connection handling. +Do not patch the module cache, change global HTTP transport, fork the SDK or add a custom runtime as an implicit workaround. + +Resolution still needs authoritative AWS clarification, a suitable stable SDK change or a separately approved deployed integration test. +Such a test should check client-observed incremental delivery and truncation/error behavior with the exact SDK and REST integration configuration. +This review used no account APIs, deployments or upstream posts. +Deployment support remains unverified. ## Implementation sequence and validation -1. Record S1/S2 resolution, then add the shared predicate and focused - buffered regressions before implementing streaming sniffing. -2. Implement the private writer over the existing bridge, testing split sniffing, - suppression, empty writes/flushes, publication, lengths, bodyless responses, - sticky faults, late trailers and panics. -3. Factor shared preparation and wire the approved constructor, reporter and - raw/typed REST entry points. -4. Exercise the actual edge writer through the SDK subprocess: first delivery - before producer completion, backpressure, cleanup/Close and invocation reuse. -5. Add SSE, direct JSON v2 NDJSON, binary and gzip examples, slow readers/early - closure and authn/authz denials/outages before commitment. Heartbeats and - application error records remain consumer-owned. -6. Run race tests, vet, formatting and both Lambda builds. Report local completion - separately from deployment compatibility. - -The initial review changed only documentation and local observation probes. The Go -1.27.1 HTTP reference probe passes for nil/empty/actual Content-Encoding, -Content-Type suppression and empty Flush. The latter publishes without a type -or inferred length. Both reference and SDK probes pass with race detection. +1. Record S1/S2 resolution, then add the shared predicate and focused buffered regressions before implementing streaming sniffing. +2. Implement the private writer over the existing bridge, testing split sniffing, suppression, empty writes/flushes, publication, lengths, bodyless responses, sticky faults, late trailers and panics. +3. Factor shared preparation and wire the approved constructor, reporter and raw/typed REST entry points. +4. Exercise the actual edge writer through the SDK subprocess: first delivery before producer completion, backpressure, cleanup/Close and invocation reuse. +5. Add SSE, direct JSON v2 NDJSON, binary and gzip examples, slow readers/early closure and authn/authz denials/outages before commitment. + Heartbeats and application error records remain consumer-owned. +6. Run race tests, vet, formatting and both Lambda builds. + Report local completion separately from deployment compatibility. + +The initial review changed only documentation and local observation probes. +The Go 1.27.1 HTTP reference probe passes for nil/empty/actual Content-Encoding, Content-Type suppression and empty Flush. +The latter publishes without a type or inferred length. +Both reference and SDK probes pass with race detection. The user subsequently approved S1/S2 and authorized implementation on 2026-09-17. Existing accepted contracts and the deployment qualification stay in force. ## Implementation completed on 2026-09-17 -The writer, constructor, raw/typed REST methods and terminal reporter are now -implemented. Shared preparation reuses buffered request validation, body ownership -and opt-in native identity. Both writers use the approved sniffing predicate; -streaming consistently omits inferred lengths. The writer retains at most 512 -sniff bytes and uses the existing bridge for handoff and backpressure. - -Contract tests cover split sniffing, header ownership, empty writes/flushes, -explicit/suppressed type, encoding, lengths, HEAD/bodyless statuses, sticky faults, -prefix/header bounds, partial writes, trailers, early/late panics, cancellation, -Close, reporter faults, multipart cleanup and concurrent identity isolation. -An 8 MiB body test proves the streaming path does not apply the buffered envelope -limit. Existing Cognito/IAM authentication, authorization and cancellation matrices -now include both streaming entry points. - -The SDK subprocess suite has nine cases, including the production raw/typed -adapter and its late length error/panic paths. The local Runtime API server reads -the first body segment before releasing the producer, testing delivery beyond -metadata. It checks reader closure before the next invocation. The mode-header -and connection-reuse observations remain; successful mock delivery does not -resolve S3. - -SSE, direct JSON v2 NDJSON, binary and gzip examples are runnable. Full module -race tests, vet, formatting, and Linux arm64/amd64 builds pass on Go 1.27.1. -See the [consumer guide](../streaming.md). No AWS deployment or account API was -used, and no SDK workaround was introduced. +The writer, constructor, raw/typed REST methods and terminal reporter are now implemented. +Shared preparation reuses buffered request validation, body ownership and opt-in native identity. +Both writers use the approved sniffing predicate; +streaming consistently omits inferred lengths. +The writer retains at most 512 sniff bytes and uses the existing bridge for handoff and backpressure. + +Contract tests cover split sniffing, header ownership, empty writes/flushes, explicit/suppressed type, encoding, lengths, HEAD/bodyless statuses, sticky faults, prefix/header bounds, partial writes, trailers, early/late panics, cancellation, Close, reporter faults, multipart cleanup and concurrent identity isolation. +An 8 MiB body test proves the streaming path does not apply the buffered envelope limit. +Existing Cognito/IAM authentication, authorization and cancellation matrices now include both streaming entry points. + +The SDK subprocess suite has nine cases, including the production raw/typed adapter and its late length error/panic paths. +The local Runtime API server reads the first body segment before releasing the producer, testing delivery beyond metadata. +It checks reader closure before the next invocation. +The mode-header and connection-reuse observations remain; +successful mock delivery does not resolve S3. + +SSE, direct JSON v2 NDJSON, binary and gzip examples are runnable. +Full module race tests, vet, formatting, and Linux arm64/amd64 builds pass on Go 1.27.1. +See the [consumer guide](../streaming.md). +No AWS deployment or account API was used, and no SDK workaround was introduced. ## Documentation and SDK follow-up -The user's requested [2026-09-17 follow-up](../reviews/2026-09-17-sdk-streaming-follow-up.md) -rechecks current AWS docs, version-specific SDK Go docs and upstream discussions. -All direct AWS dependencies remain latest stable. A tenth probe case now returns -the SDK's APIGatewayProxyStreamingResponse directly and reproduces the missing -mode header and connection reuse without edge's adapter or encoder. The SDK's -explicit REST streaming documentation strengthens the intended-support evidence, -but the transport discrepancy remains unresolved. The follow-up includes a draft -of six precise questions for the user to send to AWS. No production policy changes. +The user's requested [2026-09-17 follow-up](../reviews/2026-09-17-sdk-streaming-follow-up.md) rechecks current AWS docs, version-specific SDK Go docs and upstream discussions. +All direct AWS dependencies remain latest stable. +A tenth probe case now returns the SDK's APIGatewayProxyStreamingResponse directly and reproduces the missing mode header and connection reuse without edge's adapter or encoder. +The SDK's explicit REST streaming documentation strengthens the intended-support evidence, but the transport discrepancy remains unresolved. +The follow-up includes a draft of six precise questions for the user to send to AWS. +No production policy changes. diff --git a/docs/decisions/0023-consumer-readiness.md b/docs/decisions/0023-consumer-readiness.md index f24d856..44b11c4 100644 --- a/docs/decisions/0023-consumer-readiness.md +++ b/docs/decisions/0023-consumer-readiness.md @@ -1,81 +1,64 @@ # 0023: Consumer readiness and the next implementation milestone -Status: R1–R5 accepted on 2026-09-20; local implementation and validation completed -on 2026-09-21. External release prerequisites remain open. R6 remains an owner -choice; no license, publication, push, tag or deployment is authorized. +Status: R1–R5 accepted on 2026-09-20; +local implementation and validation completed on 2026-09-21. +External release prerequisites remain open. +R6 remains an owner choice; +no license, publication, push, tag or deployment is authorized. ## Outcome and scope -The public packages already compose into the intended HTTP, authentication and -authorization pipeline. This review found no missing root facade or dispatcher -interface needed to demonstrate that composition. The next useful deliverable -is one complete consumer application with tests and a clear support matrix. +The public packages already compose into the intended HTTP, authentication and authorization pipeline. +This review found no missing root facade or dispatcher interface needed to demonstrate that composition. +The next useful deliverable is one complete consumer application with tests and a clear support matrix. -The accepted refinement is to defer exporting `edgetest`. It was in -the original package plan, but no concrete helper API was previously accepted. -Existing identity constructors, standard httptest tools, explicit SDK event -literals and local provider transports already cover the basic test boundaries. -Prove the remaining repetition in the reference application before freezing a -new exported fixture API. +The accepted refinement is to defer exporting `edgetest`. +It was in the original package plan, but no concrete helper API was previously accepted. +Existing identity constructors, standard httptest tools, explicit SDK event literals and local provider transports already cover the basic test boundaries. +Prove the remaining repetition in the reference application before freezing a new exported fixture API. -This is a consumer composition and release-readiness review, not a new security -audit or a claim of deployed AWS interoperability. The Beakley comparison below -uses the nine findings preserved in the implementation plan; it does not assert -an exhaustive new source/API parity review of Beakley. +This is a consumer composition and release-readiness review, not a new security audit or a claim of deployed AWS interoperability. +The Beakley comparison below uses the nine findings preserved in the implementation plan; +it does not assert an exhaustive new source/API parity review of Beakley. ## Evidence inspected -- `authz/example_test.go` composes ActionHeader, Cognito configuration, IAM proof - verification/error mapping, ordered rules and application grant resolution. - Its runnable dispatcher example exercises missing credentials, not both - successful credential paths or Lambda registration. -- `authn/example_test.go` demonstrates a successful synthetic Bearer path and a - rejected proof. `iamproof/example_test.go` demonstrates client proof generation. +- `authz/example_test.go` composes ActionHeader, Cognito configuration, IAM proof verification/error mapping, ordered rules and application grant resolution. + Its runnable dispatcher example exercises missing credentials, not both successful credential paths or Lambda registration. +- `authn/example_test.go` demonstrates a successful synthetic Bearer path and a rejected proof. + `iamproof/example_test.go` demonstrates client proof generation. Registration and streaming examples live in separate root-package files. -- `authn/gateway_test.go` and `authn/authorization_test.go` exercise production - Cognito signature verification and IAM proof processing with synthetic local - JWKS/STS dependencies across native HTTP, five buffered entry formats and two - REST streaming entry points. This is stronger composition coverage than the - short consumer examples, but it is spread through test-only helpers. +- `authn/gateway_test.go` and `authn/authorization_test.go` exercise production Cognito signature verification and IAM proof processing with synthetic local JWKS/STS dependencies across native HTTP, five buffered entry formats and two REST streaming entry points. + This is stronger composition coverage than the short consumer examples, but it is spread through test-only helpers. - `authn.Authenticator.Handler` deliberately writes generic text failures. - Custom JSON failures are supported through Authenticate, Error.StatusCode, - Error.Challenges and identity.WithCaller, but the custom-envelope example only - demonstrates its failure branch. A complete success/error pipeline is missing - from the consumer walkthrough. -- `identity.NewJWT`, NewIAM and WithCaller already validate and construct fixture - facts. The adapter and authenticator reject inherited callers. A convenience - fixture must not quietly bypass that boundary or imply cryptographic proof. -- Public root godoc, package imports, README, guides, fixture inventory, current - CI workflow and decisions 0005/0011/0018–0022 were inspected. + Custom JSON failures are supported through Authenticate, Error.StatusCode, Error.Challenges and identity.WithCaller, but the custom-envelope example only demonstrates its failure branch. + A complete success/error pipeline is missing from the consumer walkthrough. +- `identity.NewJWT`, NewIAM and WithCaller already validate and construct fixture facts. + The adapter and authenticator reject inherited callers. + A convenience fixture must not quietly bypass that boundary or imply cryptographic proof. +- Public root godoc, package imports, README, guides, fixture inventory, current CI workflow and decisions 0005/0011/0018–0022 were inspected. Official documentation and Go skills checked for this review: -- [AWS HTTP API payload differences](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), - through public AWS MCP: 2.0 combines repeated header/query values, handles - cookies separately, and omits the custom-domain API mapping from rawPath. -- [REST proxy input/output](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html), - through public AWS MCP: multivalue maps, deployment-dependent requestContext - and distinct IAM/Cognito/custom-authorizer assertions. -- Installed Go 1.27.1 godoc via go-docs for httptest.NewRequestWithContext and - ResponseRecorder.Result. The former invents test host/protocol defaults and - dummy TLS state for HTTPS; the latter is a buffered observation whose body - does not report late streaming failures. Neither simulates API Gateway. -- Go rules for API design, testing, errors, packages and runnable examples, - together with the previously loaded official Go 1.27 release notes. -- [Go release workflow](https://go.dev/doc/modules/release-workflow) and - [module source layout](https://go.dev/doc/modules/managing-source). A v0 - milestone can precede API stability; v1 establishes compatibility commitments. +- [AWS HTTP API payload differences](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html), through public AWS MCP: 2.0 combines repeated header/query values, handles cookies separately, and omits the custom-domain API mapping from rawPath. +- [REST proxy input/output](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html), through public AWS MCP: multivalue maps, deployment-dependent requestContext and distinct IAM/Cognito/custom-authorizer assertions. +- Installed Go 1.27.1 godoc via go-docs for httptest.NewRequestWithContext and ResponseRecorder.Result. + The former invents test host/protocol defaults and dummy TLS state for HTTPS; + the latter is a buffered observation whose body does not report late streaming failures. + Neither simulates API Gateway. +- Go rules for API design, testing, errors, packages and runnable examples, together with the previously loaded official Go 1.27 release notes. +- [Go release workflow](https://go.dev/doc/modules/release-workflow) and [module source layout](https://go.dev/doc/modules/managing-source). + A v0 milestone can precede API stability; + v1 establishes compatibility commitments. ## R1. Build one complete reference application with existing APIs -**Recommendation:** add a reference application under `examples/dispatcher`, -with shared application code internal to that example and small explicit native, -buffered Lambda and REST streaming entry points. Keep it in the root module so -normal tests and builds include it. Add an external-module compile check using -a local replace directive to verify consumption through public imports. +**Recommendation:** add a reference application under `examples/dispatcher`, with shared application code internal to that example and small explicit native, buffered Lambda and REST streaming entry points. +Keep it in the root module so normal tests and builds include it. +Add an external-module compile check using a local replace directive to verify consumption through public imports. -Use a small orders domain and the configured Action header. The complete -pipeline should be visible in one place: +Use a small orders domain and the configured Action header. +The complete pipeline should be visible in one place: ```mermaid flowchart LR @@ -89,28 +72,27 @@ flowchart LR Execute --> Output[JSON response or explicitly configured REST stream] ``` -Show actual CognitoVerifier and iamproof.Verifier wiring, including the required -ErrInvalidProof-to-ErrInvalidCredentials mapping. Use Authenticate for consistent -application-owned JSON errors, preserve challenges/no-store/CORS and install the -caller only on success. Keep public routes/preflight explicit. Do not change the -existing convenience middleware's text response policy. +Show actual CognitoVerifier and iamproof.Verifier wiring, including the required ErrInvalidProof-to-ErrInvalidCredentials mapping. +Use Authenticate for consistent application-owned JSON errors, preserve challenges/no-store/CORS and install the caller only on success. +Keep public routes/preflight explicit. +Do not change the existing convenience middleware's text response policy. -Use a normal read action across all transports. Register a streaming action only -in the streaming application variant; do not select adapter mode from a header, -detect it with a failed Flush, or imply HTTP APIs support response streaming. -Authorize before the first write/flush. Stop on context/write/flush failures. +Use a normal read action across all transports. +Register a streaming action only in the streaming application variant; +do not select adapter mode from a header, detect it with a failed Flush, or imply HTTP APIs support response streaming. +Authorize before the first write/flush. +Stop on context/write/flush failures. Grant storage and action/resource consistency remain application-owned. **Alternative:** add an edge dispatcher/resolver/error-rendering framework. -**Reasoning:** the existing public APIs already support the composition, and -decision 0021 deliberately keeps application policy and dispatch with consumers. -**Consequence:** the example contains some explicit wiring; its application -types and configuration are not new module-wide public contracts. +**Reasoning:** the existing public APIs already support the composition, and decision 0021 deliberately keeps application policy and dispatch with consumers. +**Consequence:** the example contains some explicit wiring; +its application types and configuration are not new module-wide public contracts. ## R2. Defer exported edgetest helpers until the example proves their value -**Recommendation:** the next milestone adds no exported edgetest API. Start with -private fixture helpers in the example's tests and a consumer-testing guide. +**Recommendation:** the next milestone adds no exported edgetest API. +Start with private fixture helpers in the example's tests and a consumer-testing guide. The user approved this change to the original sequencing on 2026-09-20. The smallest test vocabulary currently needed is already available: @@ -123,76 +105,66 @@ The smallest test vocabulary currently needed is already available: | Gateway conversion | Explicit SDK events or authored raw JSON and public adapter | Deployed routing/authorizer configuration | | Incremental stream | Public streaming reader, controlled producer/consumer, Close | Remote client timing or AWS termination semantics | -For native business-handler tests, use SourceCustomAssertion unless the test -specifically models a different source. Do not inject a caller into the parent -context passed to the adapter or authenticator. Provider stubs may return the -required verified-source fixture only when explicitly labeled as stubs; real -cryptographic/proof tests use the real verifiers with local dependencies. +For native business-handler tests, use SourceCustomAssertion unless the test specifically models a different source. +Do not inject a caller into the parent context passed to the adapter or authenticator. +Provider stubs may return the required verified-source fixture only when explicitly labeled as stubs; +real cryptographic/proof tests use the real verifiers with local dependencies. -Keep distinct private REST, HTTP API 1.0 and HTTP API 2.0 fixtures. SDK V1 lacks -the raw version discriminator, so HTTP 1.0 raw fixtures must explicitly encode -`version: "1.0"` using JSON v2. Do not infer API product from the V1 Go type. -Avoid generic HTTP-request-to-Gateway conversion: an ordinary request lacks -deployment mapping, authorizer output and the original Gateway field choices. +Keep distinct private REST, HTTP API 1.0 and HTTP API 2.0 fixtures. +SDK V1 lacks the raw version discriminator, so HTTP 1.0 raw fixtures must explicitly encode `version: "1.0"` using JSON v2. +Do not infer API product from the V1 Go type. +Avoid generic HTTP-request-to-Gateway conversion: an ordinary request lacks deployment mapping, authorizer output and the original Gateway field choices. **Alternatives considered:** exported NewJWT/NewIAM convenience wrappers; -REST/HTTPV1/HTTPV2 JSON-marshaling wrappers; a universal request builder; -response collectors returning a common http.Response. The first two mostly -duplicate existing APIs. The latter two introduce fidelity, error, resource and -ownership policies that have not earned a public compatibility commitment. - -**Reasoning:** tests should expose meaningful protocol differences. A helper -that normalizes away repeated headers, claims fidelity or late read errors can -make incorrect application assumptions appear valid. -**Consequence:** some small explicit fixture construction remains. Revisit -exported helpers after the reference application and an actual consumer identify -a stable repeated operation; review exact signatures and policy at that point. +REST/HTTPV1/HTTPV2 JSON-marshaling wrappers; +a universal request builder; +response collectors returning a common http.Response. +The first two mostly duplicate existing APIs. +The latter two introduce fidelity, error, resource and ownership policies that have not earned a public compatibility commitment. + +**Reasoning:** tests should expose meaningful protocol differences. +A helper that normalizes away repeated headers, claims fidelity or late read errors can make incorrect application assumptions appear valid. +**Consequence:** some small explicit fixture construction remains. +Revisit exported helpers after the reference application and an actual consumer identify a stable repeated operation; +review exact signatures and policy at that point. ## R3. Demonstrate realistic consumer outcomes, preserving fixture provenance -**Recommendation:** test the reference application's public boundary with both -successful credential kinds, rather than only adding more constructor examples. +**Recommendation:** test the reference application's public boundary with both successful credential kinds, rather than only adding more constructor examples. Keep the current transport fixtures as independent adapter contract tests. Acceptance for the reference application: - Both credential kinds can execute the same authorized action/resource. -- Missing/rejected credentials, duplicate Authorization or Action, unknown - action, grant denial, grant outage and cancellation produce the selected safe - outcomes; denied requests never execute. Preserve challenges and CORS. +- Missing/rejected credentials, duplicate Authorization or Action, unknown action, grant denial, grant outage and cancellation produce the selected safe outcomes; + denied requests never execute. + Preserve challenges and CORS. - Resource resolution/authorization/execution use the same selected target. - Normal actions work through native HTTP and all supported buffered formats; - streaming examples additionally demonstrate first delivery before completion, - a slow reader, early Close and a late failure after successful authorization. -- Custom consumer headers survive; meaningful multivalue/cookie/binary cases - remain visible instead of being hidden by a universal response normalizer. + streaming examples additionally demonstrate first delivery before completion, a slow reader, early Close and a late failure after successful authorization. +- Custom consumer headers survive; + meaningful multivalue/cookie/binary cases remain visible instead of being hidden by a universal response normalizer. - Test dependencies cannot accidentally fall through to live AWS/network calls. Test keys and STS responses are synthetic and explicitly described that way. -**Alternative:** export fixture machinery first or treat passing package tests -as a complete consumer walkthrough. **Reasoning:** existing tests establish -composition, but consumers still have to assemble the successful production -configuration and consistent error handling from several files. +**Alternative:** export fixture machinery first or treat passing package tests as a complete consumer walkthrough. +**Reasoning:** existing tests establish composition, but consumers still have to assemble the successful production configuration and consistent error handling from several files. **Consequence:** no new authentication guarantees or relaxed source checks. ## R4. Publish a precise initial support matrix and retain deferred scope -**Recommendation:** document what is implemented, locally tested, deferred and -deployment-unverified separately. Retain custom Lambda-authorizer mapping, -Gateway metadata accessors, mTLS identity, Function URLs/ALB, generic grant -resolvers and ongoing stream reauthorization outside this initial milestone. +**Recommendation:** document what is implemented, locally tested, deferred and deployment-unverified separately. +Retain custom Lambda-authorizer mapping, Gateway metadata accessors, mTLS identity, Function URLs/ALB, generic grant resolvers and ongoing stream reauthorization outside this initial milestone. No current required consumer case justifies silently introducing their contracts. The root package currently exposes no Gateway request-ID/stage/route accessor. -Decision 0005 explicitly deferred that API. The plan's broad package-responsibility -description must not be read as a claim that every metadata field is available. -Custom authorizers are not native IAM/Cognito/JWT assertions and remain rejected -when encountered by opt-in identity extraction under the accepted rules. +Decision 0005 explicitly deferred that API. +The plan's broad package-responsibility description must not be read as a claim that every metadata field is available. +Custom authorizers are not native IAM/Cognito/JWT assertions and remain rejected when encountered by opt-in identity extraction under the accepted rules. **Alternative:** expand the next milestone to all deferred integrations. **Reasoning:** each has independent representation and trust-boundary questions. -**Consequence:** feature exclusions become explicit consumer information, and -any specific unmet consumer requirement returns for its own design review. +**Consequence:** feature exclusions become explicit consumer information, and any specific unmet consumer requirement returns for its own design review. ### Original Beakley failure cases traced to current coverage @@ -208,107 +180,102 @@ any specific unmet consumer requirement returns for its own design review. | Broad ARN wildcard matching | TestIAMExactAndRoleFamilies and validated IAM caller forms; no glob matcher | | Client ID/audience conflation and unrestricted clients | TestCognitoClientAudienceAndClaims and required explicit client configuration | -These map the recorded defects to contracts/tests, not to a claim that AWS -produces every synthetic fixture or that Beakley's entire API was copied. +These map the recorded defects to contracts/tests, not to a claim that AWS produces every synthetic fixture or that Beakley's entire API was copied. ## R5. Complete release engineering before a first v0 milestone -**Recommendation:** prepare a v0 consumer milestone after the example and review -are complete. Do not claim v1 stability yet. Keep the streaming deployment -qualification in force and track real Cognito/STS interoperability separately. -Authoritative AWS answers or an approved live fixture are still needed for the -streaming transport questions; local tests cannot close that item. +**Recommendation:** prepare a v0 consumer milestone after the example and review are complete. +Do not claim v1 stability yet. +Keep the streaming deployment qualification in force and track real Cognito/STS interoperability separately. +Authoritative AWS answers or an approved live fixture are still needed for the streaming transport questions; +local tests cannot close that item. Current readiness observations: - Five public packages exist: edge, identity, authn, authz and iamproof. - Identity remains standard-library-only; authn/authz depend on identity and the - standard library. AWS dependencies stay in transport and iamproof. + Identity remains standard-library-only; + authn/authz depend on identity and the standard library. + AWS dependencies stay in transport and iamproof. - The identity package's encoding/json import is only for Number compatibility; module-owned JSON processing remains direct JSON v2/jsontext. - CI defines Go 1.27.0/latest 1.27 tests/race/vet and Linux arm64/amd64 builds. - Referenced checkout@v7 and setup-go@v7 tags resolve. No GitHub CI result is - recorded for this working branch; local success does not establish it. -- The historical nested `docs/probes/iamproof` module is excluded from root - `go test ./...`. Label it historical or run it explicitly when relying on its - evidence. Keep new runnable consumer examples in the root module. -- Initial buffered allocation baselines exist. Add a streaming baseline that - consumes incrementally and excludes fixture setup; record environment and - allocations, without inventing performance thresholds or Lambda latency claims. -- All direct AWS modules are still latest stable on 2026-09-20. Indirect - smithy-go v1.28.2 is now available, released 2026-09-18, versus required v1.28.1. - Its [upstream comparison](https://github.com/aws/smithy-go/compare/v1.28.1...v1.28.2) - includes HTTP path/host and event-stream/decoder changes. Recommend an isolated - patch update with IAM-proof contract tests, full race/vet and target builds. + Referenced checkout@v7 and setup-go@v7 tags resolve. + No GitHub CI result is recorded for this working branch; + local success does not establish it. +- The historical nested `docs/probes/iamproof` module is excluded from root `go test ./...`. + Label it historical or run it explicitly when relying on its evidence. + Keep new runnable consumer examples in the root module. +- Initial buffered allocation baselines exist. + Add a streaming baseline that consumes incrementally and excludes fixture setup; + record environment and allocations, without inventing performance thresholds or Lambda latency claims. +- All direct AWS modules are still latest stable on 2026-09-20. + Indirect smithy-go v1.28.2 is now available, released 2026-09-18, versus required v1.28.1. + Its [upstream comparison](https://github.com/aws/smithy-go/compare/v1.28.1...v1.28.2) includes HTTP path/host and event-stream/decoder changes. + Recommend an isolated patch update with IAM-proof contract tests, full race/vet and target builds. This is not a fix for aws-lambda-go's separate streaming transport. -- A newer testify appears in the module graph through upstream SDK tests, not - this module's production imports. Do not blanket-update all listed modules. +- A newer testify appears in the module graph through upstream SDK tests, not this module's production imports. + Do not blanket-update all listed modules. **Alternative:** declare release readiness from local unit tests alone. -**Reasoning:** the reference application, CI execution and accurate support -claims validate different consumer concerns. **Consequence:** prepare these -artifacts locally; pushing, tagging, publishing and deploying are separate actions. +**Reasoning:** the reference application, CI execution and accurate support claims validate different consumer concerns. +**Consequence:** prepare these artifacts locally; +pushing, tagging, publishing and deploying are separate actions. ## R6. Obtain the owner's distribution/license choice before publication -There is no tracked LICENSE/COPYING/NOTICE file in this repository. No existing -organization license was supplied by the task, and public/private repository -visibility was not investigated by this review. +There is no tracked LICENSE/COPYING/NOTICE file in this repository. +No existing organization license was supplied by the task, and public/private repository visibility was not investigated by this review. -**Recommendation:** use the owner's existing organization-standard license if -one applies, with the exact license and copyright holder confirmed by the owner. -Otherwise defer external publication until that choice is made. Do not infer a -license from dependencies or Beakley, and do not change repository visibility. +**Recommendation:** use the owner's existing organization-standard license if one applies, with the exact license and copyright holder confirmed by the owner. +Otherwise defer external publication until that choice is made. +Do not infer a license from dependencies or Beakley, and do not change repository visibility. **Alternative:** choose a license during technical implementation. -**Reasoning:** this is an ownership/distribution decision, not an adapter API -default. **Consequence:** it does not block the example or local validation. +**Reasoning:** this is an ownership/distribution decision, not an adapter API default. +**Consequence:** it does not block the example or local validation. The exact license remains an open owner question, even if R1–R5 are approved. ## Original review validation and accepted execution order -On Go 1.27.1 darwin/arm64, the current implementation passes `go test -race ./...`, -`go vet ./...`, `go mod verify` and CGO-disabled Linux arm64/amd64 builds. -The original review changed documentation only. It did not implement a new -package, update dependencies, publish a release or contact AWS. +On Go 1.27.1 darwin/arm64, the current implementation passes `go test -race ./...`, `go vet ./...`, `go mod verify` and CGO-disabled Linux arm64/amd64 builds. +The original review changed documentation only. +It did not implement a new package, update dependencies, publish a release or contact AWS. After approval: build/test the reference application and consumer-testing guide; -reconcile support/status documentation; make the isolated Smithy patch update; -record streaming baselines and prepare CI/release evidence. Resolve any new -public API or trust-boundary decision before implementing it. The existing AWS -questions can proceed independently through the user's outreach. +reconcile support/status documentation; +make the isolated Smithy patch update; +record streaming baselines and prepare CI/release evidence. +Resolve any new public API or trust-boundary decision before implementing it. +The existing AWS questions can proceed independently through the user's outreach. ## Implementation and validation (2026-09-21) -R1–R5's local work is complete. The [reference application](../../examples/dispatcher/README.md) -provides shared internal orders code and native/buffered/REST-streaming mains. -It composes production verifiers, explicit IAM error mapping, one caller/action/ -resource, application enrollment and consistent JSON failures. No library public -API was added. The finite stream uses Go's documented http.ErrAbortHandler for -post-commit producer failures, which edge converts through its existing ErrStream -contract; it does not expose dependency diagnostics or invent replacement status. - -The example tests both credentials across eight transport entry paths, checking -repeated headers, separate V2 cookies, grant failures, cancellation and no execution -on denial. Local RSA/JWKS and synthetic STS transports cannot fall through to the -network. Separate synctest cases prove delivery before completion, slow-reader -backpressure, early Close and late failure. The actual demo enrollment policy is -also tested: exact issuer/subject or full IAM ARN, action and target all matter. - -The [consumer-testing guide](../consumer-testing.md), [support matrix](../support.md) -and [release checklist](../release-readiness.md) distinguish these local contracts -from deployment evidence. Exported edgetest remains deferred. The external-module -check uses only public imports and local replace; CI now runs it and module -verification. Historical nested IAM research is explicitly labeled as such. +R1–R5's local work is complete. +The [reference application](../../examples/dispatcher/README.md) provides shared internal orders code and native/buffered/REST-streaming mains. +It composes production verifiers, explicit IAM error mapping, one caller/action/resource, application enrollment and consistent JSON failures. +No library public API was added. +The finite stream uses Go's documented http.ErrAbortHandler for post-commit producer failures, which edge converts through its existing ErrStream contract; +it does not expose dependency diagnostics or invent replacement status. + +The example tests both credentials across eight transport entry paths, checking repeated headers, separate V2 cookies, grant failures, cancellation and no execution on denial. +Local RSA/JWKS and synthetic STS transports cannot fall through to the network. +Separate synctest cases prove delivery before completion, slow-reader backpressure, early Close and late failure. +The actual demo enrollment policy is also tested: exact issuer/subject or full IAM ARN, action and target all matter. + +The [consumer-testing guide](../consumer-testing.md), [support matrix](../support.md) and [release checklist](../release-readiness.md) distinguish these local contracts from deployment evidence. +Exported edgetest remains deferred. +The external-module check uses only public imports and local replace; +CI now runs it and module verification. +Historical nested IAM research is explicitly labeled as such. The approved Smithy v1.28.2 patch changes only its module requirement/checksums. -Production IAM-proof tests pass with that graph. [Streaming allocation baselines](../benchmarks.md#public-incremental-streaming-2026-09-21) -cover incremental raw/typed reads with setup excluded and no performance threshold. - -Full race tests, vet, module verification, formatting and CGO-disabled Linux -arm64/amd64 builds pass on Go 1.27.1; the external-module check also passes. -GitHub returned no workflow runs for this branch. A passing release-commit CI run, -the owner's license choice and existing AWS interoperability qualifications remain -open. Nothing was pushed, published or deployed. The user's AWS outreach can -proceed independently. +Production IAM-proof tests pass with that graph. +[Streaming allocation baselines](../benchmarks.md#public-incremental-streaming-2026-09-21) cover incremental raw/typed reads with setup excluded and no performance threshold. + +Full race tests, vet, module verification, formatting and CGO-disabled Linux arm64/amd64 builds pass on Go 1.27.1; +the external-module check also passes. +GitHub returned no workflow runs for this branch. +A passing release-commit CI run, the owner's license choice and existing AWS interoperability qualifications remain open. +Nothing was pushed, published or deployed. +The user's AWS outreach can proceed independently. diff --git a/docs/decisions/0024-release-automation.md b/docs/decisions/0024-release-automation.md index fd54aed..69f0659 100644 --- a/docs/decisions/0024-release-automation.md +++ b/docs/decisions/0024-release-automation.md @@ -1,170 +1,156 @@ # 0024: Release Please and pull-request validation -Status: accepted and locally implemented on 2026-09-21. The user approved -GitHub App authentication and initial v0.1.0 following the recommended v0 policy. +Status: accepted and locally implemented on 2026-09-21. +The user approved GitHub App authentication and initial v0.1.0 following the recommended v0 policy. Release Please, its GitHub Action and PR checks against main are user-directed. ## Revision: Organization App credentials (2026-09-27) -The user created and installed astrocompute-release-please and directed that both -its Client ID and private key be organization secrets. Use organization Actions -secrets RELEASE_PLEASE_CLIENT_ID and RELEASE_PLEASE_PRIVATE_KEY, with selected -repository access for go-lambda-edge. Read both through the secrets context; -remove the old repository credential entries after provisioning and verifying -organization access so a repository key cannot override the new organization key. +The user created and installed astrocompute-release-please and directed that both its Client ID and private key be organization secrets. +Use organization Actions secrets RELEASE_PLEASE_CLIENT_ID and RELEASE_PLEASE_PRIVATE_KEY, with selected repository access for go-lambda-edge. +Read both through the secrets context; +remove the old repository credential entries after provisioning and verifying organization access so a repository key cannot override the new organization key. This supersedes the original repository variable/private-key-secret setup below. -The new key was validated without printing it. App-authenticated read-only API -calls confirmed the supplied Client ID and key identify astrocompute-release-please -and that installation 165295608 includes this repository with Contents, Issues -and Pull requests write permissions. The installation is not suspended. No -installation token or release was created by these checks. +The new key was validated without printing it. +App-authenticated read-only API calls confirmed the supplied Client ID and key identify astrocompute-release-please and that installation 165295608 includes this repository with Contents, Issues and Pull requests write permissions. +The installation is not suspended. +No installation token or release was created by these checks. -[GitHub's organization secret documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) -supports selected-repository access and requires admin:org for CLI management. -Both organization secrets were provisioned and their selected-repository access -verified. The obsolete repository private-key secret and Client ID variable were -removed. Local actionlint and whitespace checks pass. Remote token creation and -release execution remain pending until the workflow change merges. +[GitHub's organization secret documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) supports selected-repository access and requires admin:org for CLI management. +Both organization secrets were provisioned and their selected-repository access verified. +The obsolete repository private-key secret and Client ID variable were removed. +Local actionlint and whitespace checks pass. +Remote token creation and release execution remain pending until the workflow change merges. ## Revision: PR-only validation (2026-09-27) -The user rejected repeating the PR validation after merge. This supersedes the -main-validation dependency in accepted A and the original implementation below. +The user rejected repeating the PR validation after merge. +This supersedes the main-validation dependency in accepted A and the original implementation below. Run the Go matrix only for PRs against main, including generated release PRs. -On main pushes, run only credential setup and Release Please. Remove the unused -workflow_call entry point. This avoids a second full matrix for each merge. - -The alternative was retaining validation of the exact main push. The chosen -workflow relies on reviewing passing PR checks before merge; direct main pushes -will not run Go validation. No branch-protection change is part of this revision. -The [official release action example](https://github.com/googleapis/release-please-action/tree/v5.0.0) -also runs Release Please directly on main pushes; a reusable Go validation job -is a repository policy choice, not an action requirement. - -The [first main run](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/36281181036) -passed all four repeated validation jobs, then failed in App token creation. -GET /repos/asteroid-computing/go-lambda-edge/installation returned 404. The -organization installation listing returned total_count=0. The App credentials -are provisioned, but the App needs installation on the organization with access -to this repository and the permissions documented in the release guide. -[GitHub's installation lookup](https://docs.github.com/en/rest/apps/apps#get-a-repository-installation-for-the-authenticated-app) -looks up the authenticated App's installation for the specified repository. -Release Please never ran; removing duplicate validation does not fix installation. +On main pushes, run only credential setup and Release Please. +Remove the unused workflow_call entry point. +This avoids a second full matrix for each merge. + +The alternative was retaining validation of the exact main push. +The chosen workflow relies on reviewing passing PR checks before merge; +direct main pushes will not run Go validation. +No branch-protection change is part of this revision. +The [official release action example](https://github.com/googleapis/release-please-action/tree/v5.0.0) also runs Release Please directly on main pushes; +a reusable Go validation job is a repository policy choice, not an action requirement. + +The [first main run](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/36281181036) passed all four repeated validation jobs, then failed in App token creation. +GET /repos/asteroid-computing/go-lambda-edge/installation returned 404. +The organization installation listing returned total_count=0. +The App credentials are provisioned, but the App needs installation on the organization with access to this repository and the permissions documented in the release guide. +[GitHub's installation lookup](https://docs.github.com/en/rest/apps/apps#get-a-repository-installation-for-the-authenticated-app) looks up the authenticated App's installation for the specified repository. +Release Please never ran; +removing duplicate validation does not fix installation. ## Evidence -The existing Go workflow already runs formatting, race tests, vet, module -verification, an external consumer check and Linux arm64/amd64 builds. It previously -ran for pushes and PRs against every branch. There are no local release tags or -release configuration files. The repository secret/variable name listings returned -no entries; no secret values were requested. Organization-scoped credentials may -need separate configuration. This review makes no repository-settings changes. +The existing Go workflow already runs formatting, race tests, vet, module verification, an external consumer check and Linux arm64/amd64 builds. +It previously ran for pushes and PRs against every branch. +There are no local release tags or release configuration files. +The repository secret/variable name listings returned no entries; +no secret values were requested. +Organization-scoped credentials may need separate configuration. +This review makes no repository-settings changes. Official sources checked: -- [Release Please Action v5.0.0](https://github.com/googleapis/release-please-action/releases/tag/v5.0.0) - is the latest stable GitHub release on this date. The tagged action uses Node 24 - and supports manifest/config inputs, a token and target-branch. Its commit is - `45996ed1f6d02564a971a2fa1b5860e934307cf7`. -- [Manifest configuration](https://github.com/googleapis/release-please/blob/main/docs/manifest-releaser.md) - supports the root package, Go strategy, bootstrap version and pre-major bump - policies. The Go strategy updates a changelog; no generated version.go is needed. -- [GitHub workflow triggers](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) - now allow built-in-token PR opened/synchronize/reopened events to create runs - requiring manual approval. App/PAT tokens allow automatic runs. This is newer - than Release Please README wording that says such events do not trigger CI. -- [PR events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request) - filter by base branch and normally check the proposed merge result. -- [GitHub App token action](https://github.com/actions/create-github-app-token) - supports short-lived installation tokens scoped to a repository and explicit - permissions. Latest stable release checked: v3.2.0. +- [Release Please Action v5.0.0](https://github.com/googleapis/release-please-action/releases/tag/v5.0.0) is the latest stable GitHub release on this date. + The tagged action uses Node 24 and supports manifest/config inputs, a token and target-branch. + Its commit is `45996ed1f6d02564a971a2fa1b5860e934307cf7`. +- [Manifest configuration](https://github.com/googleapis/release-please/blob/main/docs/manifest-releaser.md) supports the root package, Go strategy, bootstrap version and pre-major bump policies. + The Go strategy updates a changelog; + no generated version.go is needed. +- [GitHub workflow triggers](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) now allow built-in-token PR opened/synchronize/reopened events to create runs requiring manual approval. + App/PAT tokens allow automatic runs. + This is newer than Release Please README wording that says such events do not trigger CI. +- [PR events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request) filter by base branch and normally check the proposed merge result. +- [GitHub App token action](https://github.com/actions/create-github-app-token) supports short-lived installation tokens scoped to a repository and explicit permissions. + Latest stable release checked: v3.2.0. ## Accepted PR checks -Use pull_request against main, including opened, synchronize, reopened and -ready_for_review. Retain the existing validation matrix, read-only default token -and checkout without persisted credentials. Cancel superseded runs for the same -PR. Do not use path filters that could omit required checks on release-only PRs. +Use pull_request against main, including opened, synchronize, reopened and ready_for_review. +Retain the existing validation matrix, read-only default token and checkout without persisted credentials. +Cancel superseded runs for the same PR. +Do not use path filters that could omit required checks on release-only PRs. Main validation moves to the reusable job called by the release workflow. ## Accepted A: Release workflow and credential -Use a dedicated push-to-main release workflow that first calls the Go workflow -as a reusable validation job, then runs Release Please only after success. The Go -workflow retains direct PR triggers and gains workflow_call; remove its direct -push trigger at that point to avoid duplicate main validation. Keep release jobs -serialized without canceling a running release. Human merging of the generated -release PR remains the publication decision; do not enable auto-merge. - -Recommend a repository-scoped GitHub App installation token, using a variable -RELEASE_PLEASE_CLIENT_ID and secret RELEASE_PLEASE_PRIVATE_KEY. The current token -action prefers client-id; app-id remains supported but deprecated. Give the app Contents, -Pull requests and Issues write permissions for Release Please operations. Mint -only those permissions for this repository in the release job. The PR validation -workflow needs no app credentials. Pin both new actions to reviewed stable commit -SHAs with version comments; retain existing checkout/setup-go choices. - -Alternative: a fine-grained PAT stored as RELEASE_PLEASE_TOKEN, with matching -repository permissions. It works but ties rotation/ownership to a user credential. -The built-in GITHUB_TOKEN avoids an extra credential, but release PR checks need -manual approval and other downstream events remain suppressed. The repository's -current workflow PR-creation/approval setting is disabled, so that route also -needs an owner settings change. Do not silently fall back between token types. - -No app, credential, remote PR or release is created during local wiring. App -installation/credential provisioning is an explicit setup prerequisite. +Use a dedicated push-to-main release workflow that first calls the Go workflow as a reusable validation job, then runs Release Please only after success. +The Go workflow retains direct PR triggers and gains workflow_call; +remove its direct push trigger at that point to avoid duplicate main validation. +Keep release jobs serialized without canceling a running release. +Human merging of the generated release PR remains the publication decision; +do not enable auto-merge. + +Recommend a repository-scoped GitHub App installation token, using a variable RELEASE_PLEASE_CLIENT_ID and secret RELEASE_PLEASE_PRIVATE_KEY. +The current token action prefers client-id; +app-id remains supported but deprecated. +Give the app Contents, Pull requests and Issues write permissions for Release Please operations. +Mint only those permissions for this repository in the release job. +The PR validation workflow needs no app credentials. +Pin both new actions to reviewed stable commit SHAs with version comments; +retain existing checkout/setup-go choices. + +Alternative: a fine-grained PAT stored as RELEASE_PLEASE_TOKEN, with matching repository permissions. +It works but ties rotation/ownership to a user credential. +The built-in GITHUB_TOKEN avoids an extra credential, but release PR checks need manual approval and other downstream events remain suppressed. +The repository's current workflow PR-creation/approval setting is disabled, so that route also needs an owner settings change. +Do not silently fall back between token types. + +No app, credential, remote PR or release is created during local wiring. +App installation/credential provisioning is an explicit setup prerequisite. ## Accepted B: Initial version and v0 evolution Recommend one root Go module with ordinary v-prefixed tags, starting at v0.1.0. -Use manifest mode, release-type go, include-component-in-tag false, an empty -initial manifest and initial-version 0.1.0. The v5 action bundles release-please -17.6.0; its tagged schema explicitly supports initial-version. This avoids -pretending an actual earlier release exists. -Set bump-minor-pre-major true and bump-patch-for-minor-pre-major false: breaking -changes and features bump the minor version during v0; fixes bump the patch. -Move to v1 deliberately in a separately reviewed release rather than by an -incidental breaking commit. Do not rewrite the Go toolchain directive or add a -runtime version API. Release Please owns CHANGELOG.md and the version manifest. - -Alternative: normal breaking-change-to-major behavior, which can unexpectedly -turn a v0 breaking change into v1.0.0, or permanently forcing release-as, which -must be removed after the intended release. The bundled release-please supports initial-version; use it only for bootstrap. - -Use Conventional Commits in merged history. Squash-merge feature PRs with suitable -feat/fix/chore titles; a release PR's generated title/body must be preserved for -Release Please recognition. Publishing creates a Go module tag/GitHub release, -not an npm artifact or deployment of the example binaries. - -The existing license and AWS interoperability qualifications remain release-review -items. Configuring release automation does not claim they are resolved, and does -not authorize publishing a release now. +Use manifest mode, release-type go, include-component-in-tag false, an empty initial manifest and initial-version 0.1.0. +The v5 action bundles release-please 17.6.0; +its tagged schema explicitly supports initial-version. +This avoids pretending an actual earlier release exists. +Set bump-minor-pre-major true and bump-patch-for-minor-pre-major false: breaking changes and features bump the minor version during v0; +fixes bump the patch. +Move to v1 deliberately in a separately reviewed release rather than by an incidental breaking commit. +Do not rewrite the Go toolchain directive or add a runtime version API. +Release Please owns CHANGELOG.md and the version manifest. + +Alternative: normal breaking-change-to-major behavior, which can unexpectedly turn a v0 breaking change into v1.0.0, or permanently forcing release-as, which must be removed after the intended release. +The bundled release-please supports initial-version; +use it only for bootstrap. + +Use Conventional Commits in merged history. +Squash-merge feature PRs with suitable feat/fix/chore titles; +a release PR's generated title/body must be preserved for Release Please recognition. +Publishing creates a Go module tag/GitHub release, not an npm artifact or deployment of the example binaries. + +The existing license and AWS interoperability qualifications remain release-review items. +Configuring release automation does not claim they are resolved, and does not authorize publishing a release now. ## Implementation and validation -The release workflow calls the existing Go jobs before minting a scoped App token -and invoking Release Please. Go retains direct main-targeted PR events and uses -workflow_call for main validation, with no duplicate push trigger. Both release -and App-token actions use the verified stable commit SHAs. Missing configuration -fails explicitly without printing credentials or falling back to GITHUB_TOKEN. - -Root Go manifest configuration selects initial-version 0.1.0, an empty bootstrap -manifest, unprefixed v-tags and the accepted pre-major bump flags. The first -release PR creates CHANGELOG.md. [The release guide](../releases.md) documents -App setup, permissions, checks, human publication review and remaining limits. - -Both workflows pass local actionlint v1.7.12 and diff whitespace checks. JSON -configuration validates against the bundled release-please 17.6.0 schema. Local -structure checks confirm PR triggers, reusable validation, release's dependency -on validation, scoped credential use, and unchanged Go test/build jobs. No Go -source changed, so runtime suites were not rerun for this workflow-only change. -That local implementation did not install an App, provision credentials, dispatch -a workflow, change repository settings or publish a release. - -Subsequent authorized setup, verified on 2026-09-27: the repository variable -RELEASE_PLEASE_CLIENT_ID and encrypted Actions secret RELEASE_PLEASE_PRIVATE_KEY -are provisioned. The working branch was pushed and PR #1 opened. +The release workflow calls the existing Go jobs before minting a scoped App token and invoking Release Please. +Go retains direct main-targeted PR events and uses workflow_call for main validation, with no duplicate push trigger. +Both release and App-token actions use the verified stable commit SHAs. +Missing configuration fails explicitly without printing credentials or falling back to GITHUB_TOKEN. + +Root Go manifest configuration selects initial-version 0.1.0, an empty bootstrap manifest, unprefixed v-tags and the accepted pre-major bump flags. +The first release PR creates CHANGELOG.md. +[The release guide](../releases.md) documents App setup, permissions, checks, human publication review and remaining limits. + +Both workflows pass local actionlint v1.7.12 and diff whitespace checks. +JSON configuration validates against the bundled release-please 17.6.0 schema. +Local structure checks confirm PR triggers, reusable validation, release's dependency on validation, scoped credential use, and unchanged Go test/build jobs. +No Go source changed, so runtime suites were not rerun for this workflow-only change. +That local implementation did not install an App, provision credentials, dispatch a workflow, change repository settings or publish a release. + +Subsequent authorized setup, verified on 2026-09-27: the repository variable RELEASE_PLEASE_CLIENT_ID and encrypted Actions secret RELEASE_PLEASE_PRIVATE_KEY are provisioned. +The working branch was pushed and PR #1 opened. [All four PR checks at f6a2624 passed](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590). -App installation/permissions and the main-branch release path still need execution -evidence after merge. No release has been published. +App installation/permissions and the main-branch release path still need execution evidence after merge. +No release has been published. diff --git a/docs/decisions/0025-main-branch-protection.md b/docs/decisions/0025-main-branch-protection.md index bfdd1b2..fa9f861 100644 --- a/docs/decisions/0025-main-branch-protection.md +++ b/docs/decisions/0025-main-branch-protection.md @@ -1,52 +1,47 @@ # 0025: Enforce main-branch validation -Status: proposed on 2026-09-27; awaiting owner review. No repository policy has -been changed. +Status: proposed on 2026-09-27; +awaiting owner review. +No repository policy has been changed. -Owner direction on 2026-09-27: update readiness documentation, then the owner will -merge and return to the outstanding AWS qualifications. Branch protection was -not approved as part of that instruction; this proposal remains unimplemented. +Owner direction on 2026-09-27: update readiness documentation, then the owner will merge and return to the outstanding AWS qualifications. +Branch protection was not approved as part of that instruction; +this proposal remains unimplemented. ## Evidence -PR #1 at f6a2624469d14a150d89e0f5911644db86449e31 is mergeable, with all four -[Go checks passing](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590). -GitHub's main-branch endpoint reports protected=false, and the effective branch -rules endpoint returns no rules. The PR has no submitted GitHub reviews. - -The workflow implements PR validation, but cannot itself require success before -merge. The release workflow validates main again before running Release Please. -[GitHub's protected-branch documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) -supports required PRs, required checks with an expected source, up-to-date branch -requirements, and restrictions on force pushes and deletion. Protection of this -private organization repository depends on the organization's GitHub plan; +PR #1 at f6a2624469d14a150d89e0f5911644db86449e31 is mergeable, with all four [Go checks passing](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590). +GitHub's main-branch endpoint reports protected=false, and the effective branch rules endpoint returns no rules. +The PR has no submitted GitHub reviews. + +The workflow implements PR validation, but cannot itself require success before merge. +The release workflow validates main again before running Release Please. +[GitHub's protected-branch documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) supports required PRs, required checks with an expected source, up-to-date branch requirements, and restrictions on force pushes and deletion. +Protection of this private organization repository depends on the organization's GitHub plan; availability has not been verified. ## Recommendation and alternatives -Before merging, require a PR and these four checks from GitHub Actions on main: -test (1.27.0), test (1.27.x), lambda-build (arm64), lambda-build (amd64). -Require the branch to be current with main, block force pushes and deletion, and -apply the policy without a routine administrator or release-App bypass. The App -creates release branches and PRs; it does not need to push directly to main. -Do not introduce a mandatory reviewer count without an owner decision about -reviewer availability. +Before merging, require a PR and these four checks from GitHub Actions on main: test (1.27.0), test (1.27.x), lambda-build (arm64), lambda-build (amd64). +Require the branch to be current with main, block force pushes and deletion, and apply the policy without a routine administrator or release-App bypass. +The App creates release branches and PRs; +it does not need to push directly to main. +Do not introduce a mandatory reviewer count without an owner decision about reviewer availability. -This makes the existing validation an enforced merge condition. The alternative -is to retain manual inspection of checks before every merge; it avoids repository -configuration but permits accidental merges with missing or failing checks. +This makes the existing validation an enforced merge condition. +The alternative is to retain manual inspection of checks before every merge; +it avoids repository configuration but permits accidental merges with missing or failing checks. -Consequences: direct main pushes are prevented, and updating a PR after main -advances can require another CI run. Check-name changes must also update policy. +Consequences: direct main pushes are prevented, and updating a PR after main advances can require another CI run. +Check-name changes must also update policy. An unavailable repository feature requires an explicit fallback decision. ## Related readiness observations -Refresh the release checklist's historical CI/credential status. Credentials -have been provisioned and PR CI has passed; App installation/permissions and the -main release workflow still require execution evidence. Squash-merge the -foundation with its Conventional Commit feat title, then verify the generated -release PR and its checks. The license and documented live AWS qualifications -remain separate release-readiness items under decisions 0023 and 0024. +Refresh the release checklist's historical CI/credential status. +Credentials have been provisioned and PR CI has passed; +App installation/permissions and the main release workflow still require execution evidence. +Squash-merge the foundation with its Conventional Commit feat title, then verify the generated release PR and its checks. +The license and documented live AWS qualifications remain separate release-readiness items under decisions 0023 and 0024. No merge, policy change, release or live AWS test is authorized by this record. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index db3cbbe..03218c3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -1,10 +1,9 @@ # Design decisions -Statuses: proposed, accepted, superseded, or rejected. A proposed record is not -authorization to implement the behavior it describes. +Statuses: proposed, accepted, superseded, or rejected. +A proposed record is not authorization to implement the behavior it describes. -Each substantive decision records the question, relevant code and official -sources, alternatives, recommendation, consequences, and the user's resolution. +Each substantive decision records the question, relevant code and official sources, alternatives, recommendation, consequences, and the user's resolution. Keep related decisions small enough to review and implement independently. | Record | Status | Question | diff --git a/docs/gateway-fixtures.md b/docs/gateway-fixtures.md index 8dab560..d681de0 100644 --- a/docs/gateway-fixtures.md +++ b/docs/gateway-fixtures.md @@ -1,8 +1,8 @@ # Gateway identity coverage -The fixtures in gateway_identity_test.go and adapter_invoke_test.go are synthetic -and independently authored. They are not captured AWS events or copied Beakley -tests. No live AWS service is invoked by the tests. +The fixtures in gateway_identity_test.go and adapter_invoke_test.go are synthetic and independently authored. +They are not captured AWS events or copied Beakley tests. +No live AWS service is invoked by the tests. | Cases | Evidence | Qualification | | --- | --- | --- | @@ -13,32 +13,29 @@ tests. No live AWS service is invoked by the tests. | V2 IAM | [SDK v1.55.0 schema](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/apigw.go) and [SDK IAM fixture](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/testdata/apigw-v2-request-iam.json) | The SDK fixture has a ten-digit account ID; our authentication fixtures use valid twelve-digit IDs | | Malformed, ambiguous and unknown assertions | Accepted decisions 0012, 0014 and 0018 | These prove library failure policy, not that AWS generates these events | -Raw and typed paths share native shape recognition, caller construction and -resource policy. Typed V1 claims are decoded values; typed V2 claims are gateway -text. Array/number fidelity lost by upstream decoding cannot be recovered. The -real Lambda SDK registration tests cover typed V1 WithUseNumber behavior. - -SDK decoding also discards unknown V2 authorizer keys. A raw unknown authorizer -fails as unsupported, while its already-decoded empty SDK description may be -indistinguishable from an empty authorizer. Typed calls cannot promise inspection -of discarded wire data. Use the raw Adapter when complete JSON shape validation -is required. Typed SDK V2 input cannot represent the raw array-claim fixture. - -In V1, IAM lives outside the authorizer object. A separate nonempty unknown -authorizer still fails as unsupported when IAM is present; it is not sibling -metadata within a recognized native authorizer. Unknown metadata within a -recognized native authorizer is ignored as specified in decision 0018. - -The tests exercise gateway identity disabled/enabled, required identity facts, -scope consistency and limits, explicit empty producer objects, native/custom -collisions and no fallback to a bearer header. Public invocation tests cover -SDK object/function dispatch, binary bodies, cookies, application error responses, -request isolation, inherited callers, panic/cancellation and multipart cleanup. - -NewIAM's own contract suite covers all four accepted principal forms and literal -user-path punctuation. Gateway fixtures additionally exercise those forms through -the invocation boundary, including preserved session IDs and optional principal IDs. - -The SDK's upstream decoder owns duplicate/unknown fields and number handling on -typed registration; the caller/runtime also owns the final typed response's -serialized envelope limit. Edge never serializes a typed event to inspect it. +Raw and typed paths share native shape recognition, caller construction and resource policy. +Typed V1 claims are decoded values; +typed V2 claims are gateway text. +Array/number fidelity lost by upstream decoding cannot be recovered. +The real Lambda SDK registration tests cover typed V1 WithUseNumber behavior. + +SDK decoding also discards unknown V2 authorizer keys. +A raw unknown authorizer fails as unsupported, while its already-decoded empty SDK description may be indistinguishable from an empty authorizer. +Typed calls cannot promise inspection of discarded wire data. +Use the raw Adapter when complete JSON shape validation is required. +Typed SDK V2 input cannot represent the raw array-claim fixture. + +In V1, IAM lives outside the authorizer object. +A separate nonempty unknown authorizer still fails as unsupported when IAM is present; +it is not sibling metadata within a recognized native authorizer. +Unknown metadata within a recognized native authorizer is ignored as specified in decision 0018. + +The tests exercise gateway identity disabled/enabled, required identity facts, scope consistency and limits, explicit empty producer objects, native/custom collisions and no fallback to a bearer header. +Public invocation tests cover SDK object/function dispatch, binary bodies, cookies, application error responses, request isolation, inherited callers, panic/cancellation and multipart cleanup. + +NewIAM's own contract suite covers all four accepted principal forms and literal user-path punctuation. +Gateway fixtures additionally exercise those forms through the invocation boundary, including preserved session IDs and optional principal IDs. + +The SDK's upstream decoder owns duplicate/unknown fields and number handling on typed registration; +the caller/runtime also owns the final typed response's serialized envelope limit. +Edge never serializes a typed event to inspect it. diff --git a/docs/iamproof.md b/docs/iamproof.md index aae695c..fd0f2a3 100644 --- a/docs/iamproof.md +++ b/docs/iamproof.md @@ -1,14 +1,14 @@ # IAM proof generation and verification -The `iamproof` package is implemented. It authenticates a client using its AWS -credentials without requiring an API Gateway `AWS_IAM` authorizer. The client -generates an audience-bound, presigned GetCallerIdentity proof; the server -reconstructs that signed request and asks regional STS to authenticate it. +The `iamproof` package is implemented. +It authenticates a client using its AWS credentials without requiring an API Gateway `AWS_IAM` authorizer. +The client generates an audience-bound, presigned GetCallerIdentity proof; +the server reconstructs that signed request and asks regional STS to authenticate it. The verifier never signs using the Lambda role or other server credentials. -This is a library-defined proof protocol, not an ordinary SigV4 request to your -API, an EKS token, or an OAuth bearer token. The shared HTTP credential selector -is implemented in `authn`; first-party OAuth verification remains separate work. +This is a library-defined proof protocol, not an ordinary SigV4 request to your API, an EKS token, or an OAuth bearer token. +The shared HTTP credential selector is implemented in `authn`; +first-party OAuth verification remains separate work. Merely registering the edge adapter does not authenticate an Authorization header. ## Client @@ -28,34 +28,31 @@ req.Header.Set("Authorization", "EdgeIAM "+token.Value()) req.Header.Set("Action", "ListOrders") ``` -`cfg` is the application's AWS SDK configuration, and `req` is its outgoing -HTTPS request. The provider owns caching, refresh, AssumeRole and credential -retrieval I/O. Generate retrieves credentials each time and signs locally; it -does not contact STS. Temporary credentials and their session tokens are -supported. Provider AccountID is not used as identity evidence. +`cfg` is the application's AWS SDK configuration, and `req` is its outgoing HTTPS request. +The provider owns caching, refresh, AssumeRole and credential retrieval I/O. +Generate retrieves credentials each time and signs locally; +it does not contact STS. +Temporary credentials and their session tokens are supported. +Provider AccountID is not used as identity evidence. -Use a distinct, case-sensitive audience for each application/environment. The -audience is 1-256 visible ASCII bytes without spaces. Clients may reuse a token -before ExpiresAt, which is the earlier of the proof expiry and known credential -expiry. AWS may reject credentials earlier. The runnable -[client example](../iamproof/example_test.go) uses synthetic credentials and -makes no network requests. +Use a distinct, case-sensitive audience for each application/environment. +The audience is 1-256 visible ASCII bytes without spaces. +Clients may reuse a token before ExpiresAt, which is the earlier of the proof expiry and known credential expiry. +AWS may reject credentials earlier. +The runnable [client example](../iamproof/example_test.go) uses synthetic credentials and makes no network requests. ## Server -Construct one verifier with the same explicit region and audience. Its -`Verify(ctx, tokenValue)` method takes the credential without `EdgeIAM ` and -returns `identity.Caller`. It never installs the caller in a context. Successful -callers have SourceVerifiedIAMProof, preserve the full ARN/session and STS UserId, -and have an account and partition checked against the response and endpoint. +Construct one verifier with the same explicit region and audience. +Its `Verify(ctx, tokenValue)` method takes the credential without `EdgeIAM ` and returns `identity.Caller`. +It never installs the caller in a context. +Successful callers have SourceVerifiedIAMProof, preserve the full ARN/session and STS UserId, and have an account and partition checked against the response and endpoint. 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 [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. +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 [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. | Outcome | Inspect with errors.Is | Intended HTTP treatment | | --- | --- | --- | @@ -66,73 +63,67 @@ authn.ErrInvalidCredentials. Never fall back to another verifier after rejection | Client retrieval, expired/incomplete credentials or signing failure | iamproof.ErrCredentialsUnavailable | No proof generated | | Client proof exceeds 8 KiB | iamproof.ErrProofTooLarge | No proof generated; never truncate | -The package itself does not write these HTTP responses. Authorization denial -after successful authentication is separate and ordinarily produces 403. -All failures return an anonymous caller or zero token, and errors omit raw -provider/transport/XML diagnostics. Token string/debug formatting is redacted; -Value explicitly exposes the credential. Do not log that value, Authorization -headers, session tokens, signed URLs or transport dumps containing them. +The package itself does not write these HTTP responses. +Authorization denial after successful authentication is separate and ordinarily produces 403. +All failures return an anonymous caller or zero token, and errors omit raw provider/transport/XML diagnostics. +Token string/debug formatting is redacted; +Value explicitly exposes the credential. +Do not log that value, Authorization headers, session tokens, signed URLs or transport dumps containing them. ## Network, freshness and limits - Every verification contacts STS, with no application retry or result cache. - Size admission control against the shared STS quota, normally 600 requests per - second per account and Region for credential-based requests. -- The owned HTTP client refuses redirects and has no cookie jar. The default - deadline is five seconds and covers receiving the body. WithTimeout accepts a - positive override; an earlier caller deadline still wins. -- WithTransport supplies trusted deployment infrastructure. Its TLS/proxy - settings, concurrency safety, cancellation handling and internal retries are - the application's responsibility. The default is http.DefaultTransport as - configured when NewVerifier is called. Options apply in order, last value wins. -- Encoded credentials, including `v1.`, are limited to 8,192 bytes. This is not an - AWS session-token maximum, nor a guarantee the entire API request fits gateway - header limits. Incoming oversize proofs are invalid credentials. + Size admission control against the shared STS quota, normally 600 requests per second per account and Region for credential-based requests. +- The owned HTTP client refuses redirects and has no cookie jar. + The default deadline is five seconds and covers receiving the body. + WithTimeout accepts a positive override; + an earlier caller deadline still wins. +- WithTransport supplies trusted deployment infrastructure. + Its TLS/proxy settings, concurrency safety, cancellation handling and internal retries are the application's responsibility. + The default is http.DefaultTransport as configured when NewVerifier is called. + Options apply in order, last value wins. +- Encoded credentials, including `v1.`, are limited to 8,192 bytes. + This is not an AWS session-token maximum, nor a guarantee the entire API request fits gateway header limits. + Incoming oversize proofs are invalid credentials. - STS success/error bodies are limited to 65,536 bytes, parsed as XML and closed. - Unknown metadata is tolerated; duplicate/missing identity fields, unexpected - namespaces, contradictory accounts/partitions and second documents fail. + Unknown metadata is tolerated; + duplicate/missing identity fields, unexpected namespaces, contradictory accounts/partitions and second documents fail. - Proofs expire at signing time plus 60 seconds, with no expired-proof grace. - Up to 30 seconds of future clock skew is allowed. Freshness is checked before - and after STS, independently of AWS enforcement of X-Amz-Expires. + Up to 30 seconds of future clock skew is allowed. + Freshness is checked before and after STS, independently of AWS enforcement of X-Amz-Expires. -A proof can be replayed to the same audience while fresh; the possible local -acceptance interval under skew is up to 90 seconds. It does not bind the API -method, body or action header and is not a single-use challenge. HTTPS is -required to the application and STS. Neither freshness nor audience validation -alone authenticates a proof: STS must accept its signature. +A proof can be replayed to the same audience while fresh; +the possible local acceptance interval under skew is up to 90 seconds. +It does not bind the API method, body or action header and is not a single-use challenge. +HTTPS is required to the application and STS. +Neither freshness nor audience validation alone authenticates a proof: STS must accept its signature. ## Endpoint and dependency policy -Production dependencies were checked against the Go module proxy on 2026-09-16: -AWS SDK core v1.47.0 and STS v1.51.0 were the latest stable releases. The root -adapter and identity package do not import the SDK; only iamproof does. +Production dependencies were checked against the Go module proxy on 2026-09-16: AWS SDK core v1.47.0 and STS v1.51.0 were the latest stable releases. +The root adapter and identity package do not import the SDK; +only iamproof does. -Both constructors require an explicit standard regional endpoint. They expose -no global, FIPS, dual-stack or arbitrary endpoint switch. Endpoint resolution -uses the SDK V2 resolver. Because that public result omits the partition ID, -the package obtains it from the SDK's public legacy resolver and requires both -resolvers to agree on endpoint and signing region. Disagreement fails -construction. No private SDK APIs or copied partition table are used. +Both constructors require an explicit standard regional endpoint. +They expose no global, FIPS, dual-stack or arbitrary endpoint switch. +Endpoint resolution uses the SDK V2 resolver. +Because that public result omits the partition ID, the package obtains it from the SDK's public legacy resolver and requires both resolvers to agree on endpoint and signing region. +Disagreement fails construction. +No private SDK APIs or copied partition table are used. -Local fixtures cover all eight partitions in that SDK release, including -commercial, GovCloud, China, the four ISO partitions and European Sovereign -Cloud. This checks metadata and signing reconstruction, not regional account -eligibility or live service availability. +Local fixtures cover all eight partitions in that SDK release, including commercial, GovCloud, China, the four ISO partitions and European Sovereign Cloud. +This checks metadata and signing reconstruction, not regional account eligibility or live service availability. ## Protocol and verification evidence -[Decision 0017](decisions/0017-iam-proof-protocol.md) specifies the compact JSON v2 -envelope, canonical unpadded base64url, fixed STS request and accepted error policy. -The library uses direct encoding/json/v2 for generation and parsing, and the -official SDK v4 signer for cryptography. It rejects unknown/duplicate JSON -members, nulls, invalid UTF-8, trailing data, noncanonical base64, wrong audience -or scope, and malformed dates/signatures before network work. - -Tests use synthetic credentials, fake transports, a local TLS server and virtual -time. They verify reconstruction against the official signer, tampering, -freshness boundaries, response validation, bounded reads, cleanup, cancellation, -error sanitization and concurrent use. There is no live STS or deployed Gateway -interoperability result yet; those checks require separate authorization. +[Decision 0017](decisions/0017-iam-proof-protocol.md) specifies the compact JSON v2 envelope, canonical unpadded base64url, fixed STS request and accepted error policy. +The library uses direct encoding/json/v2 for generation and parsing, and the official SDK v4 signer for cryptography. +It rejects unknown/duplicate JSON members, nulls, invalid UTF-8, trailing data, noncanonical base64, wrong audience or scope, and malformed dates/signatures before network work. + +Tests use synthetic credentials, fake transports, a local TLS server and virtual time. +They verify reconstruction against the official signer, tampering, freshness boundaries, response validation, bounded reads, cleanup, cancellation, error sanitization and concurrent use. +There is no live STS or deployed Gateway interoperability result yet; +those checks require separate authorization. Official sources: diff --git a/docs/plan.md b/docs/plan.md index 8c3b9e4..23d56ec 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,71 +1,62 @@ # Edge implementation plan -Status: accepted overall direction; individual contracts require review. +Status: accepted overall direction; +individual contracts require review. ## Objective -Build `github.com/asteroid-computing/go-lambda-edge`, with root package `edge`, -as a new Go 1.27 module. Adapt AWS API Gateway Lambda proxy events to ordinary -`net/http` handlers and provide complementary identity, authentication, -authorization, and consumer-test packages. +Build `github.com/asteroid-computing/go-lambda-edge`, with root package `edge`, as a new Go 1.27 module. +Adapt AWS API Gateway Lambda proxy events to ordinary `net/http` handlers and provide complementary identity, authentication, authorization, and consumer-test packages. Beakley was reviewed at `/Volumes/home/Developer/spmt-20260905/beakley`. -Its requirements and observed failures inform this design. This work is not a -port, source copy, or compatibility-preserving update of Beakley. +Its requirements and observed failures inform this design. +This work is not a port, source copy, or compatibility-preserving update of Beakley. ## Accepted direction - Set the module's Go baseline to 1.27.0. -- Use `encoding/json/v2` directly for our event, response, JWKS, and claim JSON - handling. Do not route our codec through `encoding/json`'s compatibility API. +- Use `encoding/json/v2` directly for our event, response, JWKS, and claim JSON handling. + Do not route our codec through `encoding/json`'s compatibility API. Check third-party parser behavior separately during dependency selection. - Keep ordinary `http.Handler` as the application boundary. -- Support the user's same-route goal: clients may authenticate using IAM - credentials or an OAuth bearer token without requiring built-in AWS_IAM - authorization. [Decision 0016](decisions/0016-mixed-iam-oauth-authentication.md) - accepts explicit credential selection and online STS-proof verification. - Include an exported Go helper that generates the signed GetCallerIdentity - proof, with consumer documentation and runnable client/server examples. +- Support the user's same-route goal: clients may authenticate using IAM credentials or an OAuth bearer token without requiring built-in AWS_IAM authorization. + [Decision 0016](decisions/0016-mixed-iam-oauth-authentication.md) accepts explicit credential selection and online STS-proof verification. + Include an exported Go helper that generates the signed GetCallerIdentity proof, with consumer documentation and runnable client/server examples. Decision 0017 accepts the detailed protocol and helper/verifier APIs. -- Provide first-class consumer request-header processing for header-selected - action dispatchers. Preserve custom incoming headers independently of response - combination rules. [Decision 0011](decisions/0011-request-header-processing.md) - accepts the standard HTTP extension boundary and immutable ActionHeader - selector, with explicit configuration and strict missing/ambiguous/invalid - selection errors. The user approved the Go review refinements on 2026-09-15. -- Own event serialization at a raw Lambda invocation boundary implementing - `Invoke(context.Context, []byte) ([]byte, error)`. +- Provide first-class consumer request-header processing for header-selected action dispatchers. + Preserve custom incoming headers independently of response combination rules. + [Decision 0011](decisions/0011-request-header-processing.md) accepts the standard HTTP extension boundary and immutable ActionHeader selector, with explicit configuration and strict missing/ambiguous/invalid selection errors. + The user approved the Go review refinements on 2026-09-15. +- Own event serialization at a raw Lambda invocation boundary implementing `Invoke(context.Context, []byte) ([]byte, error)`. - Reuse AWS Lambda Go event/response structs where they meet the contract; reserve custom representations for demonstrated limitations. -- Use the latest stable aws-lambda-go release, recorded explicitly in go.mod - for reproducibility. On 2026-09-14, the Go module proxy and AWS GitHub releases - both confirmed v1.55.0, already required here; it includes Go 1.27 compatibility. +- Use the latest stable aws-lambda-go release, recorded explicitly in go.mod for reproducibility. + On 2026-09-14, the Go module proxy and AWS GitHub releases both confirmed v1.55.0, already required here; + it includes Go 1.27 compatibility. - Support already typed events without an envelope JSON round trip inside edge. - Decision 0004 accepts HandleV1 and HandleV2 with matching AWS request/response - types, sharing translation and identity policy with raw invocation. + Decision 0004 accepts HandleV1 and HandleV2 with matching AWS request/response types, sharing translation and identity policy with raw invocation. - Support REST API proxy events, HTTP API payload 1.0, and HTTP API payload 2.0. API product and payload version are distinct concepts. -- Provide separate buffered and REST streaming adapters over shared request and - identity foundations. Both are implemented; deployed streaming remains unverified. +- Provide separate buffered and REST streaming adapters over shared request and identity foundations. + Both are implemented; + deployed streaming remains unverified. - Keep payload dispatch and the response writer private initially. - Preserve separate IAM/JWT caller representations and an anonymous zero value; enforce invariants rather than relying on comments about a union. - Separate token scopes, Cognito groups, and application-resolved grants. -- Preserve context-aware authorization and distinguish denial from dependency - failure. -- Preserve full-fidelity claims when the selected source provides them. Do not - claim to recover arrays or original numeric values from flattened data. -- Research substantive decisions and bring recommendations to the user before - implementing dependent behavior. See `AGENTS.md`. - -The user approved [decision 0001](decisions/0001-gateway-identity.md), superseding -the review's initial gateway-default proposal: no gateway-derived identity by -default; `WithGatewayIdentity(true)` opts in for both IAM and JWT/Cognito. Local -verification is explicitly composed. Constructor details and producer conflicts -are settled in decisions 0012–0018; decision 0019 accepts the now-implemented -common HTTP authentication selector. Decision 0020 accepts the now-implemented -Cognito access-token verifier and JWKS cache. Decision 0021 accepts authorization -rules and the consumer-owned resolution/dispatch boundary. +- Preserve context-aware authorization and distinguish denial from dependency failure. +- Preserve full-fidelity claims when the selected source provides them. + Do not claim to recover arrays or original numeric values from flattened data. +- Research substantive decisions and bring recommendations to the user before implementing dependent behavior. + See `AGENTS.md`. + +The user approved [decision 0001](decisions/0001-gateway-identity.md), superseding the review's initial gateway-default proposal: no gateway-derived identity by default; +`WithGatewayIdentity(true)` opts in for both IAM and JWT/Cognito. +Local verification is explicitly composed. +Constructor details and producer conflicts are settled in decisions 0012–0018; +decision 0019 accepts the now-implemented common HTTP authentication selector. +Decision 0020 accepts the now-implemented Cognito access-token verifier and JWKS cache. +Decision 0021 accepts authorization rules and the consumer-owned resolution/dispatch boundary. ## Planned package boundaries @@ -78,19 +69,16 @@ rules and the consumer-owned resolution/dispatch boundary. | `authz` | Exact identity predicates, ordered rules and context-aware application checks; consumers own grant resolution and HTTP dispatch | | `edgetest` (deferred) | Revisit exported fixtures after concrete consumer needs; decision 0023 | -Avoid a broad root-package facade that reexports every companion type. Keep AWS -event types out of transport-independent packages. Reassess any additional -exported package against a concrete consumer need; version-reporting machinery -does not need to precede a working adapter. +Avoid a broad root-package facade that reexports every companion type. +Keep AWS event types out of transport-independent packages. +Reassess any additional exported package against a concrete consumer need; +version-reporting machinery does not need to precede a working adapter. The accepted IAM client helper belongs to the module's exported Go API. -[Decision 0017](decisions/0017-iam-proof-protocol.md), accepted on 2026-09-16, -specifies a focused `iamproof` -package, compact versioned proof, signed audience, short freshness policy and -bounded regional STS verification. The production generator/verifier and local -contract tests are now implemented. The user also -approved the IAM caller forms in decision 0015. The shared caller/context -foundation, gateway producers and callable buffered adapter are implemented. +[Decision 0017](decisions/0017-iam-proof-protocol.md), accepted on 2026-09-16, specifies a focused `iamproof` package, compact versioned proof, signed audience, short freshness policy and bounded regional STS verification. +The production generator/verifier and local contract tests are now implemented. +The user also approved the IAM caller forms in decision 0015. +The shared caller/context foundation, gateway producers and callable buffered adapter are implemented. ## Milestones and acceptance criteria @@ -103,409 +91,313 @@ foundation, gateway producers and callable buffered adapter are implemented. ### 1. Specify the transport contracts -Accepted: [decision 0002](decisions/0002-adapter-boundary.md) settles construction, -option application, and transport invocation-failure handling. Its independent -constructor/options foundation is implemented while the event/HTTP contracts are -reviewed. Constructor tests pass with race detection, vet and formatting checks -pass, and package builds pass for Linux arm64 and amd64. - -Accepted: [decision 0003](decisions/0003-event-decoding.md) settles AWS type reuse, -version dispatch, structural validation, and the decoder fixture matrix. The -private decoder and typed validators are implemented and tested against AWS -Lambda Go v1.55.0. [Decision 0004](decisions/0004-typed-events.md) accepts typed -methods that skip envelope JSON work inside edge. These entry points await the -response contract and gateway identity production before public wiring. - -Accepted: [decision 0005](decisions/0005-http-request-translation.md) settles -request URLs, header/query merging, cookies, body handling, invocation metadata, -and request ownership. Shared request conversion is implemented with local HTTP -comparisons, race tests, fuzzing, vet, and Lambda-target builds passing. - -Accepted: [decision 0006](decisions/0006-buffered-responses.md) specifies -buffered response commitment/encoding, unsupported features, payload bounds, -invocation completion, and the documented multipart cleanup limit. - -The accepted [2026-09-14 plan review](reviews/2026-09-14-plan-review.md) requires -response-contract refinements and completing a runnable adapter, including the -minimum gateway identity foundation, before broader authentication work. These -enhancements are incorporated into decision 0006. Decision 0010 settles concrete -metadata/header policies, with errors and identity subsequently settled in -decisions 0012–0018. - -The user accepted the qualification to decision 0007: do not freeze arbitrary -64 KiB/1,024-entry metadata limits or the initial ten-field list without stronger -resource and compatibility justification. [Decision 0010](decisions/0010-response-header-design.md) -now accepts shared snapshots, a 25-field V2 combination audit, weighted resource -accounting, and a conservative exact streaming-prefix limit. The 2026-09-15 -allocation probe revised the resource policy to a 256 KiB default with -an explicit positive override up to 6 MiB; the user approved it on 2026-09-15. -The shared snapshot, suppression/accounting logic, V1/V2 projections, option -validation and bounded streaming metadata prefix are implemented. The buffered -and streaming writers now use the shared header layer. -Streaming is specified in -[decision 0008](decisions/0008-streaming-architecture.md): explicit REST response -streaming alongside the buffered adapter, sharing HTTP/identity foundations. -The high-level architecture is accepted. [Decision 0009](decisions/0009-streaming-boundaries.md) -accepts concrete APIs and an ownership/state graph. The private bridge and shared -invocation ownership primitives are implemented. A local SDK Runtime API -probe demonstrates incremental delivery, raw/typed capture, closure, and error -trailers; it also exposes a bytes-plus-error reader issue and a missing header -relative to AWS's documented streaming contract. The production streaming API -is now implemented; deployed API Gateway behavior is not verified. - -The private bridge publishes an owned prefix through an explicit handoff before -body writes can block, uses an unbuffered pipe, and transfers cleanup to one -producer. Close and parent cancellation release blocked I/O; terminal reads and -Close join cleanup and error reporting. Early panics are rethrown at entry, late -panics become sanitized terminal errors, and reader results defer errors that -accompany bytes. Synthetic concurrency tests cover these boundaries and reporter -failures. Full race tests (including the existing SDK probe), vet, formatting, -and Linux arm64/amd64 builds pass on Go 1.27.1. HTTP streaming commitment, -sniffing, framing validation, and public entry points are now implemented under -the subsequent writer review. - -The [2026-09-17 streaming writer review](decisions/0022-streaming-writer-review.md) -confirms the approved state/ownership graph against completed authn/authz and the -current AWS/Go documentation. The user approved S1 (no automatic streaming -Content-Length) and S2 (shared sniffing behavior for empty Content-Encoding) on -2026-09-17 and authorized implementation. SDK v1.55.0 remains latest stable. -Local probes reconfirm incremental -delivery and the missing streaming-mode header, and additionally observe Runtime -API connection reuse, another difference from the custom-runtime guide. Retain -the existing deployment release gate for both observations; no live AWS test has -run. The streaming writer, NewStreaming, Handle, HandleV1 and -WithStreamErrorReporter are implemented. Shared request preparation preserves -buffered behavior, identity policy and request cleanup. The SDK probe now drives -the production adapter in raw/typed and late-error/panic cases, observes body -delivery before producer completion and checks reader closure before reuse. -Runnable SSE, direct JSON v2 NDJSON, binary and gzip examples accompany the -[consumer guide](streaming.md). Authentication/authorization integration tests -cover both streaming entry points. Full module race tests, vet, formatting and -Linux arm64/amd64 builds pass locally on Go 1.27.1. - -The [SDK documentation follow-up](reviews/2026-09-17-sdk-streaming-follow-up.md) -reconfirmed latest stable versions and reproduced the transport observations with -the SDK's own REST streaming response type. Ten local SDK probe cases now pass. +Accepted: [decision 0002](decisions/0002-adapter-boundary.md) settles construction, option application, and transport invocation-failure handling. +Its independent constructor/options foundation is implemented while the event/HTTP contracts are reviewed. +Constructor tests pass with race detection, vet and formatting checks pass, and package builds pass for Linux arm64 and amd64. + +Accepted: [decision 0003](decisions/0003-event-decoding.md) settles AWS type reuse, version dispatch, structural validation, and the decoder fixture matrix. +The private decoder and typed validators are implemented and tested against AWS Lambda Go v1.55.0. +[Decision 0004](decisions/0004-typed-events.md) accepts typed methods that skip envelope JSON work inside edge. +These entry points await the response contract and gateway identity production before public wiring. + +Accepted: [decision 0005](decisions/0005-http-request-translation.md) settles request URLs, header/query merging, cookies, body handling, invocation metadata, and request ownership. +Shared request conversion is implemented with local HTTP comparisons, race tests, fuzzing, vet, and Lambda-target builds passing. + +Accepted: [decision 0006](decisions/0006-buffered-responses.md) specifies buffered response commitment/encoding, unsupported features, payload bounds, invocation completion, and the documented multipart cleanup limit. + +The accepted [2026-09-14 plan review](reviews/2026-09-14-plan-review.md) requires response-contract refinements and completing a runnable adapter, including the minimum gateway identity foundation, before broader authentication work. +These enhancements are incorporated into decision 0006. +Decision 0010 settles concrete metadata/header policies, with errors and identity subsequently settled in decisions 0012–0018. + +The user accepted the qualification to decision 0007: do not freeze arbitrary 64 KiB/1,024-entry metadata limits or the initial ten-field list without stronger resource and compatibility justification. +[Decision 0010](decisions/0010-response-header-design.md) now accepts shared snapshots, a 25-field V2 combination audit, weighted resource accounting, and a conservative exact streaming-prefix limit. +The 2026-09-15 allocation probe revised the resource policy to a 256 KiB default with an explicit positive override up to 6 MiB; +the user approved it on 2026-09-15. +The shared snapshot, suppression/accounting logic, V1/V2 projections, option validation and bounded streaming metadata prefix are implemented. +The buffered and streaming writers now use the shared header layer. +Streaming is specified in [decision 0008](decisions/0008-streaming-architecture.md): explicit REST response streaming alongside the buffered adapter, sharing HTTP/identity foundations. +The high-level architecture is accepted. +[Decision 0009](decisions/0009-streaming-boundaries.md) accepts concrete APIs and an ownership/state graph. +The private bridge and shared invocation ownership primitives are implemented. +A local SDK Runtime API probe demonstrates incremental delivery, raw/typed capture, closure, and error trailers; +it also exposes a bytes-plus-error reader issue and a missing header relative to AWS's documented streaming contract. +The production streaming API is now implemented; +deployed API Gateway behavior is not verified. + +The private bridge publishes an owned prefix through an explicit handoff before body writes can block, uses an unbuffered pipe, and transfers cleanup to one producer. +Close and parent cancellation release blocked I/O; +terminal reads and Close join cleanup and error reporting. +Early panics are rethrown at entry, late panics become sanitized terminal errors, and reader results defer errors that accompany bytes. +Synthetic concurrency tests cover these boundaries and reporter failures. +Full race tests (including the existing SDK probe), vet, formatting, and Linux arm64/amd64 builds pass on Go 1.27.1. +HTTP streaming commitment, sniffing, framing validation, and public entry points are now implemented under the subsequent writer review. + +The [2026-09-17 streaming writer review](decisions/0022-streaming-writer-review.md) confirms the approved state/ownership graph against completed authn/authz and the current AWS/Go documentation. +The user approved S1 (no automatic streaming Content-Length) and S2 (shared sniffing behavior for empty Content-Encoding) on 2026-09-17 and authorized implementation. +SDK v1.55.0 remains latest stable. +Local probes reconfirm incremental delivery and the missing streaming-mode header, and additionally observe Runtime API connection reuse, another difference from the custom-runtime guide. +Retain the existing deployment release gate for both observations; +no live AWS test has run. +The streaming writer, NewStreaming, Handle, HandleV1 and WithStreamErrorReporter are implemented. +Shared request preparation preserves buffered behavior, identity policy and request cleanup. +The SDK probe now drives the production adapter in raw/typed and late-error/panic cases, observes body delivery before producer completion and checks reader closure before reuse. +Runnable SSE, direct JSON v2 NDJSON, binary and gzip examples accompany the [consumer guide](streaming.md). +Authentication/authorization integration tests cover both streaming entry points. +Full module race tests, vet, formatting and Linux arm64/amd64 builds pass locally on Go 1.27.1. + +The [SDK documentation follow-up](reviews/2026-09-17-sdk-streaming-follow-up.md) reconfirmed latest stable versions and reproduced the transport observations with the SDK's own REST streaming response type. +Ten local SDK probe cases now pass. It records the evidence and questions prepared for the user's AWS outreach; the deployment qualification remains open. -Implemented under decision 0006: bounded direct JSON v2 envelope encoding, -text/base64 body selection, and a shared invocation scope that preserves primary -errors, cleans up on failures/panics, and checks parent cancellation. The HTTP -writer now uses the shared header implementation from -[decision 0010](decisions/0010-response-header-design.md), enforces commitment and -body/length/status rules, and returns typed AWS response projections without -JSON work. Tests compare ordinary behavior with net/http and exercise request -conversion, mux/range/gzip handling and invocation cleanup. Public buffered wiring -subsequently completed with the identity contracts. [Initial codec benchmarks](benchmarks.md) record -allocation baselines; they do not establish a peak-memory bound. +Implemented under decision 0006: bounded direct JSON v2 envelope encoding, text/base64 body selection, and a shared invocation scope that preserves primary errors, cleans up on failures/panics, and checks parent cancellation. +The HTTP writer now uses the shared header implementation from [decision 0010](decisions/0010-response-header-design.md), enforces commitment and body/length/status rules, and returns typed AWS response projections without JSON work. +Tests compare ordinary behavior with net/http and exercise request conversion, mux/range/gzip handling and invocation cleanup. +Public buffered wiring subsequently completed with the identity contracts. +[Initial codec benchmarks](benchmarks.md) record allocation baselines; +they do not establish a peak-memory bound. Research and present a contract covering: - Raw invocation API, constructor/options, configuration validation, and errors. -- Discrimination between all three supported event cases; reject unsupported - versions and malformed shapes without fallback to a different format. -- Strict JSON v2 handling of duplicate names and invalid UTF-8, with tolerance - for additional AWS fields. -- V1 decoded paths versus V2 raw paths, escaped segments, query representation, - custom-domain mappings, and information that AWS does not preserve. -- Headers and multivalue precedence per key, cookies, host, protocol, - `RequestURI`, source address, body decoding/length/cleanup, and context lifetime. -- Status commitment, header snapshots, implicit responses, content-type - detection, HEAD/bodyless statuses, informational responses, trailers, and - accurately advertised optional `ResponseWriter` capabilities. -- Binary response selection and REST API deployment requirements; response-size - limits, including base64 and JSON-envelope overhead. +- Discrimination between all three supported event cases; + reject unsupported versions and malformed shapes without fallback to a different format. +- Strict JSON v2 handling of duplicate names and invalid UTF-8, with tolerance for additional AWS fields. +- V1 decoded paths versus V2 raw paths, escaped segments, query representation, custom-domain mappings, and information that AWS does not preserve. +- Headers and multivalue precedence per key, cookies, host, protocol, `RequestURI`, source address, body decoding/length/cleanup, and context lifetime. +- Status commitment, header snapshots, implicit responses, content-type detection, HEAD/bodyless statuses, informational responses, trailers, and accurately advertised optional `ResponseWriter` capabilities. +- Binary response selection and REST API deployment requirements; + response-size limits, including base64 and JSON-envelope overhead. - Separation of HTTP application outcomes from invocation/translation faults. -Acceptance: a reviewed contract and fixture matrix exist before the corresponding -behavior is implemented. Resolve smaller decisions incrementally rather than -presenting an entire frozen public API at once. +Acceptance: a reviewed contract and fixture matrix exist before the corresponding behavior is implemented. +Resolve smaller decisions incrementally rather than presenting an entire frozen public API at once. ### 2. Implement and prove the HTTP adapter - [x] Create the Go 1.27 module and constructor/options foundation after review. -- [x] Implement the private decoder and typed semantic validators with direct - JSON v2, SDK types, opaque authorizer data, fixtures, and fuzzing. +- [x] Implement the private decoder and typed semantic validators with direct JSON v2, SDK types, opaque authorizer data, fixtures, and fuzzing. - [x] Implement shared HTTP request conversion and request-lifetime cleanup. -- [x] Implement the accepted action-header selector with ordinary HTTP middleware - examples and native HTTP/raw/typed Gateway request contract tests. -- [x] Implement shared response-header snapshots, budget configuration, V1/V2 - projections and bounded REST streaming metadata prefix encoding. +- [x] Implement the accepted action-header selector with ordinary HTTP middleware examples and native HTTP/raw/typed Gateway request contract tests. +- [x] Implement shared response-header snapshots, budget configuration, V1/V2 projections and bounded REST streaming metadata prefix encoding. - [x] Implement the private buffered HTTP writer and gateway response conversion. -- [x] Implement accepted public error categories and sanitized diagnostics across - existing private stages, including cleanup and streaming bridge faults. +- [x] Implement accepted public error categories and sanitized diagnostics across existing private stages, including cleanup and streaming bridge faults. - [x] Implement bounded envelope/body encoding and shared invocation cleanup. -- [x] Extract shared invocation ownership and implement the private streaming - bridge, with handoff, backpressure, cancellation, cleanup, and error tests. -- [x] Implement the streaming HTTP writer, raw/typed REST entry points, reporter, - examples and authentication/authorization integration tests under 0022. -- [ ] Resolve the streaming SDK deployment qualification through authoritative - clarification, a suitable stable SDK fix or a separately approved live test. -- [x] Implement the minimum reviewed identity/context and native gateway producers - needed to honor WithGatewayIdentity(true) before publishing invocation. +- [x] Extract shared invocation ownership and implement the private streaming bridge, with handoff, backpressure, cancellation, cleanup, and error tests. +- [x] Implement the streaming HTTP writer, raw/typed REST entry points, reporter, examples and authentication/authorization integration tests under 0022. +- [ ] Resolve the streaming SDK deployment qualification through authoritative clarification, a suitable stable SDK fix or a separately approved live test. +- [x] Implement the minimum reviewed identity/context and native gateway producers needed to honor WithGatewayIdentity(true) before publishing invocation. - [x] Implement raw invocation after the remaining transport contracts are reviewed. -- [x] Implement the accepted typed entry points after shared HTTP translation - review, documenting upstream codec ownership. +- [x] Implement the accepted typed entry points after shared HTTP translation review, documenting upstream codec ownership. - Implement the adapter from scratch using JSON v2 directly. - Test raw invocation through the actual pinned AWS Lambda Go SDK boundary. -- Add runnable raw/typed registration examples, sequential/concurrent invocation - isolation checks, and ServeMux, Redirect, ServeContent/Range, compression, and - cookie integration cases. +- Add runnable raw/typed registration examples, sequential/concurrent invocation isolation checks, and ServeMux, Redirect, ServeContent/Range, compression, and cookie integration cases. - Exercise documented event fixtures and adversarial/malformed input. -- Compare relevant request/response behavior with real `net/http` serving, - documenting unavoidable transport differences. -- Fuzz decoding and path/query conversion; add targeted allocation benchmarks. +- Compare relevant request/response behavior with real `net/http` serving, documenting unavoidable transport differences. +- Fuzz decoding and path/query conversion; + add targeted allocation benchmarks. - Run appropriate tests, race detection, vet, and Lambda-target build checks. -- Put those checks in Go 1.27 CI and establish raw/typed, binary, and near-limit - allocation baselines before setting performance thresholds. +- Put those checks in Go 1.27 CI and establish raw/typed, binary, and near-limit allocation baselines before setting performance thresholds. -CI configuration is present for Go 1.27.0/latest 1.27 patch tests, race detection, -vet, and Lambda-target builds. It has not run on GitHub yet. Local Go 1.27.1 race -tests, vet, formatting checks, and both target builds pass for both public -adapters. SDK registration, gateway recognition/fidelity, owned claims, concurrent -identity isolation, multipart cleanup, panic/cancellation and raw envelope limits -are exercised. Runnable examples and initial public allocation benchmarks are -present. HTTP 1.0 authentication coverage is explicitly qualified in -[the fixture inventory](gateway-fixtures.md); no live deployment was exercised. +CI configuration is present for Go 1.27.0/latest 1.27 patch tests, race detection, vet, and Lambda-target builds. +It has not run on GitHub yet. +Local Go 1.27.1 race tests, vet, formatting checks, and both target builds pass for both public adapters. +SDK registration, gateway recognition/fidelity, owned claims, concurrent identity isolation, multipart cleanup, panic/cancellation and raw envelope limits are exercised. +Runnable examples and initial public allocation benchmarks are present. +HTTP 1.0 authentication coverage is explicitly qualified in [the fixture inventory](gateway-fixtures.md); +no live deployment was exercised. -Acceptance: the same application handler behaves as specified across all three -event cases; no auth or codec behavior depends on accidental SDK dispatch. +Acceptance: the same application handler behaves as specified across all three event cases; +no auth or codec behavior depends on accidental SDK dispatch. ### 3. Identity and gateway producers -Design graph prepared on 2026-09-15 in [decision 0012](decisions/0012-shared-identity.md), -with [public invocation errors in decision 0013](decisions/0013-public-invocation-errors.md). -The user accepted both records as a starting point on 2026-09-15. The deferred -claim API, resource limits and native-producer coverage were subsequently resolved -in decisions 0014–0018. -The accepted order is caller/context/error foundations, precise claim API and -native-producer fixtures, then raw/typed buffered invocation. Broader local -authentication and authorization follow that shared foundation. - -[Decision 0014](decisions/0014-claims-api.md), accepted on 2026-09-16, specifies the -claims constructor/accessor surface, SDK number-preserving input compatibility, -ownership and weighted resource limits, and gateway normalization qualifications. -The user approved its SDK numeric compatibility and gateway qualifications and -authorized implementation. Claims constructors, immutable accessors, strict raw -JSON and direct typed construction, byte/depth limits, and sanitized errors are -implemented. The adapter budget option is validated and a private invocation-error -translation boundary is tested and now wired through gateway extraction and -public invocation under decision 0018. -Production allocation measurements supplement the original probe. Contract tests, -SDK integration, race detection, vet, JSON fuzzing and Lambda-target builds pass. -Caller/context construction, JWT normalization and dedicated-scope accounting -are now implemented after approval of [decision 0015](decisions/0015-iam-caller-forms.md) -and 0017. IAM literal path punctuation follows the user's approved refinement. -The private invocation guard rejects inherited callers. Full race tests, vet, -formatting and Linux arm64/amd64 builds pass. Native-producer fixture review -was approved in decision 0018 on 2026-09-16. Native extraction and buffered public -invocation wiring are now implemented and tested. Custom-authorizer mapping -remains a later explicit design; it is not silently treated as native identity. - -- Review caller constructors, immutable/owned data, validation, anonymous state, - and context-presence semantics. -- Preserve issuer plus subject for JWT application identity; do not treat a - bare subject as globally unique or assume every M2M caller has a user subject. +Design graph prepared on 2026-09-15 in [decision 0012](decisions/0012-shared-identity.md), with [public invocation errors in decision 0013](decisions/0013-public-invocation-errors.md). +The user accepted both records as a starting point on 2026-09-15. +The deferred claim API, resource limits and native-producer coverage were subsequently resolved in decisions 0014–0018. +The accepted order is caller/context/error foundations, precise claim API and native-producer fixtures, then raw/typed buffered invocation. +Broader local authentication and authorization follow that shared foundation. + +[Decision 0014](decisions/0014-claims-api.md), accepted on 2026-09-16, specifies the claims constructor/accessor surface, SDK number-preserving input compatibility, ownership and weighted resource limits, and gateway normalization qualifications. +The user approved its SDK numeric compatibility and gateway qualifications and authorized implementation. +Claims constructors, immutable accessors, strict raw JSON and direct typed construction, byte/depth limits, and sanitized errors are implemented. +The adapter budget option is validated and a private invocation-error translation boundary is tested and now wired through gateway extraction and public invocation under decision 0018. +Production allocation measurements supplement the original probe. +Contract tests, SDK integration, race detection, vet, JSON fuzzing and Lambda-target builds pass. +Caller/context construction, JWT normalization and dedicated-scope accounting are now implemented after approval of [decision 0015](decisions/0015-iam-caller-forms.md) and 0017. +IAM literal path punctuation follows the user's approved refinement. +The private invocation guard rejects inherited callers. +Full race tests, vet, formatting and Linux arm64/amd64 builds pass. +Native-producer fixture review was approved in decision 0018 on 2026-09-16. +Native extraction and buffered public invocation wiring are now implemented and tested. +Custom-authorizer mapping remains a later explicit design; +it is not silently treated as native identity. + +- Review caller constructors, immutable/owned data, validation, anonymous state, and context-presence semantics. +- Preserve issuer plus subject for JWT application identity; + do not treat a bare subject as globally unique or assume every M2M caller has a user subject. - Keep audience, app client, token use, and provenance distinct. - Implement the approved opt-in gateway policy for native JWT/Cognito and IAM. -- Review custom-authorizer mapping for both payload families, including context, - error returns, provenance, and conflicts between identity sources. -- Expose only the gateway metadata handlers actually need, separately from - authenticated identity. Do not synthesize cryptographic guarantees from it. +- Review custom-authorizer mapping for both payload families, including context, error returns, provenance, and conflicts between identity sources. +- Expose only the gateway metadata handlers actually need, separately from authenticated identity. + Do not synthesize cryptographic guarantees from it. -Acceptance: invalid/ambiguous identities cannot authorize; gateway and token -claims cannot be silently mixed; unavailable claim fidelity remains explicit. +Acceptance: invalid/ambiguous identities cannot authorize; +gateway and token claims cannot be silently mixed; +unavailable claim fidelity remains explicit. ### 4. Local authentication: Cognito/JWKS and IAM credentials -The implemented HTTP authentication design is recorded in accepted -[decision 0019](decisions/0019-http-authentication-selector.md): shared HTTP -selection, explicit verifier dependencies, header limits, context composition, -error interoperability and challenges. The user approved A1-A6 on 2026-09-16; +The implemented HTTP authentication design is recorded in accepted [decision 0019](decisions/0019-http-authentication-selector.md): shared HTTP selection, explicit verifier dependencies, header limits, context composition, error interoperability and challenges. +The user approved A1-A6 on 2026-09-16; authn configuration, selection, errors, middleware and examples are implemented. The IAM proof package remains independently usable. -- [x] Implement the accepted protocol/helper/verifier contract in decision 0017 - after the shared foundation and callable buffered adapter. Production iamproof - uses SDK core v1.47.0 and STS v1.51.0, rechecked as latest stable on 2026-09-16. - [The consumer guide](iamproof.md) covers credential ownership, endpoints, - replay/limits, error handling and the remaining integration qualifications. -- [x] Implement the shared HTTP selector and challenges under decision 0019, - preserving the proof, provenance, binding, replay, endpoint and error contracts. - Configuration and verifier functions are explicit, with an SDK-independent - package and consumer-owned action dispatch and authorization. - [The authn guide](authn.md) documents IAM error mapping, header bounds, - cancellation and custom JSON response handling. -- Provide exported Go proof generation and consumer documentation with runnable - examples showing IAM and OAuth clients calling the same protected route. - IAM generation, selector/dispatcher examples and local mixed-identity contract - tests now exist. The original selector example still labels its synthetic - Bearer function; Cognito configuration examples and actual RS256 verification - now accompany it. Deployed interoperability remains unverified. - -The authn milestone passes full module race tests, vet, formatting checks and -Linux arm64/amd64 builds on Go 1.27.1. Authorization-parser fuzzing completed -726,457 executions without failures. Native in-memory HTTP and raw/typed -Gateway fixtures verify challenges and IAM-helper composition with local STS -fixtures. No live AWS or GitHub CI run was performed for these changes. - -- [x] [Decision 0020](decisions/0020-cognito-verification.md) records the accepted - Cognito access-token verifier/JWKS design, reviewed against AWS documentation, - current identity/authn code and current stable Go JWT libraries on 2026-09-16. - It specifies direct JSON v2 plus stdlib RS256, per-client resource binding and - explicit unbound allowances for M2M/SDK flows, and bounded on-demand key caching. - The user approved C1-C7 on 2026-09-16; the verifier/cache are implemented. -- [x] Require explicit issuer/client restrictions; there is no implicit all-client - policy. Constructors perform no I/O and own their client configuration. -- [x] Keep access-token `client_id`, ID-token audience, and access-token resource - audience validation separate. Preserve access-token-only defaults unless - deliberately widened. -- [x] Implement reviewed algorithm/key selection, expiry/not-before/issued-at - behavior, leeway and malformed claims. Own JSON v2 parsing and use stdlib RSA. +- [x] Implement the accepted protocol/helper/verifier contract in decision 0017 after the shared foundation and callable buffered adapter. + Production iamproof uses SDK core v1.47.0 and STS v1.51.0, rechecked as latest stable on 2026-09-16. + [The consumer guide](iamproof.md) covers credential ownership, endpoints, replay/limits, error handling and the remaining integration qualifications. +- [x] Implement the shared HTTP selector and challenges under decision 0019, preserving the proof, provenance, binding, replay, endpoint and error contracts. + Configuration and verifier functions are explicit, with an SDK-independent package and consumer-owned action dispatch and authorization. + [The authn guide](authn.md) documents IAM error mapping, header bounds, cancellation and custom JSON response handling. +- Provide exported Go proof generation and consumer documentation with runnable examples showing IAM and OAuth clients calling the same protected route. + IAM generation, selector/dispatcher examples and local mixed-identity contract tests now exist. + The original selector example still labels its synthetic Bearer function; + Cognito configuration examples and actual RS256 verification now accompany it. + Deployed interoperability remains unverified. + +The authn milestone passes full module race tests, vet, formatting checks and Linux arm64/amd64 builds on Go 1.27.1. +Authorization-parser fuzzing completed 726,457 executions without failures. +Native in-memory HTTP and raw/typed Gateway fixtures verify challenges and IAM-helper composition with local STS fixtures. +No live AWS or GitHub CI run was performed for these changes. + +- [x] [Decision 0020](decisions/0020-cognito-verification.md) records the accepted Cognito access-token verifier/JWKS design, reviewed against AWS documentation, current identity/authn code and current stable Go JWT libraries on 2026-09-16. + It specifies direct JSON v2 plus stdlib RS256, per-client resource binding and explicit unbound allowances for M2M/SDK flows, and bounded on-demand key caching. + The user approved C1-C7 on 2026-09-16; + the verifier/cache are implemented. +- [x] Require explicit issuer/client restrictions; + there is no implicit all-client policy. + Constructors perform no I/O and own their client configuration. +- [x] Keep access-token `client_id`, ID-token audience, and access-token resource audience validation separate. + Preserve access-token-only defaults unless deliberately widened. +- [x] Implement reviewed algorithm/key selection, expiry/not-before/issued-at behavior, leeway and malformed claims. + Own JSON v2 parsing and use stdlib RSA. - [x] Separate cache freshness, unknown-key refresh, and failure backoff. -- [x] Bound network duration and JWKS response size. Define stale-key behavior, - rotation, concurrency, waiter cancellation, and HTTP client ownership. -- [x] Use deterministic cache/rotation/outage tests with testing/synctest and - native in-memory HTTP integration, plus an independent RFC RS256 vector. +- [x] Bound network duration and JWKS response size. + Define stale-key behavior, rotation, concurrency, waiter cancellation, and HTTP client ownership. +- [x] Use deterministic cache/rotation/outage tests with testing/synctest and native in-memory HTTP integration, plus an independent RFC RS256 vector. -The Cognito milestone passes full race tests, vet, formatting and Linux -arm64/amd64 builds on Go 1.27.1. Final fixed-fixture fuzz runs completed 136,129 -token cases and 280,903 JWKS cases without failures. Production authn remains -stdlib/identity-only. [The Cognito guide](cognito.md) documents configuration, -resource binding, cache ownership and offline-verification limitations. There -was no live AWS or GitHub CI run. +The Cognito milestone passes full race tests, vet, formatting and Linux arm64/amd64 builds on Go 1.27.1. +Final fixed-fixture fuzz runs completed 136,129 token cases and 280,903 JWKS cases without failures. +Production authn remains stdlib/identity-only. +[The Cognito guide](cognito.md) documents configuration, resource binding, cache ownership and offline-verification limitations. +There was no live AWS or GitHub CI run. -Acceptance: malformed credentials fail closed, failures do not create fetch -storms, and dependency outages remain distinguishable from invalid credentials. +Acceptance: malformed credentials fail closed, failures do not create fetch storms, and dependency outages remain distinguishable from invalid credentials. ### 5. Authorization and consumer ergonomics -[Decision 0021](decisions/0021-authorization-rules.md), approved on 2026-09-17, -accepts the initial authz API and design graph: immutable rules over explicit caller, -action and resource facts, exact predicates and ordered combinators. Application -principal/grant resolution and HTTP dispatch initially remain consumer-owned -through context-aware custom checks and runnable examples. Z1–Z7 are implemented -and locally validated. Production authz uses only the standard library and identity. - -- [x] Implement immutable checks, ordered All/Any, explicit action/resource - requests, exact JWT/source/IAM predicates and bounded policy trees. -- [x] Preserve scope/group/grant separation and consumer-owned principal - resolution through context-aware custom checks. -- [x] Document/test 401, 403 and 503 boundaries, safe Bearer challenges and - cancellation. Public routes bypass protected-route authorization explicitly. -- [x] Match exact caller ARNs and explicit role-session families, including - partition/account/path boundaries and role-name recreation limitations. -- [x] Reject zero/empty/nil rules and anonymous callers; sanitize callback errors. -- [x] Provide consumer fixtures, runnable examples and the - [authorization guide](authz.md). Logging hooks and a generic exported - principal/grant resolver remain deferred under decision 0021. +[Decision 0021](decisions/0021-authorization-rules.md), approved on 2026-09-17, accepts the initial authz API and design graph: immutable rules over explicit caller, action and resource facts, exact predicates and ordered combinators. +Application principal/grant resolution and HTTP dispatch initially remain consumer-owned through context-aware custom checks and runnable examples. +Z1–Z7 are implemented and locally validated. +Production authz uses only the standard library and identity. + +- [x] Implement immutable checks, ordered All/Any, explicit action/resource requests, exact JWT/source/IAM predicates and bounded policy trees. +- [x] Preserve scope/group/grant separation and consumer-owned principal resolution through context-aware custom checks. +- [x] Document/test 401, 403 and 503 boundaries, safe Bearer challenges and cancellation. + Public routes bypass protected-route authorization explicitly. +- [x] Match exact caller ARNs and explicit role-session families, including partition/account/path boundaries and role-name recreation limitations. +- [x] Reject zero/empty/nil rules and anonymous callers; + sanitize callback errors. +- [x] Provide consumer fixtures, runnable examples and the [authorization guide](authz.md). + Logging hooks and a generic exported principal/grant resolver remain deferred under decision 0021. Full race tests, vet, formatting and Linux arm64/amd64 builds pass on Go 1.27.1. -Policy fuzzing completed 45,257 cases without failure. Native HTTP and five -raw/typed Gateway paths exercise authorization after Cognito signature and IAM -proof verification using local synthetic provider responses. Tests cover grant -denials/outages, provider call counts, immutable action/resource selection, safe -responses and cancellation. No live AWS or GitHub CI run occurred. +Policy fuzzing completed 45,257 cases without failure. +Native HTTP and five raw/typed Gateway paths exercise authorization after Cognito signature and IAM proof verification using local synthetic provider responses. +Tests cover grant denials/outages, provider call counts, immutable action/resource selection, safe responses and cancellation. +No live AWS or GitHub CI run occurred. -Acceptance: examples work both in Lambda and ordinary HTTP applications, while -authorization decisions remain inspectable and deny by default. +Acceptance: examples work both in Lambda and ordinary HTTP applications, while authorization decisions remain inspectable and deny by default. ### 6. Consumer readiness and release preparation — local work complete The user authorized a consumer-readiness design review on 2026-09-20. -[Decision 0023](decisions/0023-consumer-readiness.md) records the code/documentation -evidence, the Beakley failure-case coverage matrix and recommendations R1–R6. -The user approved R1–R5 on 2026-09-20, including deferring exported `edgetest` -helpers. The license/copyright choice remains open and does not block local work. - -- [x] Review public composition, existing examples, fixture provenance, deferred - features, package dependencies and CI/release readiness. -- [x] Approve R1–R5: complete reference application, deferred edgetest exports, - consumer outcomes, precise support matrix and v0 preparation. The license - choice remains open. -- [x] Implement the reference application and consumer-testing - guide using the existing public APIs, with local provider fixtures. -- [x] Reconcile support/status documentation, update the indirect - Smithy patch in isolation, and record incremental streaming baselines. -- [x] Obtain actual GitHub CI evidence for the foundation PR; all four jobs - passed at f6a2624. Later commits require their own passing checks. -- [ ] Verify the main release workflow and resolve publication prerequisites - before a release. Streaming deployment and live identity interoperability - remain separate qualifications. - -The [complete dispatcher](../examples/dispatcher/README.md), [testing guide](consumer-testing.md), -[support matrix](support.md) and [release checklist](release-readiness.md) are -implemented. Local RSA/JWKS and synthetic STS fixtures exercise both credentials; -streaming lifecycle tests consume incrementally. The external-module check and -CI wiring are present. Smithy is updated to v1.28.2, and streaming allocation -baselines are recorded. No library API or transport policy changed. - -Full local race tests, vet, module verification, formatting, external consumption -and Linux arm64/amd64 builds pass on Go 1.27.1 (2026-09-21). The subsequent -authorized push and passing PR CI are recorded below. The license and deployment -qualifications remain open; no release publication or AWS deployment occurred. - -Release automation follow-up (2026-09-21): the user selected Release Please and -its GitHub Action, plus checks on PRs against main. [Decision 0024](decisions/0024-release-automation.md) -records the approved GitHub App credential and initial v0.1.0, with pre-major -versioning. The original main-validation dependency is superseded by the -2026-09-27 user direction: Go validation runs on PRs only, including release PRs; -main pushes run only release automation. Local wiring and validation are complete: both workflows -pass actionlint, release JSON passes the bundled schema, and existing Go jobs are -unchanged. See the [release guide](releases.md). - -Subsequent authorized setup and publication of the working branch are complete: -the new App Client ID and private key are configured as organization Actions -secrets with access granted to this repository, and -[PR #1](https://github.com/asteroid-computing/go-lambda-edge/pull/1) merged at -24f7686 on 2026-09-27. -[The PR run at f6a2624](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590) -passed both Go test jobs and both Linux build jobs, verified on 2026-09-27. The action -review verified all four latest stable releases and pinned checkout/setup-go to -their release commits; those aliases already selected the same versions. App -installation initially blocked the main run with a 404 during repository lookup. -The owner subsequently installed astrocompute-release-please; read-only checks -confirmed the new credentials and repository installation permissions. Both -organization secrets and their repository access are verified, and the old -repository credentials have been removed. The workflow now reads both values -from secrets. Token creation and Release Please execution still need a successful -run after merge. No release was published, and the license and deployment -qualifications remain open. +[Decision 0023](decisions/0023-consumer-readiness.md) records the code/documentation evidence, the Beakley failure-case coverage matrix and recommendations R1–R6. +The user approved R1–R5 on 2026-09-20, including deferring exported `edgetest` helpers. +The license/copyright choice remains open and does not block local work. + +- [x] Review public composition, existing examples, fixture provenance, deferred features, package dependencies and CI/release readiness. +- [x] Approve R1–R5: complete reference application, deferred edgetest exports, consumer outcomes, precise support matrix and v0 preparation. + The license choice remains open. +- [x] Implement the reference application and consumer-testing guide using the existing public APIs, with local provider fixtures. +- [x] Reconcile support/status documentation, update the indirect Smithy patch in isolation, and record incremental streaming baselines. +- [x] Obtain actual GitHub CI evidence for the foundation PR; + all four jobs passed at f6a2624. + Later commits require their own passing checks. +- [ ] Verify the main release workflow and resolve publication prerequisites before a release. + Streaming deployment and live identity interoperability remain separate qualifications. + +The [complete dispatcher](../examples/dispatcher/README.md), [testing guide](consumer-testing.md), [support matrix](support.md) and [release checklist](release-readiness.md) are implemented. +Local RSA/JWKS and synthetic STS fixtures exercise both credentials; +streaming lifecycle tests consume incrementally. +The external-module check and CI wiring are present. +Smithy is updated to v1.28.2, and streaming allocation baselines are recorded. +No library API or transport policy changed. + +Full local race tests, vet, module verification, formatting, external consumption and Linux arm64/amd64 builds pass on Go 1.27.1 (2026-09-21). +The subsequent authorized push and passing PR CI are recorded below. +The license and deployment qualifications remain open; +no release publication or AWS deployment occurred. + +Release automation follow-up (2026-09-21): the user selected Release Please and its GitHub Action, plus checks on PRs against main. +[Decision 0024](decisions/0024-release-automation.md) records the approved GitHub App credential and initial v0.1.0, with pre-major versioning. +The original main-validation dependency is superseded by the 2026-09-27 user direction: Go validation runs on PRs only, including release PRs; +main pushes run only release automation. +Local wiring and validation are complete: both workflows pass actionlint, release JSON passes the bundled schema, and existing Go jobs are unchanged. +See the [release guide](releases.md). + +Subsequent authorized setup and publication of the working branch are complete: the new App Client ID and private key are configured as organization Actions secrets with access granted to this repository, and [PR #1](https://github.com/asteroid-computing/go-lambda-edge/pull/1) merged at 24f7686 on 2026-09-27. +[The PR run at f6a2624](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590) passed both Go test jobs and both Linux build jobs, verified on 2026-09-27. +The action review verified all four latest stable releases and pinned checkout/setup-go to their release commits; +those aliases already selected the same versions. +App installation initially blocked the main run with a 404 during repository lookup. +The owner subsequently installed astrocompute-release-please; +read-only checks confirmed the new credentials and repository installation permissions. +Both organization secrets and their repository access are verified, and the old repository credentials have been removed. +The workflow now reads both values from secrets. +Token creation and Release Please execution still need a successful run after merge. +No release was published, and the license and deployment qualifications remain open. ## Findings carried forward from the Beakley review -These are requirements to test independently, not instructions to preserve old -implementation structure. +These are requirements to test independently, not instructions to preserve old implementation structure. 1. Unverified header JWTs were reconciled using only `sub` and `client_id`. - A probe admitted altered groups with an invalid signature when modeling a - gateway/adapter identity-source mismatch. This is conditional on that mismatch, - not a demonstrated bypass of a correctly aligned native JWT authorizer. -2. Repeated final `WriteHeader` calls replaced the committed status, and header - changes after commitment appeared in output. Both reproduced. -3. V1 path `/a?b#c` was interpreted as path, query, and fragment rather than as - the supplied path. Reproduced. -4. V1 helpers discarded single-value maps wholesale when multivalue maps were - nonempty. Host lookup and request metadata also need defined semantics. + A probe admitted altered groups with an invalid signature when modeling a gateway/adapter identity-source mismatch. + This is conditional on that mismatch, not a demonstrated bypass of a correctly aligned native JWT authorizer. +2. Repeated final `WriteHeader` calls replaced the committed status, and header changes after commitment appeared in output. + Both reproduced. +3. V1 path `/a?b#c` was interpreted as path, query, and fragment rather than as the supplied path. + Reproduced. +4. V1 helpers discarded single-value maps wholesale when multivalue maps were nonempty. + Host lookup and request metadata also need defined semantics. 5. A caller with both IAM and JWT arms could satisfy both rule conditions. - Reproduced; validation was not consistently enforced. -6. Cold JWKS failures bypassed the refetch floor: three immediate calls produced - three network requests. Reproduced. Outage and cancellation behavior need - explicit contracts beyond the existing successful-cache tests. + Reproduced; + validation was not consistently enforced. +6. Cold JWKS failures bypassed the refetch floor: three immediate calls produced three network requests. + Reproduced. + Outage and cancellation behavior need explicit contracts beyond the existing successful-cache tests. 7. An attached anonymous identity prevented subsequent bearer verification. - Existing tests codified that behavior; the new composition policy must be - deliberate rather than inherited. -8. `path.Match` permits wildcards across colons, contrary to the old ARN-matching - comment. Synthetic IAM-role examples are insufficient for role-session cases. -9. Client ID and audience were conflated, and verifier construction allowed any - client in the issuer's pool unless explicitly restricted. - -Validation performed during review: the old suite passed with `-race` in a -temporary copy after changing only its Go directive to 1.27. The original 1.26 -directive triggered Go 1.27's standard-library version checks for JSON v2. The -SDK serialization-boundary probe passed on Go 1.27. No live AWS integration was -deployed or exercised, and the Beakley repository was not edited. + Existing tests codified that behavior; + the new composition policy must be deliberate rather than inherited. +8. `path.Match` permits wildcards across colons, contrary to the old ARN-matching comment. + Synthetic IAM-role examples are insufficient for role-session cases. +9. Client ID and audience were conflated, and verifier construction allowed any client in the issuer's pool unless explicitly restricted. + +Validation performed during review: the old suite passed with `-race` in a temporary copy after changing only its Go directive to 1.27. +The original 1.26 directive triggered Go 1.27's standard-library version checks for JSON v2. +The SDK serialization-boundary probe passed on Go 1.27. +No live AWS integration was deployed or exercised, and the Beakley repository was not edited. ## Official references diff --git a/docs/probes/iamproof/README.md b/docs/probes/iamproof/README.md index fbde1a8..679d7ba 100644 --- a/docs/probes/iamproof/README.md +++ b/docs/probes/iamproof/README.md @@ -1,10 +1,10 @@ # Historical IAM proof design probe -This standalone Go 1.27 module preserves research before decision 0017's -acceptance and implementation. Current production code/tests live in the root -module's `iamproof` package. This probe uses synthetic credentials, with no AWS calls. -Its SDK requirements do not change the parent module's dependencies. Root -`go test ./...` and current CI do not traverse this nested module. +This standalone Go 1.27 module preserves research before decision 0017's acceptance and implementation. +Current production code/tests live in the root module's `iamproof` package. +This probe uses synthetic credentials, with no AWS calls. +Its SDK requirements do not change the parent module's dependencies. +Root `go test ./...` and current CI do not traverse this nested module. Run from this directory: @@ -13,17 +13,13 @@ go test -v -race ./... go vet ./... ``` -The probe checks that AWS SDK v2 signing with an application header yields the -expected signed-header list, that a JSON v2 envelope can reconstruct all signed -URL parameters, and that changing the application header changes the signature. -Query ordering is normalized for comparison: SigV4 sorts parameters, whereas -the SDK appends the signature after signing. No signature is recreated by a -server credential in the reconstruction step. +The probe checks that AWS SDK v2 signing with an application header yields the expected signed-header list, that a JSON v2 envelope can reconstruct all signed URL parameters, and that changing the application header changes the signature. +Query ordering is normalized for comparison: SigV4 sorts parameters, whereas the SDK appends the signature after signing. +No signature is recreated by a server credential in the reconstruction step. -For standard commercial, GovCloud and China regional endpoints, all twelve -cases pass. These are SDK endpoint/signing checks, not deployed partition or -STS interoperability certification. No proof validation, freshness enforcement, -HTTP verification, response parsing or public API is implemented here. +For standard commercial, GovCloud and China regional endpoints, all twelve cases pass. +These are SDK endpoint/signing checks, not deployed partition or STS interoperability certification. +No proof validation, freshness enforcement, HTTP verification, response parsing or public API is implemented here. Measured token bytes for eu-west-2 and the audience `orders.production`: @@ -34,12 +30,12 @@ Measured token bytes for eu-west-2 and the audience `orders.production`: | 4,096 | 5,767 | 14,175 | | 6,144 | 8,498 | 21,002 | -Both columns include a three-byte version prefix, but exclude the HTTP scheme -and field name. Session strings deliberately repeat `+/=a` to exercise URL -escaping. These are synthetic stress cases, not observed AWS token distributions -or a promised upper bound. Actual audiences, credential scopes, and session -tokens change the sizes. The last compact case exceeds the proposed 8 KiB limit. +Both columns include a three-byte version prefix, but exclude the HTTP scheme and field name. +Session strings deliberately repeat `+/=a` to exercise URL escaping. +These are synthetic stress cases, not observed AWS token distributions or a promised upper bound. +Actual audiences, credential scopes, and session tokens change the sizes. +The last compact case exceeds the proposed 8 KiB limit. -Latest stable versions were resolved on 2026-09-16 with `go list -m -json`: -AWS SDK core v1.47.0 and STS v1.51.0. Explicit versions and go.sum make this -experiment reproducible; they are not a policy to avoid future stable upgrades. +Latest stable versions were resolved on 2026-09-16 with `go list -m -json`: AWS SDK core v1.47.0 and STS v1.51.0. +Explicit versions and go.sum make this experiment reproducible; +they are not a policy to avoid future stable upgrades. diff --git a/docs/probes/iamproof/proof_test.go b/docs/probes/iamproof/proof_test.go index ad68894..acfa25f 100644 --- a/docs/probes/iamproof/proof_test.go +++ b/docs/probes/iamproof/proof_test.go @@ -1,5 +1,5 @@ -// Package iamproofprobe checks proposed protocol mechanics using synthetic -// credentials. It is not an authentication implementation or an AWS acceptance test. +// Package iamproofprobe checks proposed protocol mechanics using synthetic credentials. +// It is not an authentication implementation or an AWS acceptance test. package iamproofprobe import ( @@ -34,15 +34,18 @@ func TestReconstruction(t *testing.T) { t.Run(strconv.Itoa(size), func(t *testing.T) { ctx := t.Context() endpoint, err := sts.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, sts.EndpointParameters{ - Region: new(region), UseGlobalEndpoint: new(false), - UseFIPS: new(false), UseDualStack: new(false), + Region: new(region), + UseGlobalEndpoint: new(false), + UseFIPS: new(false), + UseDualStack: new(false), }) if err != nil { t.Fatal(err) } creds := aws.Credentials{ - AccessKeyID: "AKIAIOSFODNN7EXAMPLE", SecretAccessKey: "synthetic-secret-never-valid-in-AWS", - SessionToken: strings.Repeat("+/=a", size/4), + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "synthetic-secret-never-valid-in-AWS", + SessionToken: strings.Repeat("+/=a", size/4), } when := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) req := request(t, endpoint.URI.String(), "orders.production") @@ -60,8 +63,10 @@ func TestReconstruction(t *testing.T) { t.Fatal("unexpected signed headers") } wire, err := json.Marshal(envelope{ - Audience: "orders.production", Credential: q.Get("X-Amz-Credential"), - Date: q.Get("X-Amz-Date"), Signature: q.Get("X-Amz-Signature"), + Audience: "orders.production", + Credential: q.Get("X-Amz-Credential"), + Date: q.Get("X-Amz-Date"), + Signature: q.Get("X-Amz-Signature"), SessionToken: q.Get("X-Amz-Security-Token"), }) if err != nil { @@ -87,8 +92,8 @@ func TestReconstruction(t *testing.T) { params.Set("X-Amz-Security-Token", proof.SessionToken) } rebuilt.URL.RawQuery = params.Encode() - // SigV4 canonicalizes query order; the SDK appends the signature - // after signing, whereas url.Values.Encode sorts all parameters. + // SigV4 canonicalizes query order; + // the SDK appends the signature after signing, whereas url.Values.Encode sorts all parameters. u.RawQuery = q.Encode() if rebuilt.URL.String() != u.String() { t.Fatal("reconstruction changed the presigned URL") @@ -105,8 +110,7 @@ func TestReconstruction(t *testing.T) { if otherURL.Query().Get("X-Amz-Signature") == proof.Signature { t.Fatal("audience did not affect signature") } - t.Logf("session bytes=%d compact token bytes=%d URL token bytes=%d host=%s", - size, len(token), 3+base64.RawURLEncoding.EncodedLen(len(signedURL)), u.Host) + t.Logf("session bytes=%d compact token bytes=%d URL token bytes=%d host=%s", size, len(token), 3+base64.RawURLEncoding.EncodedLen(len(signedURL)), u.Host) }) } }) diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 9a9774a..ac74f3b 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,60 +1,54 @@ # First v0 milestone -Decision 0023 accepts preparing a consumer milestone before a v1 compatibility -commitment. This checklist records preparation, not authorization to publish: +Decision 0023 accepts preparing a consumer milestone before a v1 compatibility commitment. +This checklist records preparation, not authorization to publish: - Complete application with native/buffered/streaming registration. - Local tests for both credentials, grant outcomes and streaming lifecycle. - External-module check using public imports. -- Accurate [support matrix](support.md), [testing guide](consumer-testing.md) and - [allocation baselines](benchmarks.md). +- Accurate [support matrix](support.md), [testing guide](consumer-testing.md) and [allocation baselines](benchmarks.md). - Reproducible SDK versions and an isolated Smithy patch update. - Race tests, vet, module verification, formatting and Linux arm64/amd64 builds. -Release Please and GitHub App authentication are now configured for an initial -v0.1.0 under decision 0024. [The release guide](releases.md) documents the workflow -and required App setup. Merging a generated release PR is the publication decision; +Release Please and GitHub App authentication are now configured for an initial v0.1.0 under decision 0024. +[The release guide](releases.md) documents the workflow and required App setup. +Merging a generated release PR is the publication decision; automation does not resolve the remaining readiness items below. ## Verified readiness (2026-09-27) -[PR #1](https://github.com/asteroid-computing/go-lambda-edge/pull/1) merged on -2026-09-27. All four jobs in -[the run for f6a2624](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590) -passed: test (1.27.0), test (1.27.x), lambda-build (arm64) and lambda-build (amd64). -This provides GitHub evidence for formatting, race tests, vet, module -verification, external consumer checks and both Linux builds. Later commits -require their own passing PR checks. Per the user's 2026-09-27 direction, Go -validation runs only on PRs; main pushes run only release automation. - -The organization Actions secrets RELEASE_PLEASE_CLIENT_ID and -RELEASE_PLEASE_PRIVATE_KEY are provisioned with selected-repository access for -go-lambda-edge. Secret metadata and repository access were verified on 2026-09-27; -the obsolete repository variable and private-key secret were removed. The new -key and Client ID were verified against the astrocompute-release-please App and -its repository installation using read-only API calls without printing credentials. -Its installation grants all three required write permissions. No branch -protection was configured. +[PR #1](https://github.com/asteroid-computing/go-lambda-edge/pull/1) merged on 2026-09-27. +All four jobs in [the run for f6a2624](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/35619720590) passed: test (1.27.0), test (1.27.x), lambda-build (arm64) and lambda-build (amd64). +This provides GitHub evidence for formatting, race tests, vet, module verification, external consumer checks and both Linux builds. +Later commits require their own passing PR checks. +Per the user's 2026-09-27 direction, Go validation runs only on PRs; +main pushes run only release automation. + +The organization Actions secrets RELEASE_PLEASE_CLIENT_ID and RELEASE_PLEASE_PRIVATE_KEY are provisioned with selected-repository access for go-lambda-edge. +Secret metadata and repository access were verified on 2026-09-27; +the obsolete repository variable and private-key secret were removed. +The new key and Client ID were verified against the astrocompute-release-please App and its repository installation using read-only API calls without printing credentials. +Its installation grants all three required write permissions. +No branch protection was configured. ## Outstanding evidence and owner choices -The [first main run](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/36281181036) -passed the original repeated validation but failed at App token creation: the -repository installation lookup returned 404. The owner subsequently installed -astrocompute-release-please, and installation access is now verified. After the -workflow credential migration merges, verify token creation, the generated v0.1.0 -release PR and its checks. Release Please has not yet executed successfully. +The [first main run](https://github.com/asteroid-computing/go-lambda-edge/actions/runs/36281181036) passed the original repeated validation but failed at App token creation: the repository installation lookup returned 404. +The owner subsequently installed astrocompute-release-please, and installation access is now verified. +After the workflow credential migration merges, verify token creation, the generated v0.1.0 release PR and its checks. +Release Please has not yet executed successfully. -The owner's license and copyright holder remain undecided. No license, repository -visibility change, release tag or release publication has been performed. +The owner's license and copyright holder remain undecided. +No license, repository visibility change, release tag or release publication has been performed. Resolve the license before external publication. -REST streaming's SDK deployment qualification remains open: local probes cannot -establish AWS acceptance of the observed mode-header/connection behavior. +REST streaming's SDK deployment qualification remains open: local probes cannot establish AWS acceptance of the observed mode-header/connection behavior. Authoritative clarification or a separately approved live test is still needed. -Live Cognito/STS interoperability is also unverified; local fixtures are synthetic. +Live Cognito/STS interoperability is also unverified; +local fixtures are synthetic. No cloud resources were deployed. -The nested `docs/probes/iamproof` module is historical research. Root tests and -CI do not traverse it. Run its tests explicitly if relying on new probe evidence; +The nested `docs/probes/iamproof` module is historical research. +Root tests and CI do not traverse it. +Run its tests explicitly if relying on new probe evidence; current production proof tests live in the root module's `iamproof` package. diff --git a/docs/releases.md b/docs/releases.md index e01edc1..ce3b642 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,32 +1,37 @@ # Releases -Release Please manages this single root Go module. Its first release is -`v0.1.0`. While versions remain below v1, features and breaking changes bump the -minor version; fixes bump the patch version. A v1 release is an explicit review -decision. The Go toolchain directive is independent of the module version. +Release Please manages this single root Go module. +Its first release is `v0.1.0`. +While versions remain below v1, features and breaking changes bump the minor version; +fixes bump the patch version. +A v1 release is an explicit review decision. +The Go toolchain directive is independent of the module version. ## Workflow -1. Opening or updating a PR against main runs the Go workflow. Reopening a PR - or marking it ready for review also runs checks. Superseded PR runs cancel. -2. A push to main starts only the release job. It mints a GitHub App token and - runs Release Please without repeating the Go checks completed on the PR. -3. Release Please creates or updates a release PR with CHANGELOG.md and the - version manifest. The App's PR events run the same Go validation as other PRs. +1. Opening or updating a PR against main runs the Go workflow. + Reopening a PR or marking it ready for review also runs checks. + Superseded PR runs cancel. +2. A push to main starts only the release job. + It mints a GitHub App token and runs Release Please without repeating the Go checks completed on the PR. +3. Release Please creates or updates a release PR with CHANGELOG.md and the version manifest. + The App's PR events run the same Go validation as other PRs. 4. Review the release PR and its passing checks, then merge when ready to publish. - Release Please creates the version tag and GitHub release. There is - no automatic merge, binary deployment or separate package-registry upload. + Release Please creates the version tag and GitHub release. + There is no automatic merge, binary deployment or separate package-registry upload. Release runs share one concurrency group and do not cancel a running release. -GitHub may replace an older pending run with a newer one; this is not a guarantee -of one release operation per push. Release Please reconciles the main branch. +GitHub may replace an older pending run with a newer one; +this is not a guarantee of one release operation per push. +Release Please reconciles the main branch. The configuration is [release-please-config.json](../release-please-config.json). -The [initial manifest](../.release-please-manifest.json) is empty because no release -exists yet. `initial-version` selects 0.1.0 only for the first release. Release -Please subsequently maintains the manifest; it does not force every release -back to 0.1.0. Plain `vX.Y.Z` tags identify this root module, without a component -prefix. CHANGELOG.md is created by the first release PR. +The [initial manifest](../.release-please-manifest.json) is empty because no release exists yet. +`initial-version` selects 0.1.0 only for the first release. +Release Please subsequently maintains the manifest; +it does not force every release back to 0.1.0. +Plain `vX.Y.Z` tags identify this root module, without a component prefix. +CHANGELOG.md is created by the first release PR. ## GitHub App setup @@ -37,52 +42,49 @@ Its repository permissions must include: - Pull requests: read and write. - Issues: read and write, for release labels and related operations. -In the organization's **Settings → Secrets and variables → Actions**, configure -both values as organization Actions secrets and grant `go-lambda-edge` access: +In the organization's **Settings → Secrets and variables → Actions**, configure both values as organization Actions secrets and grant `go-lambda-edge` access: | Kind | Name | Value | | --- | --- | --- | | Organization secret | `RELEASE_PLEASE_CLIENT_ID` | The App's Client ID | | Organization secret | `RELEASE_PLEASE_PRIVATE_KEY` | Its PEM private key | -Use selected-repository access for this setup; grant other repositories access -when they adopt the same automation. Both workflow inputs use the `secrets` -context. Remove any same-name repository secrets when migrating: a repository -secret takes precedence over an organization secret. The old repository Client -ID variable is no longer used. Use the Client ID, not the installation ID; the -token action prefers `client-id` over its deprecated `app-id` input. Keep the -private key out of this repository. - -The workflow explicitly requests only this repository and the three permissions -above. The token action revokes its short-lived token after the job. The release -job's built-in GITHUB_TOKEN has no permissions; validation gets contents read and -no inherited App secrets. Missing App configuration fails with a setup message -instead of silently using a different credential. +Use selected-repository access for this setup; +grant other repositories access when they adopt the same automation. +Both workflow inputs use the `secrets` context. +Remove any same-name repository secrets when migrating: a repository secret takes precedence over an organization secret. +The old repository Client ID variable is no longer used. +Use the Client ID, not the installation ID; +the token action prefers `client-id` over its deprecated `app-id` input. +Keep the private key out of this repository. + +The workflow explicitly requests only this repository and the three permissions above. +The token action revokes its short-lived token after the job. +The release job's built-in GITHUB_TOKEN has no permissions; +validation gets contents read and no inherited App secrets. +Missing App configuration fails with a setup message instead of silently using a different credential. An App must be installed and allowed by organization policy before this works. -The workflow does not create/install the App, provision secrets, change rulesets, -or bypass branch protection. Configure required PR checks using the check names -observed in the first successful run, and review any existing rules that restrict -the App's release-branch or version-tag writes. PR checks use the proposed merge -result. Validation runs only on PRs; direct main pushes do not run Go checks. +The workflow does not create/install the App, provision secrets, change rulesets, or bypass branch protection. +Configure required PR checks using the check names observed in the first successful run, and review any existing rules that restrict the App's release-branch or version-tag writes. +PR checks use the proposed merge result. +Validation runs only on PRs; +direct main pushes do not run Go checks. -If token creation fails with a 404 looking up the repository installation, -check that the configured App is installed on the organization and includes this -repository. Storing its Client ID and private key does not install the App. +If token creation fails with a 404 looking up the repository installation, check that the configured App is installed on the organization and includes this repository. +Storing its Client ID and private key does not install the App. ## Commit and release review -Use Conventional Commits in merged history: `feat:`, `fix:` and explicit `!` or -`BREAKING CHANGE:` markers where appropriate. Squash-merge feature PRs with an -appropriate conventional title. Preserve Release Please's generated release PR -title/body and labels so it can recognize the merged release. Ordinary docs/CI -changes alone need not create a release PR. +Use Conventional Commits in merged history: `feat:`, `fix:` and explicit `!` or `BREAKING CHANGE:` markers where appropriate. +Squash-merge feature PRs with an appropriate conventional title. +Preserve Release Please's generated release PR title/body and labels so it can recognize the merged release. +Ordinary docs/CI changes alone need not create a release PR. -Merging the release PR is the publication decision. Confirm the -[release-readiness prerequisites](release-readiness.md), including the license -choice and accurate AWS support claims, before merging it. Those are human -review items, not automatically verified by the workflow. No live AWS tests run -in this automation, and a passing CI run does not close deployment qualifications. +Merging the release PR is the publication decision. +Confirm the [release-readiness prerequisites](release-readiness.md), including the license choice and accurate AWS support claims, before merging it. +Those are human review items, not automatically verified by the workflow. +No live AWS tests run in this automation, and a passing CI run does not close deployment qualifications. ## Maintenance @@ -96,17 +98,14 @@ The latest stable upstream releases were verified on 2026-09-21: | actions/create-github-app-token | [v3.2.0](https://github.com/actions/create-github-app-token/releases/tag/v3.2.0) | | googleapis/release-please-action | [v5.0.0](https://github.com/googleapis/release-please-action/releases/tag/v5.0.0) | -The previous checkout/setup-go v7 aliases resolved to these same commits when -checked; pinning makes that selection reproducible. Review upstream release notes -and resolve each release tag to its commit when updating the SHA and version -comment together. The config schema is pinned to release-please 17.6.0, bundled -by the release action. No runtime version API or generated version.go is required. +The previous checkout/setup-go v7 aliases resolved to these same commits when checked; +pinning makes that selection reproducible. +Review upstream release notes and resolve each release tag to its commit when updating the SHA and version comment together. +The config schema is pinned to release-please 17.6.0, bundled by the release action. +No runtime version API or generated version.go is required. -Validate workflow edits locally with `actionlint`; validate config edits against -the referenced JSON schema. A real GitHub run is still needed to verify App -installation, permissions, repository policies and remote release recognition. +Validate workflow edits locally with `actionlint`; +validate config edits against the referenced JSON schema. +A real GitHub run is still needed to verify App installation, permissions, repository policies and remote release recognition. -References: [Release Please action](https://github.com/googleapis/release-please-action/tree/v5.0.0), -[manifest configuration](https://github.com/googleapis/release-please/blob/v17.6.0/docs/manifest-releaser.md), -[App token action](https://github.com/actions/create-github-app-token/tree/v3.2.0), -[reusable workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows). +References: [Release Please action](https://github.com/googleapis/release-please-action/tree/v5.0.0), [manifest configuration](https://github.com/googleapis/release-please/blob/v17.6.0/docs/manifest-releaser.md), [App token action](https://github.com/actions/create-github-app-token/tree/v3.2.0), [reusable workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows). diff --git a/docs/reviews/2026-09-14-plan-review.md b/docs/reviews/2026-09-14-plan-review.md index 26b9241..8c6912a 100644 --- a/docs/reviews/2026-09-14-plan-review.md +++ b/docs/reviews/2026-09-14-plan-review.md @@ -2,36 +2,32 @@ Status: accepted enhancements (2026-09-14). -Reviewed the plan, decisions 0001–0006, and the implemented constructor, decoder, -request conversion, and tests on `work/edge-foundation`. The user approved these -enhancements with “go with your recommended enhancements.” They refine decision -0006 and milestone sequencing, preserving accepted AWS-type reuse, JSON-free -typed entry points, direct JSON v2, and opt-in gateway identity. Exact metadata -budgets, the initial V2 joinable-field set, exported errors, and identity APIs -still require the concrete specifications identified below. +Reviewed the plan, decisions 0001–0006, and the implemented constructor, decoder, request conversion, and tests on `work/edge-foundation`. +The user approved these enhancements with “go with your recommended enhancements.” +They refine decision 0006 and milestone sequencing, preserving accepted AWS-type reuse, JSON-free typed entry points, direct JSON v2, and opt-in gateway identity. +Exact metadata budgets, the initial V2 joinable-field set, exported errors, and identity APIs still require the concrete specifications identified below. ## 1. Make response limits precise and enforce them during encoding -AWS defines its MB unit as 1,024 KB. The buffered response limit is therefore -6 MiB, or 6,291,456 bytes, including the serialized envelope. The original -6,000,000-byte draft cap leaves capacity unused; decision 0006 now records the -correct documented unit. A lower application budget could be a future option. +AWS defines its MB unit as 1,024 KB. +The buffered response limit is therefore 6 MiB, or 6,291,456 bytes, including the serialized envelope. +The original 6,000,000-byte draft cap leaves capacity unused; +decision 0006 now records the correct documented unit. +A lower application budget could be a future option. [AWS Lambda quotas](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html) -Recommend using `encoding/json/v2.MarshalWrite` with a bounded destination for -raw invocation output. Stop retaining encoded output at the limit, rather than -allocating an oversized complete envelope before rejecting it. Keep body-write -and base64-expansion checks. Preflight the committed header/cookie data before -deep copying it: bound retained field bytes and entry counts as well as the body. +Recommend using `encoding/json/v2.MarshalWrite` with a bounded destination for raw invocation output. +Stop retaining encoded output at the limit, rather than allocating an oversized complete envelope before rejecting it. +Keep body-write and base64-expansion checks. +Preflight the committed header/cookie data before deep copying it: bound retained field bytes and entry counts as well as the body. Set the exact metadata budget in the response contract before implementation; do not borrow an unrelated AWS request-header quota. [MarshalWrite](https://pkg.go.dev/encoding/json/v2#MarshalWrite) -This bounds retained adapter output, not all codec temporaries or memory already -allocated by an application. Measure peak allocations near the limit. Typed -methods stay JSON-free and continue to delegate the exact final serialized -envelope limit to their caller/runtime. Test exact boundaries, escaped strings, -binary expansion, large cookies, and many empty header entries. +This bounds retained adapter output, not all codec temporaries or memory already allocated by an application. +Measure peak allocations near the limit. +Typed methods stay JSON-free and continue to delegate the exact final serialized envelope limit to their caller/runtime. +Test exact boundaries, escaped strings, binary expansion, large cookies, and many empty header entries. ## 2. Do not combine arbitrary repeated V2 headers @@ -39,107 +35,91 @@ Decision 0006 currently proposes comma-joining every field except Set-Cookie. That can change meaning: two Location values cannot safely become one URI. HTTP field definitions determine whether comma-separated lists are supported; Set-Cookie requires separate treatment. -[RFC 9110 field ordering](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.3) -[Location](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2) - -Recommend preserving V1 multivalue output and V2 Cookies as proposed. For V2, -join repeated values only for a documented, audited set of list-valued fields; -reject repeated singleton or unknown fields with an invocation error. A single -value passes through without splitting embedded commas. Preserve value order. -Specify the initial set before coding it; do not add a public policy callback -without a demonstrated consumer need. - -The alternative of taking the first or last value silently loses data. Blind -joining changes semantics. Strict rejection makes V2's limitation visible; -applications can explicitly construct a field value according to its definition, -or choose V1 when separate field values are required. +[RFC 9110 field ordering](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.3) [Location](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2) + +Recommend preserving V1 multivalue output and V2 Cookies as proposed. +For V2, join repeated values only for a documented, audited set of list-valued fields; +reject repeated singleton or unknown fields with an invocation error. +A single value passes through without splitting embedded commas. +Preserve value order. +Specify the initial set before coding it; +do not add a public policy callback without a demonstrated consumer need. + +The alternative of taking the first or last value silently loses data. +Blind joining changes semantics. +Strict rejection makes V2's limitation visible; +applications can explicitly construct a field value according to its definition, or choose V1 when separate field values are required. [AWS HTTP API response formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) ## 3. Specify a small response behavior matrix before writing the writer -Retain the existing snapshot, binary encoding, and unsupported-capability -direction. Add these cases to the contract and differential tests: - -- A zero-length Write commits an implicit response but returns `(0, nil)`, - including after 204/304. Only a nonempty forbidden-body write returns - `http.ErrBodyNotAllowed`. -- An explicitly present nil/empty Content-Length slice suppresses inferred - length, just as the draft already specifies for Content-Type. Distinguish this - from an explicit empty string, which is an invalid length. -- Capture the original request method for HEAD response handling so a handler's - mutation of its request cannot change transport finalization. -- Add 205 Reset Content. Recommend suppressing its body and rejecting nonempty - writes with `http.ErrBodyNotAllowed`. RFC 9110 prohibits response content here, - while the inspected Go 1.27.1 server's body-allowed helper does not exclude 205. +Retain the existing snapshot, binary encoding, and unsupported-capability direction. +Add these cases to the contract and differential tests: + +- A zero-length Write commits an implicit response but returns `(0, nil)`, including after 204/304. + Only a nonempty forbidden-body write returns `http.ErrBodyNotAllowed`. +- An explicitly present nil/empty Content-Length slice suppresses inferred length, just as the draft already specifies for Content-Type. + Distinguish this from an explicit empty string, which is an invalid length. +- Capture the original request method for HEAD response handling so a handler's mutation of its request cannot change transport finalization. +- Add 205 Reset Content. + Recommend suppressing its body and rejecting nonempty writes with `http.ErrBodyNotAllowed`. + RFC 9110 prohibits response content here, while the inspected Go 1.27.1 server's body-allowed helper does not exclude 205. Record this as an intentional standards-based difference, not Go parity. -Use the installed Go 1.27.1 server as the implementation reference and the HTTP -specification to identify deliberate differences. The draft's 200–599 status -range, sticky errors, and unsupported 1xx/trailer behavior are also stricter than -ordinary Go serving; keep those differences explicit in consumer documentation. -[Go server implementation](https://go.dev/src/net/http/server.go) -[Go body/status handling](https://go.dev/src/net/http/transfer.go) -[205 semantics](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.6) +Use the installed Go 1.27.1 server as the implementation reference and the HTTP specification to identify deliberate differences. +The draft's 200–599 status range, sticky errors, and unsupported 1xx/trailer behavior are also stricter than ordinary Go serving; +keep those differences explicit in consumer documentation. +[Go server implementation](https://go.dev/src/net/http/server.go) [Go body/status handling](https://go.dev/src/net/http/transfer.go) [205 semantics](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.6) ## 4. Give the whole invocation one lifetime -Currently `serveHTTP` creates the child context. The planned identity step runs -before this helper, so it would not share that scope. Recommend moving ownership -to a shared invocation coordinator covering conversion, identity production, -handler execution, response finalization, and cleanup. Pass that scope through -the existing conversion functions instead of adding a second handler-only scope. - -Reject an already-canceled parent before invoking application code. Continue to -check the parent before returning success; ordinary child cancellation during -cleanup must not turn success into a context error. Ensure identity failures, -writer faults, and panics all reach cleanup. Preserve panic propagation. - -Define simultaneous-error behavior before implementation: retain a primary -conversion/identity/response fault, join sanitized cleanup errors, and let parent -cancellation replace only an otherwise successful result. Preserve `errors.Is` -for context and standard HTTP errors. Review a small set of programmatic adapter -error categories before publishing the invocation methods, so applications need -not match diagnostic strings. Exact exported names remain a separate API detail. - -Retain the documented multipart limitation: a form first parsed on a separate -request copy is that middleware's cleanup responsibility. Eager parsing would -change handler behavior and does not belong in transport conversion. +Currently `serveHTTP` creates the child context. +The planned identity step runs before this helper, so it would not share that scope. +Recommend moving ownership to a shared invocation coordinator covering conversion, identity production, handler execution, response finalization, and cleanup. +Pass that scope through the existing conversion functions instead of adding a second handler-only scope. + +Reject an already-canceled parent before invoking application code. +Continue to check the parent before returning success; +ordinary child cancellation during cleanup must not turn success into a context error. +Ensure identity failures, writer faults, and panics all reach cleanup. +Preserve panic propagation. + +Define simultaneous-error behavior before implementation: retain a primary conversion/identity/response fault, join sanitized cleanup errors, and let parent cancellation replace only an otherwise successful result. +Preserve `errors.Is` for context and standard HTTP errors. +Review a small set of programmatic adapter error categories before publishing the invocation methods, so applications need not match diagnostic strings. +Exact exported names remain a separate API detail. + +Retain the documented multipart limitation: a form first parsed on a separate request copy is that middleware's cleanup responsibility. +Eager parsing would change handler behavior and does not belong in transport conversion. [Request.WithContext](https://pkg.go.dev/net/http#Request.WithContext) ## 5. Make the next deliverable a complete adapter -The private decoder and request conversion already have substantive unit, -differential, race, and fuzz coverage. The next major uncertainty is composition, -not another private conversion layer in isolation. +The private decoder and request conversion already have substantive unit, differential, race, and fuzz coverage. +The next major uncertainty is composition, not another private conversion layer in isolation. Recommend this sequence after the response refinements are resolved: 1. Implement the buffered writer and response conversion. -2. Review and implement the minimum identity/context and native gateway-producer - contracts needed to honor `WithGatewayIdentity(true)`. Keep the option's - accepted default and do not publish a callable adapter that silently ignores - an enabled option. +2. Review and implement the minimum identity/context and native gateway-producer contracts needed to honor `WithGatewayIdentity(true)`. + Keep the option's accepted default and do not publish a callable adapter that silently ignores an enabled option. 3. Wire Invoke, HandleV1, and HandleV2 through the common invocation coordinator. -4. Add runnable raw and typed Lambda registration examples and integration tests - through the pinned SDK's `lambda.NewHandler` boundary. -5. Continue with Cognito/JWKS resilience, broader identity composition, - authorization, and consumer helpers as already planned. - -For the first complete adapter, test sequential reuse and concurrent invocations, -raw/typed HTTP equivalence where input fidelity permits it, cancellation/cleanup, -and absence of cross-invocation body, header, or identity leakage. Exercise real -handlers such as ServeMux, Redirect, ServeContent with Range, compression, and -cookie round trips. Some handlers may expose an intentional transport limit; +4. Add runnable raw and typed Lambda registration examples and integration tests through the pinned SDK's `lambda.NewHandler` boundary. +5. Continue with Cognito/JWKS resilience, broader identity composition, authorization, and consumer helpers as already planned. + +For the first complete adapter, test sequential reuse and concurrent invocations, raw/typed HTTP equivalence where input fidelity permits it, cancellation/cleanup, and absence of cross-invocation body, header, or identity leakage. +Exercise real handlers such as ServeMux, Redirect, ServeContent with Range, compression, and cookie round trips. +Some handlers may expose an intentional transport limit; document that outcome instead of weakening the contract to make a test pass. -Add Go 1.27 CI for tests, race detection, vet, and Linux arm64/amd64 builds. Add -allocation benchmarks comparing raw and typed paths, plus binary and near-limit -responses. Establish measured baselines before choosing performance thresholds. -These checks complement the existing fuzzing and make a usable first release -the next concrete acceptance milestone. +Add Go 1.27 CI for tests, race detection, vet, and Linux arm64/amd64 builds. +Add allocation benchmarks comparing raw and typed paths, plus binary and near-limit responses. +Establish measured baselines before choosing performance thresholds. +These checks complement the existing fuzzing and make a usable first release the next concrete acceptance milestone. ## Resolution -Accepted by the user on 2026-09-14. Decision 0006 and the implementation plan -incorporate the refinements. Implementation may proceed for settled behavior; +Accepted by the user on 2026-09-14. +Decision 0006 and the implementation plan incorporate the refinements. +Implementation may proceed for settled behavior; the remaining concrete policy/API details are reviewed separately. diff --git a/docs/reviews/2026-09-17-sdk-streaming-follow-up.md b/docs/reviews/2026-09-17-sdk-streaming-follow-up.md index 495619d..08a23ec 100644 --- a/docs/reviews/2026-09-17-sdk-streaming-follow-up.md +++ b/docs/reviews/2026-09-17-sdk-streaming-follow-up.md @@ -1,13 +1,12 @@ # AWS Lambda Go streaming: verification and AWS questions -Checked 2026-09-17 using AWS public MCP documentation, version-specific Go docs, -downloaded SDK source, AWS's public GitHub repository and local Runtime API tests. -No AWS resources were deployed or invoked; this draft has not been sent to AWS. +Checked 2026-09-17 using AWS public MCP documentation, version-specific Go docs, downloaded SDK source, AWS's public GitHub repository and local Runtime API tests. +No AWS resources were deployed or invoked; +this draft has not been sent to AWS. ## Result -All direct AWS dependencies match the latest stable versions returned by the Go -module proxy: +All direct AWS dependencies match the latest stable versions returned by the Go module proxy: | Module | Required and latest stable | Version timestamp | | --- | --- | --- | @@ -15,45 +14,34 @@ module proxy: | github.com/aws/aws-sdk-go-v2 | v1.47.0 | 2026-09-09 | | github.com/aws/aws-sdk-go-v2/service/sts | v1.51.0 | 2026-09-14 | -AWS's [latest Lambda Go release](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0) -independently confirms v1.55.0 and its Go 1.27 compatibility work. The Lambda Go -runtime library owns this streaming transport; the SDK v2 core and STS modules -serve our IAM-proof path. No dependency change is necessary. - -The strongest positive evidence is the SDK's own -[APIGatewayProxyStreamingResponse documentation](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.55.0/events#APIGatewayProxyStreamingResponse). -It explicitly describes AWS_PROXY, the response-streaming-invocations URI and -STREAM transfer mode. [PR 603](https://github.com/aws/aws-lambda-go/pull/603), merged -on 2025-12-05, introduced that type; its review discusses API Gateway testing. -This was already noted in decision 0008 and is reconfirmed here. It supports the -interpretation that this is an intended SDK path, not an accidental use of a -Function URL-only API. - -The reader contract is also documented by -[lambda.Start](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.55.0/lambda#Start): -reader returns, closing responses that implement io.Closer, reporting read errors, -and JSON serialization precedence. The response type's -[implementation](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/apigw.go#L37-L93) -uses the same content type, metadata/delimiter structure and deliberate JSON -serialization rejection as edge. Edge retains its own bounded JSON v2 encoder -and lifecycle; replacing it with the SDK response wrapper would not change the -underlying Runtime API transport. +AWS's [latest Lambda Go release](https://github.com/aws/aws-lambda-go/releases/tag/v1.55.0) independently confirms v1.55.0 and its Go 1.27 compatibility work. +The Lambda Go runtime library owns this streaming transport; +the SDK v2 core and STS modules serve our IAM-proof path. +No dependency change is necessary. + +The strongest positive evidence is the SDK's own [APIGatewayProxyStreamingResponse documentation](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.55.0/events#APIGatewayProxyStreamingResponse). +It explicitly describes AWS_PROXY, the response-streaming-invocations URI and STREAM transfer mode. +[PR 603](https://github.com/aws/aws-lambda-go/pull/603), merged on 2025-12-05, introduced that type; +its review discusses API Gateway testing. +This was already noted in decision 0008 and is reconfirmed here. +It supports the interpretation that this is an intended SDK path, not an accidental use of a Function URL-only API. + +The reader contract is also documented by [lambda.Start](https://pkg.go.dev/github.com/aws/aws-lambda-go@v1.55.0/lambda#Start): reader returns, closing responses that implement io.Closer, reporting read errors, and JSON serialization precedence. +The response type's [implementation](https://github.com/aws/aws-lambda-go/blob/v1.55.0/events/apigw.go#L37-L93) uses the same content type, metadata/delimiter structure and deliberate JSON serialization rejection as edge. +Edge retains its own bounded JSON v2 encoder and lifecycle; +replacing it with the SDK response wrapper would not change the underlying Runtime API transport. ## Remaining discrepancy and local control -The current [custom-runtime guide](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html#runtimes-custom-response-streaming) -still requires an explicit streaming-mode header, chunked encoding and closing -the underlying connection after writing the response. The SDK's -[runtime client](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/runtime_api_client.go#L128-L160) -uses Go's chunked request transport, but does not set the mode header or request -connection closure. Closing an io.ReadCloser or an HTTP response body is distinct -from closing the underlying TCP connection. +The current [custom-runtime guide](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html#runtimes-custom-response-streaming) still requires an explicit streaming-mode header, chunked encoding and closing the underlying connection after writing the response. +The SDK's [runtime client](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/runtime_api_client.go#L128-L160) uses Go's chunked request transport, but does not set the mode header or request connection closure. +Closing an io.ReadCloser or an HTTP response body is distinct from closing the underlying TCP connection. -The local probe was extended with `sdk_gateway`, returning -`*events.APIGatewayProxyStreamingResponse` directly through lambda.Start. It uses -neither edge's adapter nor its prefix encoder. The body contains a gated reader; -the local Runtime API server must receive the first body segment before it -releases the remaining segment. A body wrapper only observes Close. +The local probe was extended with `sdk_gateway`, returning `*events.APIGatewayProxyStreamingResponse` directly through lambda.Start. +It uses neither edge's adapter nor its prefix encoder. +The body contains a gated reader; +the local Runtime API server must receive the first body segment before it releases the remaining segment. +A body wrapper only observes Close. Run from the repository: @@ -61,109 +49,89 @@ Run from the repository: go test -race -count=1 -v ./internal/streamprobe -run '^TestSDKStreamsThroughRuntimeAPI$' ``` -All ten cases pass on Go 1.27.1. The direct SDK control and edge cases all show: +All ten cases pass on Go 1.27.1. +The direct SDK control and edge cases all show: ```text incremental=true response-mode="" runtime-request-close=false response-connection-reused-for-next=true ``` -The test also checks chunked transfer, integration content type, metadata/body -framing and Close before the next invocation. Fault cases emit runtime error -trailers. It verifies behavior against a permissive local server, not AWS's -acceptance or client-visible delivery. A real Lambda endpoint might close the -connection itself; the local result cannot establish what the service does. +The test also checks chunked transfer, integration content type, metadata/body framing and Close before the next invocation. +Fault cases emit runtime error trailers. +It verifies behavior against a permissive local server, not AWS's acceptance or client-visible delivery. +A real Lambda endpoint might close the connection itself; +the local result cannot establish what the service does. -No reviewed source explains whether invocation mode, content type or another -service-side rule makes the header/connection requirements optional. That is a -hypothesis to ask AWS about, not an established explanation. +No reviewed source explains whether invocation mode, content type or another service-side rule makes the header/connection requirements optional. +That is a hypothesis to ask AWS about, not an established explanation. ## Upstream findings and limits -- [Issue 500](https://github.com/aws/aws-lambda-go/issues/500) explicitly asks about - the missing mode header. Its collaborator responses and eventual closure - address reader/writer API design, not that protocol question. -- [Issue 565](https://github.com/aws/aws-lambda-go/issues/565#issuecomment-2221292052) - contains a collaborator's working Function URL example on provided.al2023. +- [Issue 500](https://github.com/aws/aws-lambda-go/issues/500) explicitly asks about the missing mode header. + Its collaborator responses and eventual closure address reader/writer API design, not that protocol question. +- [Issue 565](https://github.com/aws/aws-lambda-go/issues/565#issuecomment-2221292052) contains a collaborator's working Function URL example on provided.al2023. This supports Go streaming generally but is not a test of our REST integration. -- [PR 603 review](https://github.com/aws/aws-lambda-go/pull/603#discussion_r2593680120) - resolves a default-status detail. It does not discuss the mode header or TCP - lifetime, so it cannot settle those questions. -- [Issue 610](https://github.com/aws/aws-lambda-go/issues/610), updated in September - 2026, concerns prelude and mixed-output expectations. Its comments do not - provide a maintainer resolution of this transport discrepancy. -- The SDK's [errorCapturingReader](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/runtime_api_client.go#L176-L188) - discards `n` when Read returns bytes together with a non-EOF error. The local - unguarded case loses those bytes; edge's existing guard returns bytes first and - the error on the next read. No reviewed issue/release supplied a fix. - -The [Gateway integration guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) -confirms the metadata format and omitted-length fallback, but does not define -the exact byte accounting of its 16KB delimiter constraint. -The [troubleshooting guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-streaming-troubleshoot.html) -provides streaming timing log fields and recommends deployed curl tests with -`--no-buffer`. Its console/TestInvokeMethod path buffers responses. It discusses -timeout truncation but does not establish how Lambda runtime error trailers map -to an API Gateway client's response or to failure metrics. +- [PR 603 review](https://github.com/aws/aws-lambda-go/pull/603#discussion_r2593680120) resolves a default-status detail. + It does not discuss the mode header or TCP lifetime, so it cannot settle those questions. +- [Issue 610](https://github.com/aws/aws-lambda-go/issues/610), updated in September 2026, concerns prelude and mixed-output expectations. + Its comments do not provide a maintainer resolution of this transport discrepancy. +- The SDK's [errorCapturingReader](https://github.com/aws/aws-lambda-go/blob/v1.55.0/lambda/runtime_api_client.go#L176-L188) discards `n` when Read returns bytes together with a non-EOF error. + The local unguarded case loses those bytes; + edge's existing guard returns bytes first and the error on the next read. + No reviewed issue/release supplied a fix. + +The [Gateway integration guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html) confirms the metadata format and omitted-length fallback, but does not define the exact byte accounting of its 16KB delimiter constraint. +The [troubleshooting guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-streaming-troubleshoot.html) provides streaming timing log fields and recommends deployed curl tests with `--no-buffer`. +Its console/TestInvokeMethod path buffers responses. +It discusses timeout truncation but does not establish how Lambda runtime error trailers map to an API Gateway client's response or to failure metrics. ## Draft for AWS Subject: Clarify aws-lambda-go REST response streaming contract and runtime documentation -We are building a Go 1.27 net/http adapter for API Gateway REST Lambda proxy -response streaming, using the latest stable aws-lambda-go v1.55.0. Our intended -deployment is provided.al2023 with AWS_PROXY, response transfer mode STREAM and -the InvokeWithResponseStream integration URI. We reuse the SDK runtime transport. +We are building a Go 1.27 net/http adapter for API Gateway REST Lambda proxy response streaming, using the latest stable aws-lambda-go v1.55.0. +Our intended deployment is provided.al2023 with AWS_PROXY, response transfer mode STREAM and the InvokeWithResponseStream integration URI. +We reuse the SDK runtime transport. We have not deployed this integration yet. The SDK documents APIGatewayProxyStreamingResponse for this configuration. -However, its Runtime API client omits Lambda-Runtime-Function-Response-Mode and -does not request TCP connection closure, both required by the custom-runtime -guide. A local Runtime API control using the SDK response type directly confirms -incremental chunked output, no mode header and TCP reuse for the next invocation. +However, its Runtime API client omits Lambda-Runtime-Function-Response-Mode and does not request TCP connection closure, both required by the custom-runtime guide. +A local Runtime API control using the SDK response type directly confirms incremental chunked output, no mode header and TCP reuse for the next invocation. Could the Lambda runtime/Go SDK and API Gateway teams clarify: -1. **Supported configuration:** Is unmodified aws-lambda-go v1.55.0 on - provided.al2023, returning APIGatewayProxyStreamingResponse through - lambda.Start, the supported REST streaming path? Does an equivalent io.ReadCloser - with the same content type and wire framing have the same support contract? +1. **Supported configuration:** Is unmodified aws-lambda-go v1.55.0 on provided.al2023, returning APIGatewayProxyStreamingResponse through lambda.Start, the supported REST streaming path? + Does an equivalent io.ReadCloser with the same content type and wire framing have the same support contract? Are any additional runtime settings or build requirements necessary? -2. **Mode header:** Is Lambda-Runtime-Function-Response-Mode: streaming required - for this path? If the SDK may omit it, what precise condition enables - streaming, and can AWS document that exception? If it is required, is this an - SDK defect, and which release will correct it? - -3. **Connection lifetime:** Does the documented connection-close requirement - mean a TCP close, or just completing the chunked request? Is keep-alive reuse - explicitly supported? If the runtime must close TCP, does the Lambda endpoint - enforce closure itself, or must the Go SDK change? - -4. **Midstream failure contract:** After metadata and body bytes reach API - Gateway, how are Lambda-Runtime-Function-Error-Type/Body trailers exposed to - the HTTP client? Does the client see an incomplete/reset transfer, forwarded - trailers, or a normally completed response with the original status? Which - access-log fields and Lambda/API Gateway metrics reliably identify the - failure, especially for an unknown-length response? - -5. **Exact prefix limit:** Does the first 16KB mean 16,000 or 16,384 bytes? Must - the entire eight-NUL delimiter fit within that bound, or only its first byte? +2. **Mode header:** Is Lambda-Runtime-Function-Response-Mode: streaming required for this path? + If the SDK may omit it, what precise condition enables streaming, and can AWS document that exception? + If it is required, is this an SDK defect, and which release will correct it? + +3. **Connection lifetime:** Does the documented connection-close requirement mean a TCP close, or just completing the chunked request? + Is keep-alive reuse explicitly supported? + If the runtime must close TCP, does the Lambda endpoint enforce closure itself, or must the Go SDK change? + +4. **Midstream failure contract:** After metadata and body bytes reach API Gateway, how are Lambda-Runtime-Function-Error-Type/Body trailers exposed to the HTTP client? + Does the client see an incomplete/reset transfer, forwarded trailers, or a normally completed response with the original status? + Which access-log fields and Lambda/API Gateway metrics reliably identify the failure, especially for an unknown-length response? + +5. **Exact prefix limit:** Does the first 16KB mean 16,000 or 16,384 bytes? + Must the entire eight-NUL delimiter fit within that bound, or only its first byte? Please specify the maximum accepted byte offset/total prefix length. -6. **Reader error handling:** Is the SDK's loss of `n > 0` bytes when Read also - returns a non-EOF error a known issue, and is a fix planned? We currently - preserve bytes by returning them before surfacing the error on the next read. +6. **Reader error handling:** Is the SDK's loss of `n > 0` bytes when Read also returns a non-EOF error a known issue, and is a fix planned? + We currently preserve bytes by returning them before surfacing the error on the next read. -Questions 1–3 are the immediate deployment-contract qualification. Question 4 -defines reliable production failure detection. Questions 5–6 do not block our -local implementation: we currently cap the entire prefix at 16,000 bytes and -guard the reader error case. +Questions 1–3 are the immediate deployment-contract qualification. +Question 4 defines reliable production failure detection. +Questions 5–6 do not block our local implementation: we currently cap the entire prefix at 16,000 bytes and guard the reader error case. ## Recommendation -Keep the approved implementation and SDK reuse. The public SDK type and upstream -work strengthen the case that Go REST streaming is intended to work. They do not -justify silently waiving the documented transport mismatch. Retain the deployment -qualification pending AWS's answers or a separately approved deployed test. +Keep the approved implementation and SDK reuse. +The public SDK type and upstream work strengthen the case that Go REST streaming is intended to work. +They do not justify silently waiving the documented transport mismatch. +Retain the deployment qualification pending AWS's answers or a separately approved deployed test. No new public API, trust-boundary or error-policy decision is proposed here. diff --git a/docs/streaming.md b/docs/streaming.md index 7a759a6..916aeea 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -1,23 +1,22 @@ # REST response streaming -`edge.NewStreaming` adapts a REST API Lambda proxy event to an ordinary -`http.Handler`. The returned reader contains bounded JSON metadata, eight NUL -bytes, then raw body bytes. AWS consumes that prefix; it is not application body -content. Streaming input requests and API Gateway HTTP API responses are not -supported. - -The local implementation is complete. **Deployment compatibility remains -unverified.** AWS Lambda Go v1.55.0 is the latest stable release checked on -2026-09-17. Local Runtime API probes show incremental delivery and reader closure, -but the SDK omits the documented streaming-mode header and reuses the response -connection. See [decision 0022](decisions/0022-streaming-writer-review.md#s3-sdk-issue-retain-the-existing-deployment-release-gate). -These observations neither prove real-service rejection nor establish deployed -support. No live AWS test has run, and edge does not patch the SDK or transport. +`edge.NewStreaming` adapts a REST API Lambda proxy event to an ordinary `http.Handler`. +The returned reader contains bounded JSON metadata, eight NUL bytes, then raw body bytes. +AWS consumes that prefix; +it is not application body content. +Streaming input requests and API Gateway HTTP API responses are not supported. + +The local implementation is complete. +**Deployment compatibility remains unverified.** +AWS Lambda Go v1.55.0 is the latest stable release checked on 2026-09-17. +Local Runtime API probes show incremental delivery and reader closure, but the SDK omits the documented streaming-mode header and reuses the response connection. +See [decision 0022](decisions/0022-streaming-writer-review.md#s3-sdk-issue-retain-the-existing-deployment-release-gate). +These observations neither prove real-service rejection nor establish deployed support. +No live AWS test has run, and edge does not patch the SDK or transport. ## Registration and configuration -In a Lambda main, construct the adapter, handle its startup error, and register -one method: +In a Lambda main, construct the adapter, handle its startup error, and register one method: ```go adapter, err := edge.NewStreaming(handler, @@ -31,130 +30,124 @@ if err != nil { lambda.Start(adapter.Handle) ``` -`Handle(context.Context, jsontext.Value)` captures raw event JSON through the SDK, -then validates/decodes it using direct JSON v2 inside edge. Explicitly versioned -HTTP API envelopes are rejected. Alternatively register -`lambda.Start(adapter.HandleV1)` for an `events.APIGatewayProxyRequest`; this skips -request-envelope JSON work inside edge, with input codec validation and numeric -fidelity owned by the caller/SDK. The V1 type also represents HTTP API payload -1.0, so its type cannot prove that the deployment is a REST streaming integration. +`Handle(context.Context, jsontext.Value)` captures raw event JSON through the SDK, then validates/decodes it using direct JSON v2 inside edge. +Explicitly versioned HTTP API envelopes are rejected. +Alternatively register `lambda.Start(adapter.HandleV1)` for an `events.APIGatewayProxyRequest`; +this skips request-envelope JSON work inside edge, with input codec validation and numeric fidelity owned by the caller/SDK. +The V1 type also represents HTTP API payload 1.0, so its type cannot prove that the deployment is a REST streaming integration. Both entry points encode output metadata using direct JSON v2. -Do not register the StreamingAdapter object or wrap it in a byte-returning -Invoke method. The SDK must receive the reader to consume incrementally. Direct -callers own reading and closing it. There is no HandleV2 streaming entry point. +Do not register the StreamingAdapter object or wrap it in a byte-returning Invoke method. +The SDK must receive the reader to consume incrementally. +Direct callers own reading and closing it. +There is no HandleV2 streaming entry point. -The AWS configuration requires a REST proxy integration with response transfer -mode STREAM and an InvokeWithResponseStream integration URI. The buffered -integration configuration alone does not enable streaming. See the official -[REST streaming overview](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) -and [Lambda streaming integration format](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html). +The AWS configuration requires a REST proxy integration with response transfer mode STREAM and an InvokeWithResponseStream integration URI. +The buffered integration configuration alone does not enable streaming. +See the official [REST streaming overview](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode.html) and [Lambda streaming integration format](https://docs.aws.amazon.com/apigateway/latest/developerguide/response-transfer-mode-lambda.html). -Existing gateway-identity, claims-budget and response-header-budget options also -apply. Gateway identity remains disabled by default; inherited callers are -rejected. The adapter is immutable and reusable; the application owns handler -concurrency safety. Each invocation has its own request, context and writer. +Existing gateway-identity, claims-budget and response-header-budget options also apply. +Gateway identity remains disabled by default; +inherited callers are rejected. +The adapter is immutable and reusable; +the application owns handler concurrency safety. +Each invocation has its own request, context and writer. ## Commitment, flushing and limits -The first final WriteHeader, Write or Flush commits status and an owned header -snapshot. Later ordinary header mutations cannot change the response. An empty -Write commits 200 when needed but does not force publication. WriteHeader alone -does not necessarily hand off a reader. +The first final WriteHeader, Write or Flush commits status and an owned header snapshot. +Later ordinary header mutations cannot change the response. +An empty Write commits 200 when needed but does not force publication. +WriteHeader alone does not necessarily hand off a reader. -When Content-Type needs detection, edge retains at most 512 body bytes. Filling -that prefix, flushing or completing the handler publishes metadata and drains -pending bytes through an unbuffered pipe. An explicit or suppressed Content-Type, -or a nonblank Content-Encoding, permits immediate publication on the first -nonempty Write. Empty Content-Encoding values do not suppress detection. +When Content-Type needs detection, edge retains at most 512 body bytes. +Filling that prefix, flushing or completing the handler publishes metadata and drains pending bytes through an unbuffered pipe. +An explicit or suppressed Content-Type, or a nonblank Content-Encoding, permits immediate publication on the first nonempty Write. +Empty Content-Encoding values do not suppress detection. -Use `http.NewResponseController(w).Flush()` to observe flush errors. The writer -also supports `http.Flusher`; its void Flush records failures for finalization. -An empty Flush publishes without inventing a type, and later writes cannot -change it. Flush provides an application publication boundary, not a promise of -client receipt or preserved network chunk boundaries. Middleware wrappers must -preserve FlushError/Flusher or expose ResponseController-compatible Unwrap. +Use `http.NewResponseController(w).Flush()` to observe flush errors. +The writer also supports `http.Flusher`; +its void Flush records failures for finalization. +An empty Flush publishes without inventing a type, and later writes cannot change it. +Flush provides an application publication boundary, not a promise of client receipt or preserved network chunk boundaries. +Middleware wrappers must preserve FlushError/Flusher or expose ResponseController-compatible Unwrap. Streaming never infers Content-Length, including tiny, empty and HEAD responses. -A valid supplied length is honored when status permits it. A whole Write that -would exceed it fails before any bytes from that Write are accepted. A short -non-HEAD body fails at handler completion. Edge does not generate Transfer-Encoding; -Gateway owns its transport framing. This length policy intentionally differs -from buffered edge and native net/http length inference. - -HEAD counts/discards representation writes, may detect their type and permits a -supplied representation length without requiring that many writes. Statuses -204, 205 and 304 suppress bodies and Content-Length; 304 also suppresses -Content-Type. Nonempty writes for these statuses return http.ErrBodyNotAllowed -without invalidating the response. The 205 rule is an intentional difference -from net/http. Informational responses, application trailers, Hijacker, Pusher, -deadline controls and full duplex are unsupported. - -The shared default header resource budget is 256 KiB, adjustable through -WithResponseHeaderBudget. Separately, the complete metadata prefix including -delimiter must fit 16,000 bytes, edge's conservative interpretation of the AWS -metadata constraint. Validation happens before reader handoff. MultiValueHeaders -preserves repeated response fields, including Set-Cookie. - -The streaming body is raw, without base64 or a JSON envelope. It does not use the -buffered adapter's 6 MiB envelope limit; edge does not add an arbitrary body cap. +A valid supplied length is honored when status permits it. +A whole Write that would exceed it fails before any bytes from that Write are accepted. +A short non-HEAD body fails at handler completion. +Edge does not generate Transfer-Encoding; +Gateway owns its transport framing. +This length policy intentionally differs from buffered edge and native net/http length inference. + +HEAD counts/discards representation writes, may detect their type and permits a supplied representation length without requiring that many writes. +Statuses 204, 205 and 304 suppress bodies and Content-Length; +304 also suppresses Content-Type. +Nonempty writes for these statuses return http.ErrBodyNotAllowed without invalidating the response. +The 205 rule is an intentional difference from net/http. +Informational responses, application trailers, Hijacker, Pusher, deadline controls and full duplex are unsupported. + +The shared default header resource budget is 256 KiB, adjustable through WithResponseHeaderBudget. +Separately, the complete metadata prefix including delimiter must fit 16,000 bytes, edge's conservative interpretation of the AWS metadata constraint. +Validation happens before reader handoff. +MultiValueHeaders preserves repeated response fields, including Set-Cookie. + +The streaming body is raw, without base64 or a JSON envelope. +It does not use the buffered adapter's 6 MiB envelope limit; +edge does not add an arbitrary body cap. This is not a claim to exceed AWS service size, bandwidth or timeout limits. Retained sniff bytes and metadata are bounded, not the application's allocations. ## Authentication and application protocols -Authenticate, resolve the action/resource and authorize before writing or -flushing. The same `authn` and `authz` components compose with both adapters. -Denied access, dependency outages, challenges, CORS and JSON error bodies remain -ordinary HTTP responses selected by application middleware. A valid 401, 403 or -503 response is a successful transport outcome. +Authenticate, resolve the action/resource and authorize before writing or flushing. +The same `authn` and `authz` components compose with both adapters. +Denied access, dependency outages, challenges, CORS and JSON error bodies remain ordinary HTTP responses selected by application middleware. +A valid 401, 403 or 503 response is a successful transport outcome. -After commitment, a later authorization failure cannot replace the status or -earlier bytes. The application owns ongoing authorization, heartbeats and -protocol-specific terminal messages such as an SSE error event. Edge does not -turn a late transport failure into an application record or retry a stream. +After commitment, a later authorization failure cannot replace the status or earlier bytes. +The application owns ongoing authorization, heartbeats and protocol-specific terminal messages such as an SSE error event. +Edge does not turn a late transport failure into an application record or retry a stream. -The [runnable examples](../streaming_example_test.go) cover SSE, direct JSON v2 -NDJSON, binary and gzip bodies. Their local reader collector is only for displaying -test output; a Lambda wrapper must return the reader directly. For gzip, negotiate -Accept-Encoding in the application, set the response type/encoding before writing, -flush the encoder before the HTTP writer, and close the encoder to finish its -footer. Gateway does not perform streaming content compression for the integration. +The [runnable examples](../streaming_example_test.go) cover SSE, direct JSON v2 NDJSON, binary and gzip bodies. +Their local reader collector is only for displaying test output; +a Lambda wrapper must return the reader directly. +For gzip, negotiate Accept-Encoding in the application, set the response type/encoding before writing, flush the encoder before the HTTP writer, and close the encoder to finish its footer. +Gateway does not perform streaming content compression for the integration. ## Errors and ownership -Logical HTTP commitment and reader handoff are different boundaries. Before the -handoff, invocation faults return a nil reader and error; early producer panics -propagate after cleanup. After handoff, failures preserve status and prior bytes -and become sanitized terminal reader errors. Late panics are recovered as ErrStream. +Logical HTTP commitment and reader handoff are different boundaries. +Before the handoff, invocation faults return a nil reader and error; +early producer panics propagate after cleanup. +After handoff, failures preserve status and prior bytes and become sanitized terminal reader errors. +Late panics are recovered as ErrStream. Use errors.Is for public categories and context.Canceled/context.DeadlineExceeded; errors.AsType can inspect sanitized InvocationError details. -WithStreamErrorReporter runs once for a terminal failure, after request cleanup -and before completion. Pre-handoff failures are returned directly and are not -reported. The callback receives the invocation context, possibly canceled, and -must return promptly. It must not read or close its own stream, which would wait -for the callback itself to finish. A callback panic adds a sanitized ErrStream -without replacing the original failure. New rejects this streaming-only option; -NewStreaming rejects a final nil reporter. Options use their existing ordered, -last-assignment-wins policy. - -The bridge uses one producer and backpressure, not a goroutine or queued body per -Write. Read and Close coordinate with producer completion. Terminal reads wait -for cleanup/reporting; Close cancels the request, unblocks pipe writes and joins -the producer. Handlers must observe request cancellation and stop on write/flush -errors. A handler that ignores both can delay Close until invocation termination. +WithStreamErrorReporter runs once for a terminal failure, after request cleanup and before completion. +Pre-handoff failures are returned directly and are not reported. +The callback receives the invocation context, possibly canceled, and must return promptly. +It must not read or close its own stream, which would wait for the callback itself to finish. +A callback panic adds a sanitized ErrStream without replacing the original failure. +New rejects this streaming-only option; +NewStreaming rejects a final nil reporter. +Options use their existing ordered, last-assignment-wins policy. + +The bridge uses one producer and backpressure, not a goroutine or queued body per Write. +Read and Close coordinate with producer completion. +Terminal reads wait for cleanup/reporting; +Close cancels the request, unblocks pipe writes and joins the producer. +Handlers must observe request cancellation and stop on write/flush errors. +A handler that ignores both can delay Close until invocation termination. Do not abandon a returned reader without closing it. -Request bodies and multipart files on the served request remain owned until -producer cleanup; multipart files attached only to a middleware-created request -copy remain that middleware's responsibility. The request context is canceled on -completion. Local SDK probes verify reader Close before the next invocation. -Client disconnection need not immediately cancel Lambda execution; this API -cannot promise disconnection notification that AWS does not provide. - -The SDK probe also covers its handling of readers returning bytes plus an error: -edge exposes those bytes first and the error on the next read, preserving the -payload despite the current SDK reader wrapper. See the accepted -[ownership design](decisions/0009-streaming-boundaries.md) and -[writer review](decisions/0022-streaming-writer-review.md) for evidence and limits. +Request bodies and multipart files on the served request remain owned until producer cleanup; +multipart files attached only to a middleware-created request copy remain that middleware's responsibility. +The request context is canceled on completion. +Local SDK probes verify reader Close before the next invocation. +Client disconnection need not immediately cancel Lambda execution; +this API cannot promise disconnection notification that AWS does not provide. + +The SDK probe also covers its handling of readers returning bytes plus an error: edge exposes those bytes first and the error on the next read, preserving the payload despite the current SDK reader wrapper. +See the accepted [ownership design](decisions/0009-streaming-boundaries.md) and [writer review](decisions/0022-streaming-writer-review.md) for evidence and limits. diff --git a/docs/support.md b/docs/support.md index b9a78e8..e9fa081 100644 --- a/docs/support.md +++ b/docs/support.md @@ -1,8 +1,8 @@ # Support and evidence -The module targets Go 1.27, processing module-owned JSON directly with JSON v2 -and jsontext. Its five public libraries are `edge`, `identity`, `authn`, `authz` -and `iamproof`. The dispatcher's internal application and commands are examples. +The module targets Go 1.27, processing module-owned JSON directly with JSON v2 and jsontext. +Its five public libraries are `edge`, `identity`, `authn`, `authz` and `iamproof`. +The dispatcher's internal application and commands are examples. | Capability | Implementation | Evidence / qualification | | --- | --- | --- | @@ -18,24 +18,29 @@ and `iamproof`. The dispatcher's internal application and commands are examples. | Authorization | Exact predicates, ordered rules, grant callbacks | Deny-by-default, outage/cancellation and dispatch consistency | | Consumer composition | Complete orders example, three mains | Local application matrix and external-module check | -Gateway identity is disabled by default. Enabling it is a deployment trust choice; -custom authorizer output is not a native IAM/JWT assertion. Flattened claims -cannot recover original arrays or exact numbers. Typed SDK inputs inherit their -upstream decoding fidelity. See [fixture qualifications](gateway-fixtures.md). +Gateway identity is disabled by default. +Enabling it is a deployment trust choice; +custom authorizer output is not a native IAM/JWT assertion. +Flattened claims cannot recover original arrays or exact numbers. +Typed SDK inputs inherit their upstream decoding fidelity. +See [fixture qualifications](gateway-fixtures.md). -REST buffered binary-media configuration remains the application's deployment -responsibility. Streaming emits raw bytes and does not imply HTTP API streaming -support. The SDK mode-header/connection questions remain in the -[streaming guide](streaming.md) and [AWS follow-up](reviews/2026-09-17-sdk-streaming-follow-up.md). +REST buffered binary-media configuration remains the application's deployment responsibility. +Streaming emits raw bytes and does not imply HTTP API streaming support. +The SDK mode-header/connection questions remain in the [streaming guide](streaming.md) and [AWS follow-up](reviews/2026-09-17-sdk-streaming-follow-up.md). -Deferred: custom Lambda-authorizer identity mappers; Gateway request-ID, stage -and route metadata accessors; mTLS identity; Function URLs and ALB; generic grant -resolvers; ongoing stream reauthorization; exported `edgetest` helpers. The root -package currently has no Gateway metadata accessor. Broad original package -responsibilities are not a claim that those APIs exist. +Deferred: custom Lambda-authorizer identity mappers; +Gateway request-ID, stage and route metadata accessors; +mTLS identity; +Function URLs and ALB; +generic grant resolvers; +ongoing stream reauthorization; +exported `edgetest` helpers. +The root package currently has no Gateway metadata accessor. +Broad original package responsibilities are not a claim that those APIs exist. -Beakley supplied requirements/failure cases, not copied code or compatibility -targets. [Decision 0023](decisions/0023-consumer-readiness.md#original-beakley-failure-cases-traced-to-current-coverage) -maps its nine recorded defects to current tests; this is not an exhaustive API -parity audit. Local success differs from deployed interoperability and a GitHub -CI result. The [release checklist](release-readiness.md) tracks remaining items. +Beakley supplied requirements/failure cases, not copied code or compatibility targets. +[Decision 0023](decisions/0023-consumer-readiness.md#original-beakley-failure-cases-traced-to-current-coverage) maps its nine recorded defects to current tests; +this is not an exhaustive API parity audit. +Local success differs from deployed interoperability and a GitHub CI result. +The [release checklist](release-readiness.md) tracks remaining items. diff --git a/errors.go b/errors.go index e5f0061..ac17cb7 100644 --- a/errors.go +++ b/errors.go @@ -6,9 +6,8 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Invocation failure categories support errors.Is through wrapping and joined -// errors. Application HTTP responses, including 4xx and 5xx, are not invocation -// failures when the transport completes successfully. +// Invocation failure categories support errors.Is through wrapping and joined errors. +// Application HTTP responses, including 4xx and 5xx, are not invocation failures when the transport completes successfully. var ( // ErrInvalidInvocation indicates an invalid direct invocation argument or adapter. ErrInvalidInvocation = errors.New("edge: invalid invocation") @@ -28,15 +27,15 @@ var ( ErrStream = errors.New("edge: stream failure") ) -// InvocationError describes a sanitized transport failure. Use errors.Is to -// inspect its category and errors.AsType[*InvocationError] for its diagnostics. +// InvocationError describes a sanitized transport failure. +// Use errors.Is to inspect its category and errors.AsType[*InvocationError] for its diagnostics. // Its text is diagnostic, not a stable identifier or an HTTP response body. // It does not expose event values, application data, or unsafe underlying errors. // A zero InvocationError describes an unclassified failure. // -// Operation failures precede any joined cleanup failures. Parent cancellation -// is returned as the standard context error when there is no operation or cleanup -// failure. Application panics propagated to the runtime are not InvocationErrors. +// Operation failures precede any joined cleanup failures. +// Parent cancellation is returned as the standard context error when there is no operation or cleanup failure. +// Application panics propagated to the runtime are not InvocationErrors. type InvocationError struct { operation string detail string @@ -65,8 +64,8 @@ func (e *InvocationError) Unwrap() error { return e.cause } -// Operation identifies the failed operation: validate, decode, request, -// identity, response, encode, cleanup, or stream. A zero error returns "". +// Operation identifies the failed operation: validate, decode, request, identity, response, encode, cleanup, or stream. +// A zero error returns "". func (e *InvocationError) Operation() string { if e == nil { return "" @@ -74,12 +73,13 @@ func (e *InvocationError) Operation() string { return e.operation } -// Limit reports the resource name and its effective maximum in bytes for a -// limit failure. Names are response_headers, buffered_body, buffered_envelope, -// stream_metadata, and identity_claims. A response header or identity claims -// budget measures the documented charge; raw claims also have a wire-length -// limit. Neither budget measures heap use. buffered_body covers base64 expansion and -// the representable HEAD byte count. Other failures return "", 0, false. +// Limit reports the resource name and its effective maximum in bytes for a limit failure. +// Names are response_headers, buffered_body, buffered_envelope, stream_metadata, and identity_claims. +// A response header or identity claims budget measures the documented charge; +// raw claims also have a wire-length limit. +// Neither budget measures heap use. +// buffered_body covers base64 expansion and the representable HEAD byte count. +// Other failures return "", 0, false. func (e *InvocationError) Limit() (name string, maximum int64, ok bool) { if e == nil || e.limit == nil { return "", 0, false @@ -88,8 +88,8 @@ func (e *InvocationError) Limit() (name string, maximum int64, ok bool) { } // invocationError accepts only package-owned diagnostic strings and causes. -// Never pass parser errors, application errors, or input values here. Error() -// sanitization alone would not make an unsafe Unwrap chain safe. +// Never pass parser errors, application errors, or input values here. +// Error() sanitization alone would not make an unsafe Unwrap chain safe. func invocationError(operation string, category error, detail string, causes ...error) *InvocationError { cause := category if len(causes) != 0 { @@ -105,8 +105,8 @@ func limitError(operation, name string, maximum int64, reason error) *Invocation return err } -// claimsError translates only package-owned claim failures. It deliberately -// rebuilds a safe error tree instead of wrapping the supplied error or its text. +// claimsError translates only package-owned claim failures. +// It deliberately rebuilds a safe error tree instead of wrapping the supplied error or its text. // Gateway preparation uses this boundary for raw and typed claim construction. func claimsError(err error) *InvocationError { if limit, ok := errors.AsType[*identity.ClaimsLimitError](err); ok { diff --git a/errors_test.go b/errors_test.go index 21ec49b..27d820d 100644 --- a/errors_test.go +++ b/errors_test.go @@ -9,8 +9,8 @@ import ( "testing" ) -// Exercise real transport fault sites while the public invocation methods await -// identity producers. Assertions use the exported contract, not private causes. +// Exercise real transport fault sites while the public invocation methods await identity producers. +// Assertions use the exported contract, not private causes. func TestInvocationErrorCategories(t *testing.T) { for _, tt := range []struct { name string @@ -103,8 +103,8 @@ func TestInvocationErrorCategories(t *testing.T) { if name, maximum, ok := diagnostic.Limit(); name != "" || maximum != 0 || ok { t.Errorf("Limit() = %q, %d, %v for a non-limit failure", name, maximum, ok) } - // Inspect every exposed cause, including joined siblings. An error's - // own sanitized text must not conceal a credential-bearing child. + // Inspect every exposed cause, including joined siblings. + // An error's own sanitized text must not conceal a credential-bearing child. pending := []error{wrapped} for len(pending) != 0 { node := pending[len(pending)-1] diff --git a/examples/dispatcher/README.md b/examples/dispatcher/README.md index f9cfc3a..58246e9 100644 --- a/examples/dispatcher/README.md +++ b/examples/dispatcher/README.md @@ -1,9 +1,8 @@ # Orders dispatcher -This consumer application uses edge's existing public APIs. The code in -`internal/orders` is example application code, not a new edge API. It configures -real Cognito and IAM-proof verification, application grants and a tiny read-only -order store. +This consumer application uses edge's existing public APIs. +The code in `internal/orders` is example application code, not a new edge API. +It configures real Cognito and IAM-proof verification, application grants and a tiny read-only order store. | Entry point | Registration | Actions | | --- | --- | --- | @@ -12,13 +11,15 @@ order store. | `streaming` | `lambda.Start(adapter.Handle)` | `orders.read`, `orders.watch` | Streaming is for REST API only and retains the [AWS deployment qualification](../../docs/streaming.md). -An Action header cannot change the transport selected at startup. For typed -inputs, register buffered `HandleV1`/`HandleV2` or streaming `HandleV1` instead. -The SDK then owns input decoding; edge skips an envelope JSON round trip. +An Action header cannot change the transport selected at startup. +For typed inputs, register buffered `HandleV1`/`HandleV2` or streaming `HandleV1` instead. +The SDK then owns input decoding; +edge skips an envelope JSON round trip. ## Configuration -Set all eight variables explicitly; there are no credential or enrollment defaults: +Set all eight variables explicitly; +there are no credential or enrollment defaults: | Variable | Meaning | | --- | --- | @@ -31,12 +32,11 @@ Set all eight variables explicitly; there are no credential or enrollment defaul | `ORDER_IAM_PRINCIPAL` | Exact full caller ARN enrolled for this example | | `CORS_ORIGIN` | Exact browser origin without a trailing slash | -The example accepts resource-bound Cognito access tokens with `orders.read` -scope; it does not configure the distinct unbound M2M profile. Both actions -require read permission plus an application grant for their exact action and -target. An IAM assumed-role session ARN enrolls that exact session, not every -session of the role. Replace the demonstration grant store with application -enrollment and resource rules before expanding the data set. +The example accepts resource-bound Cognito access tokens with `orders.read` scope; +it does not configure the distinct unbound M2M profile. +Both actions require read permission plus an application grant for their exact action and target. +An IAM assumed-role session ARN enrolls that exact session, not every session of the role. +Replace the demonstration grant store with application enrollment and resource rules before expanding the data set. After setting configuration, run from the repository root: @@ -44,20 +44,19 @@ After setting configuration, run from the repository root: go run ./examples/dispatcher/native ``` -`GET /healthz` is public. The protected endpoint is `GET /tenants/42/orders/7`, -with `Action: orders.read` and either `Authorization: Bearer ` or -`Authorization: EdgeIAM `. The streaming variant also accepts -`Action: orders.watch`, emitting two finite NDJSON records. Construction does no -network I/O; credential-bearing requests use the real configured JWKS/STS -endpoints. These commands are not offline simulators. - -Generate a proof with `iamproof.NewGenerator(region, audience, credentials)` and -`Generate(ctx)`, then set Authorization to `"EdgeIAM " + token.Value()`. Supply -the AWS credentials provider already configured by your client application. -See the [helper example](../../iamproof/example_test.go) and -[protocol guide](../../docs/iamproof.md). The server needs no AWS credentials to -forward the signed proof. Do not configure AWS_IAM authorization on this mixed -credential route; the Lambda mains leave gateway identity disabled. +`GET /healthz` is public. +The protected endpoint is `GET /tenants/42/orders/7`, with `Action: orders.read` and either `Authorization: Bearer ` or `Authorization: EdgeIAM `. +The streaming variant also accepts `Action: orders.watch`, emitting two finite NDJSON records. +Construction does no network I/O; +credential-bearing requests use the real configured JWKS/STS endpoints. +These commands are not offline simulators. + +Generate a proof with `iamproof.NewGenerator(region, audience, credentials)` and `Generate(ctx)`, then set Authorization to `"EdgeIAM " + token.Value()`. +Supply the AWS credentials provider already configured by your client application. +See the [helper example](../../iamproof/example_test.go) and [protocol guide](../../docs/iamproof.md). +The server needs no AWS credentials to forward the signed proof. +Do not configure AWS_IAM authorization on this mixed credential route; +the Lambda mains leave gateway identity disabled. Build a Lambda binary without deploying it: @@ -65,35 +64,38 @@ Build a Lambda binary without deploying it: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /tmp/orders-bootstrap ./examples/dispatcher/buffered ``` -Use the `streaming` directory for a separately configured REST streaming -integration. The examples provision no routes, authorizers or AWS resources. +Use the `streaming` directory for a separately configured REST streaming integration. +The examples provision no routes, authorizers or AWS resources. ## Request flow -Public health and narrowly scoped CORS preflight are explicit. Protected -requests must use GET and a valid order path. The dispatcher authenticates once, -installs the caller, parses Action once, selects one registry entry, checks its -identity policy and application grant, then executes the same entry and target. -The demo data is immutable; a mutable store must preserve object/version -consistency between authorization and execution, for example with a transaction. - -The application uses `Authenticate` to own its JSON error envelope. It preserves -challenges, no-store and CORS. Missing/rejected credentials yield 401; malformed -credentials/actions 400 (oversized credentials 431); unknown actions 404; denied -grants 403; unavailable dependencies 503. Cancellation prevents execution, but -the transport may terminate instead of delivering the attempted 503. - -Application-owned CORS permits the configured origin, GET, and Authorization, -Action and Custom-Trace headers. Avoid a competing Gateway CORS policy. CORS is -not authentication: direct clients still need credentials and grants. Additional -consumer middleware uses ordinary `http.Handler` and `r.Header`. - -`orders.watch` authorizes before flushing and stops on context/write/flush -failures. A later producer failure uses Go's documented `http.ErrAbortHandler`; -edge converts it to a sanitized terminal stream error. It cannot rewrite the -committed status or earlier records. Abrupt termination is not a complete result; -replay/resume protocols belong to the application. The Lambda main returns the -reader directly and logs only edge's sanitized stream diagnostic, never tokens. +Public health and narrowly scoped CORS preflight are explicit. +Protected requests must use GET and a valid order path. +The dispatcher authenticates once, installs the caller, parses Action once, selects one registry entry, checks its identity policy and application grant, then executes the same entry and target. +The demo data is immutable; +a mutable store must preserve object/version consistency between authorization and execution, for example with a transaction. + +The application uses `Authenticate` to own its JSON error envelope. +It preserves challenges, no-store and CORS. +Missing/rejected credentials yield 401; +malformed credentials/actions 400 (oversized credentials 431); +unknown actions 404; +denied grants 403; +unavailable dependencies 503. +Cancellation prevents execution, but the transport may terminate instead of delivering the attempted 503. + +Application-owned CORS permits the configured origin, GET, and Authorization, Action and Custom-Trace headers. +Avoid a competing Gateway CORS policy. +CORS is not authentication: direct clients still need credentials and grants. +Additional consumer middleware uses ordinary `http.Handler` and `r.Header`. + +`orders.watch` authorizes before flushing and stops on context/write/flush failures. +A later producer failure uses Go's documented `http.ErrAbortHandler`; +edge converts it to a sanitized terminal stream error. +It cannot rewrite the committed status or earlier records. +Abrupt termination is not a complete result; +replay/resume protocols belong to the application. +The Lambda main returns the reader directly and logs only edge's sanitized stream diagnostic, never tokens. ## Local tests @@ -102,12 +104,11 @@ go test -race ./examples/dispatcher/... sh scripts/check-consumer.sh ``` -Tests sign synthetic access tokens locally and run production verifiers with -closed local JWKS/STS transports. A synthetic STS response does not prove AWS -accepted a signature. No test falls through to a live provider. Fixtures retain -REST/HTTP API distinctions, V2 combined headers and separate response cookies. +Tests sign synthetic access tokens locally and run production verifiers with closed local JWKS/STS transports. +A synthetic STS response does not prove AWS accepted a signature. +No test falls through to a live provider. +Fixtures retain REST/HTTP API distinctions, V2 combined headers and separate response cookies. -Streaming tests consume incrementally and use `testing/synctest` to prove first -delivery before completion, slow-reader backpressure, early Close and late errors. -Small JSON/error tests collect bodies only for assertions. See the -[consumer testing guide](../../docs/consumer-testing.md). +Streaming tests consume incrementally and use `testing/synctest` to prove first delivery before completion, slow-reader backpressure, early Close and late errors. +Small JSON/error tests collect bodies only for assertions. +See the [consumer testing guide](../../docs/consumer-testing.md). diff --git a/examples/dispatcher/buffered/main.go b/examples/dispatcher/buffered/main.go index a2b738c..5422e87 100644 --- a/examples/dispatcher/buffered/main.go +++ b/examples/dispatcher/buffered/main.go @@ -22,7 +22,7 @@ func main() { slog.Error("configure buffered adapter", "error", err) os.Exit(1) } - // Register the object to retain JSON v2 envelope ownership. For a fixed - // typed input, register adapter.HandleV1 or adapter.HandleV2 instead. + // Register the object to retain JSON v2 envelope ownership. + // For a fixed typed input, register adapter.HandleV1 or adapter.HandleV2 instead. lambda.Start(adapter) } diff --git a/examples/dispatcher/internal/orders/app.go b/examples/dispatcher/internal/orders/app.go index 128d564..170a918 100644 --- a/examples/dispatcher/internal/orders/app.go +++ b/examples/dispatcher/internal/orders/app.go @@ -16,22 +16,24 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Grants resolves application enrollment and permissions, independently of token -// scopes and AWS IAM policies. Implementations must support concurrent requests. +// Grants resolves application enrollment and permissions, independently of token scopes and AWS IAM policies. +// Implementations must support concurrent requests. type Grants interface { Allowed(context.Context, identity.Caller, string, string) (bool, error) } -// Store reads the exact authorized resource. Events calls yield synchronously, -// stops on its first error, and observes context cancellation. A database-backed -// application must also preserve object/version consistency through execution. +// Store reads the exact authorized resource. +// Events calls yield synchronously, stops on its first error, and observes context cancellation. +// A database-backed application must also preserve object/version consistency through execution. type Store interface { Read(context.Context, string) (string, error) Events(context.Context, string, func(string) error) error } -// Config belongs to this example. Required strings have no implicit defaults. -// Transports are trusted infrastructure; nil selects the verifier's default. +// Config belongs to this example. +// Required strings have no implicit defaults. +// Transports are trusted infrastructure; +// nil selects the verifier's default. type Config struct { Cognito authn.CognitoConfig Region string @@ -44,8 +46,8 @@ type Config struct { Streaming bool } -// New constructs the complete application without network I/O. Streaming is a -// startup choice: orders.watch is absent from the buffered variant's registry. +// New constructs the complete application without network I/O. +// Streaming is a startup choice: orders.watch is absent from the buffered variant's registry. func New(cfg Config) (http.Handler, error) { if cfg.Grants == nil || cfg.Store == nil || cfg.Origin == "" { return nil, errors.New("orders: missing application configuration") @@ -63,7 +65,8 @@ func New(cfg Config) (http.Handler, error) { return nil, err } authentication, err := authn.New(authn.Config{ - Realm: "orders", Bearer: bearer.Verify, + Realm: "orders", + Bearer: bearer.Verify, IAMProof: func(ctx context.Context, credential string) (identity.Caller, error) { caller, err := proof.Verify(ctx, credential) if errors.Is(err, iamproof.ErrInvalidProof) { @@ -121,8 +124,8 @@ func New(cfg Config) (http.Handler, error) { }} } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // This example owns CORS. Disable conflicting Gateway-managed CORS when - // deploying it, or move this policy wholly into that deployment layer. + // This example owns CORS. + // Disable conflicting Gateway-managed CORS when deploying it, or move this policy wholly into that deployment layer. w.Header().Add("Vary", "Origin") if origins := r.Header.Values("Origin"); len(origins) == 1 && origins[0] == cfg.Origin { w.Header().Set("Access-Control-Allow-Origin", cfg.Origin) @@ -192,8 +195,8 @@ func New(cfg Config) (http.Handler, error) { }), nil } -// The application accepts canonical decimal IDs only; no aliases, escaping or -// query parameters can change the target between authorization and execution. +// The application accepts canonical decimal IDs only; +// no aliases, escaping or query parameters can change the target between authorization and execution. func resourcePath(path string) (string, bool) { parts := strings.Split(path, "/") if len(parts) != 5 || parts[0] != "" || parts[1] != "tenants" || parts[3] != "orders" { @@ -242,7 +245,8 @@ func writeJSON(w http.ResponseWriter, status int, payload map[string]string) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) - // Status is committed. A client write failure cannot be replaced by JSON. + // Status is committed. + // A client write failure cannot be replaced by JSON. _ = json.MarshalWrite(w, payload) } @@ -250,8 +254,8 @@ func watch(w http.ResponseWriter, r *http.Request, store Store, resource string) w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-store") controller := http.NewResponseController(w) - // Publish only after authorization; this also makes an empty stream's - // commitment explicit before entering a potentially slow producer. + // Publish only after authorization; + // this also makes an empty stream's commitment explicit before entering a potentially slow producer. if err := controller.Flush(); err != nil { return } @@ -268,9 +272,10 @@ func watch(w http.ResponseWriter, r *http.Request, store Store, resource string) return controller.Flush() }) if err != nil { - // net/http's documented abort sentinel prevents a truncated result from - // appearing complete. Edge converts this post-handoff panic to ErrStream; - // it never exposes the store's diagnostic. Do not recover it in middleware. + // net/http's documented abort sentinel prevents a truncated result from appearing complete. + // Edge converts this post-handoff panic to ErrStream; + // it never exposes the store's diagnostic. + // Do not recover it in middleware. panic(http.ErrAbortHandler) } } diff --git a/examples/dispatcher/internal/orders/app_test.go b/examples/dispatcher/internal/orders/app_test.go index 861560d..b7accb6 100644 --- a/examples/dispatcher/internal/orders/app_test.go +++ b/examples/dispatcher/internal/orders/app_test.go @@ -31,8 +31,8 @@ func (s *spyStore) Read(ctx context.Context, target string) (string, error) { if err := ctx.Err(); err != nil { return "", err } - // The response exposes which target actually executed, independently of the - // grant callback's observation. A changed target makes the test fail. + // The response exposes which target actually executed, independently of the grant callback's observation. + // A changed target makes the test fail. return "read:" + target, nil } @@ -95,8 +95,7 @@ func TestConsumerOutcomes(t *testing.T) { } headers := http.Header{"Authorization": authorization, "Action": actions, "Origin": {origin}, "Custom-Trace": {"first", "second"}, "Cookie": {"client=example"}} h := application(t, cfg) - // Consumer middleware sees ordinary Go headers/cookies, including - // the intentionally lossy V2 comma combination. + // Consumer middleware sees ordinary Go headers/cookies, including the intentionally lossy V2 comma combination. observed := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { wantTrace := []string{"first", "second"} if strings.HasSuffix(format, "v2") { diff --git a/examples/dispatcher/internal/orders/environment.go b/examples/dispatcher/internal/orders/environment.go index 4e24349..ad2f4a1 100644 --- a/examples/dispatcher/internal/orders/environment.go +++ b/examples/dispatcher/internal/orders/environment.go @@ -12,8 +12,8 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// FromEnvironment configures real verification and a small, explicitly enrolled -// read-only demonstration store. It never obtains server-side AWS credentials. +// FromEnvironment configures real verification and a small, explicitly enrolled read-only demonstration store. +// It never obtains server-side AWS credentials. func FromEnvironment(streaming bool) (http.Handler, error) { values := make(map[string]string) for _, name := range []string{"COGNITO_ISSUER", "COGNITO_CLIENT_ID", "COGNITO_AUDIENCE", "IAM_PROOF_REGION", "IAM_PROOF_AUDIENCE", "ORDER_JWT_SUBJECT", "ORDER_IAM_PRINCIPAL", "CORS_ORIGIN"} { @@ -31,16 +31,19 @@ func FromEnvironment(streaming bool) (http.Handler, error) { Issuer: values["COGNITO_ISSUER"], Clients: []authn.CognitoClient{{ClientID: values["COGNITO_CLIENT_ID"], Audience: values["COGNITO_AUDIENCE"]}}, }, - Region: values["IAM_PROOF_REGION"], ProofAudience: values["IAM_PROOF_AUDIENCE"], - IAMPrincipal: values["ORDER_IAM_PRINCIPAL"], Origin: values["CORS_ORIGIN"], - Grants: demoGrants{issuer: values["COGNITO_ISSUER"], subject: values["ORDER_JWT_SUBJECT"], principal: values["ORDER_IAM_PRINCIPAL"]}, - Store: demoStore{}, Streaming: streaming, + Region: values["IAM_PROOF_REGION"], + ProofAudience: values["IAM_PROOF_AUDIENCE"], + IAMPrincipal: values["ORDER_IAM_PRINCIPAL"], + Origin: values["CORS_ORIGIN"], + Grants: demoGrants{issuer: values["COGNITO_ISSUER"], subject: values["ORDER_JWT_SUBJECT"], principal: values["ORDER_IAM_PRINCIPAL"]}, + Store: demoStore{}, + Streaming: streaming, }) } -// These application grants are server configuration, never client claims. This -// example enrolls one exact JWT subject and one full IAM caller ARN. It does not -// infer IAM role paths, role lifecycle identity or a human behind a session. +// These application grants are server configuration, never client claims. +// This example enrolls one exact JWT subject and one full IAM caller ARN. +// It does not infer IAM role paths, role lifecycle identity or a human behind a session. type demoGrants struct { issuer string subject string diff --git a/examples/dispatcher/internal/orders/environment_test.go b/examples/dispatcher/internal/orders/environment_test.go index 4ad53c0..0964b7a 100644 --- a/examples/dispatcher/internal/orders/environment_test.go +++ b/examples/dispatcher/internal/orders/environment_test.go @@ -6,8 +6,8 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Test the actual demonstration enrollment policy separately from cryptographic -// verification. All identities here are explicitly custom-assertion fixtures. +// Test the actual demonstration enrollment policy separately from cryptographic verification. +// All identities here are explicitly custom-assertion fixtures. func TestDemoEnrollment(t *testing.T) { const issuer = "https://issuer.example/pool" const principal = "arn:aws:iam::123456789012:user/Alice" @@ -32,8 +32,8 @@ func TestDemoEnrollment(t *testing.T) { if tc.arn != "" { caller, err = identity.NewIAM(tc.arn, identity.SourceCustomAssertion) } else { - // A subjectless caller still needs a client identity. Enrollment - // must not silently treat that client as the configured subject. + // A subjectless caller still needs a client identity. + // Enrollment must not silently treat that client as the configured subject. claims := map[string]any{"iss": tc.issuer, "client_id": "app"} if tc.subject != "" { claims["sub"] = tc.subject diff --git a/examples/dispatcher/internal/orders/fixtures_test.go b/examples/dispatcher/internal/orders/fixtures_test.go index 9a7992d..b1f4cc6 100644 --- a/examples/dispatcher/internal/orders/fixtures_test.go +++ b/examples/dispatcher/internal/orders/fixtures_test.go @@ -48,9 +48,12 @@ func providerFixtures(t *testing.T) (orders.Config, map[string]string) { t.Fatal(err) } jwks := marshal(t, map[string]any{"keys": []any{map[string]any{ - "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "fixture", - "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), - "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "fixture", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), }}}) now := time.Now().Unix() claims := marshal(t, map[string]any{"iss": issuer, "sub": "alice", "client_id": "app", "aud": "https://orders.example", "token_use": "access", "scope": "orders.read", "iat": now, "exp": now + 3600}) @@ -73,7 +76,9 @@ func providerFixtures(t *testing.T) (orders.Config, map[string]string) { t.Fatal(err) } cfg := orders.Config{ - Cognito: authn.CognitoConfig{Issuer: issuer, Clients: []authn.CognitoClient{{ClientID: "app", Audience: "https://orders.example"}}, + Cognito: authn.CognitoConfig{ + Issuer: issuer, + Clients: []authn.CognitoClient{{ClientID: "app", Audience: "https://orders.example"}}, Transport: transportFunc(func(r *http.Request) (*http.Response, error) { if r.URL.String() != issuer+"/.well-known/jwks.json" { return nil, errors.New("unexpected JWKS request") @@ -81,7 +86,10 @@ func providerFixtures(t *testing.T) (orders.Config, map[string]string) { return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(jwks))}, nil }), }, - Region: "eu-west-2", ProofAudience: "orders.test", IAMPrincipal: principal, Origin: origin, + Region: "eu-west-2", + ProofAudience: "orders.test", + IAMPrincipal: principal, + Origin: origin, STSTransport: transportFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Host != "sts.eu-west-2.amazonaws.com" || r.Header.Get("X-Edge-IAM-Audience") != "orders.test" || r.URL.Query().Get("Action") != "GetCallerIdentity" || r.URL.Query().Get("X-Amz-Signature") == "" { return nil, errors.New("unexpected STS request") @@ -111,8 +119,8 @@ func application(t *testing.T, cfg orders.Config) http.Handler { return h } -// Explicitly authored fixtures retain the product/version distinction. The SDK -// V1 request type cannot distinguish REST from HTTP API payload 1.0 by itself. +// Explicitly authored fixtures retain the product/version distinction. +// The SDK V1 request type cannot distinguish REST from HTTP API payload 1.0 by itself. func restEvent(path string, headers http.Header) events.APIGatewayProxyRequest { return events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: path, MultiValueHeaders: headers.Clone(), RequestContext: events.APIGatewayProxyRequestContext{APIID: "synthetic-rest"}} } @@ -130,12 +138,19 @@ func httpV2Event(path string, headers http.Header) events.APIGatewayV2HTTPReques } combined[strings.ToLower(name)] = strings.Join(values, ",") } - return events.APIGatewayV2HTTPRequest{Version: "2.0", RawPath: path, Headers: combined, Cookies: cookies, - RequestContext: events.APIGatewayV2HTTPRequestContext{APIID: "synthetic-http", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}}} + return events.APIGatewayV2HTTPRequest{ + Version: "2.0", + RawPath: path, + Headers: combined, + Cookies: cookies, + RequestContext: events.APIGatewayV2HTTPRequestContext{APIID: "synthetic-http", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}}, + } } -// Small responses only. Repeated V2 headers remain combined; cookies are kept -// separate. Incremental/late-error assertions use the reader directly elsewhere. +// Small responses only. +// Repeated V2 headers remain combined; +// cookies are kept separate. +// Incremental/late-error assertions use the reader directly elsewhere. type response struct { status int headers http.Header diff --git a/examples/dispatcher/internal/orders/stream_test.go b/examples/dispatcher/internal/orders/stream_test.go index 8fc9a83..8e1d1a6 100644 --- a/examples/dispatcher/internal/orders/stream_test.go +++ b/examples/dispatcher/internal/orders/stream_test.go @@ -83,8 +83,8 @@ func TestStreamLifecycle(t *testing.T) { close(continueProduction) synctest.Wait() if outcome == "complete" { - // No consumer has read the second record. The unbuffered bridge - // must hold the writer here instead of collecting the body. + // No consumer has read the second record. + // The unbuffered bridge must hold the writer here instead of collecting the body. select { case <-finished: t.Fatal("slow reader did not apply backpressure") diff --git a/examples/dispatcher/streaming/main.go b/examples/dispatcher/streaming/main.go index 19f5be7..a03790f 100644 --- a/examples/dispatcher/streaming/main.go +++ b/examples/dispatcher/streaming/main.go @@ -20,15 +20,16 @@ func main() { os.Exit(1) } adapter, err := edge.NewStreaming(h, edge.WithStreamErrorReporter(func(_ context.Context, err error) { - // Edge supplies sanitized invocation diagnostics. Never log a request's - // Authorization header, token, proof or raw provider error here. + // Edge supplies sanitized invocation diagnostics. + // Never log a request's Authorization header, token, proof or raw provider error here. slog.Error("orders stream terminated", "error", err) })) if err != nil { slog.Error("configure streaming adapter", "error", err) os.Exit(1) } - // Return the reader directly; do not collect or JSON-encode the stream. + // Return the reader directly; + // do not collect or JSON-encode the stream. // For already typed REST events, register adapter.HandleV1 instead. lambda.Start(adapter.Handle) } diff --git a/gateway_identity.go b/gateway_identity.go index 0e6a34c..b966245 100644 --- a/gateway_identity.go +++ b/gateway_identity.go @@ -11,8 +11,7 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Candidate discovery precedes construction, so malformed competing producers -// cannot disappear through validation or establish precedence by field order. +// Candidate discovery precedes construction, so malformed competing producers cannot disappear through validation or establish precedence by field order. type gatewayCandidates struct { iam func() (identity.Caller, error) jwt func() (identity.Caller, error) @@ -154,9 +153,9 @@ func rawGatewayJWT(fields map[string]jsontext.Value, budget int) (identity.Calle return identity.NewJWT(claims, identity.SourceGatewayAssertion, identity.WithGatewayScopes(scopes)) } -// Bound scope expansion before building the slice. NewJWT then enforces the -// combined claims/scopes charge and owns strings. Raw scopes use no extra wire -// allowance: whitespace does not change their weighted collection cost. +// Bound scope expansion before building the slice. +// NewJWT then enforces the combined claims/scopes charge and owns strings. +// Raw scopes use no extra wire allowance: whitespace does not change their weighted collection cost. func rawGatewayScopes(raw jsontext.Value, maximum int) ([]string, error) { if !nonnull(raw) { return nil, nil @@ -248,8 +247,8 @@ func typedGatewayClaims(input any, maximum int) (identity.Claims, error) { case map[string]any: return identity.NewClaims(input, identity.WithClaimsBudget(maximum)) case map[string]string: - // V1 callers may supply a concrete string map. Bound the temporary - // conversion before allocating it, and retain decoded-value provenance. + // V1 callers may supply a concrete string map. + // Bound the temporary conversion before allocating it, and retain decoded-value provenance. remaining := maximum - 64 if len(input) > max(remaining, 0)/64 { return identity.Claims{}, gatewayScopeLimit(maximum) diff --git a/gateway_identity_test.go b/gateway_identity_test.go index 6a20511..13675c3 100644 --- a/gateway_identity_test.go +++ b/gateway_identity_test.go @@ -157,7 +157,8 @@ func TestGatewayFidelityAndOwnership(t *testing.T) { } event := events.APIGatewayV2HTTPRequest{ RawPath: "/", RequestContext: events.APIGatewayV2HTTPRequestContext{ - APIID: "api", HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}, + APIID: "api", + HTTP: events.APIGatewayV2HTTPRequestContextHTTPDescription{Method: "GET"}, Authorizer: &events.APIGatewayV2HTTPRequestContextAuthorizerDescription{JWT: &events.APIGatewayV2HTTPRequestContextAuthorizerJWTDescription{ Claims: map[string]string{"iss": "issuer", "sub": "subject", "aud": "[client]", "cognito:groups": "[Ops]", "custom": "9007199254740993"}, }}, @@ -267,8 +268,9 @@ func TestTypedGatewayDoesNotMarshalAndChecksScopeBudget(t *testing.T) { } event := events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{ APIID: "api", Authorizer: map[string]any{ - "claims": map[string]string{"iss": "issuer", "sub": "subject"}, - "scopes": []any{"read"}, "ignored": forbiddenEnvelopeMarshal{}, + "claims": map[string]string{"iss": "issuer", "sub": "subject"}, + "scopes": []any{"read"}, + "ignored": forbiddenEnvelopeMarshal{}, }, }} if _, err := a.HandleV1(t.Context(), event); err != nil { @@ -297,9 +299,7 @@ func TestTypedV1NilScopesAreUnavailable(t *testing.T) { } for _, scopes := range []any{nil, []any(nil), []string(nil)} { ran = false - event := events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{ - APIID: "api", Authorizer: map[string]any{"claims": nil, "scopes": scopes}, - }} + event := events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{APIID: "api", Authorizer: map[string]any{"claims": nil, "scopes": scopes}}} if _, err := a.HandleV1(t.Context(), event); err != nil || !ran { t.Errorf("nil scope placeholder %T: ran=%t err=%v", scopes, ran, err) } @@ -332,7 +332,11 @@ func TestTypedV2CannotDetectDiscardedUnknownProducer(t *testing.T) { func FuzzGatewayIdentityBoundary(f *testing.F) { for _, auth := range []string{ - `null`, `{}`, `{"jwt":{}}`, `{"iam":{},"jwt":{}}`, `{"lambda":{}}`, + `null`, + `{}`, + `{"jwt":{}}`, + `{"iam":{},"jwt":{}}`, + `{"lambda":{}}`, `{"jwt":{"claims":{"iss":"issuer","sub":"s"},"scopes":[]}}`, `{"claims":{"iss":"issuer","sub":"s"},"scopes":["read"]}`, `{"iam":{"userArn":"arn:aws:iam::123456789012:root"}}`, diff --git a/iamproof/doc.go b/iamproof/doc.go index 36497f3..c13b195 100644 --- a/iamproof/doc.go +++ b/iamproof/doc.go @@ -1,17 +1,18 @@ -// Package iamproof generates short-lived, audience-bound GetCallerIdentity -// proofs and verifies them online with regional AWS STS. It does not require an -// API Gateway authorizer. It neither authorizes application actions nor installs -// callers in a context; compose those responsibilities in the application. +// Package iamproof generates short-lived, audience-bound GetCallerIdentity proofs and verifies them online with regional AWS STS. +// It does not require an API Gateway authorizer. +// It neither authorizes application actions nor installs callers in a context; +// compose those responsibilities in the application. // -// Send Token.Value as the credential in Authorization: EdgeIAM , over -// HTTPS. A proof is reusable within its freshness window and does not sign the -// application request body, method or action. Do not log credentials or signed -// STS requests. Generation uses the caller's AWS credentials; verification never -// signs with the server's credentials. Every verification calls STS, without an -// application retry or result cache. Account for STS quotas when sizing traffic. +// Send Token.Value as the credential in Authorization: EdgeIAM , over HTTPS. +// A proof is reusable within its freshness window and does not sign the application request body, method or action. +// Do not log credentials or signed STS requests. +// Generation uses the caller's AWS credentials; +// verification never signs with the server's credentials. +// Every verification calls STS, without an application retry or result cache. +// Account for STS quotas when sizing traffic. // -// The version 1 protocol bounds encoded credentials to 8 KiB and STS responses -// to 64 KiB. Proofs expire 60 seconds after their signing time; verification -// permits up to 30 seconds of future clock skew, with no expired-proof grace. +// The version 1 protocol bounds encoded credentials to 8 KiB and STS responses to 64 KiB. +// Proofs expire 60 seconds after their signing time; +// verification permits up to 30 seconds of future clock skew, with no expired-proof grace. // These are library policies, not AWS credential-size or expiration guarantees. package iamproof diff --git a/iamproof/example_test.go b/iamproof/example_test.go index 78b896f..cac7e81 100644 --- a/iamproof/example_test.go +++ b/iamproof/example_test.go @@ -12,12 +12,10 @@ import ( func ExampleGenerator_Generate() { ctx := context.Background() - // Synthetic credentials keep this example entirely local. In an application, - // pass cfg.Credentials from your existing AWS SDK configuration instead. + // Synthetic credentials keep this example entirely local. + // In an application, pass cfg.Credentials from your existing AWS SDK configuration instead. provider := aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { - return aws.Credentials{ - AccessKeyID: "AKIAIOSFODNN7EXAMPLE", SecretAccessKey: "synthetic-example-only", - }, nil + return aws.Credentials{AccessKeyID: "AKIAIOSFODNN7EXAMPLE", SecretAccessKey: "synthetic-example-only"}, nil }) generator, err := iamproof.NewGenerator("eu-west-2", "orders.production", provider) if err != nil { @@ -36,7 +34,8 @@ func ExampleGenerator_Generate() { } req.Header.Set("Authorization", "EdgeIAM "+token.Value()) req.Header.Set("Action", "ListOrders") - // Sending req is the application's responsibility. Never log its credential. + // Sending req is the application's responsibility. + // Never log its credential. fmt.Println(req.Header.Get("Action")) fmt.Println(token) // Default diagnostics redact the proof. // Output: @@ -50,8 +49,8 @@ func ExampleNewVerifier() { fmt.Println(err) return } - // Pass verifier.Verify to your authentication layer. It accepts a context - // and the proof credential, returns an identity.Caller, and calls real STS. + // Pass verifier.Verify to your authentication layer. + // It accepts a context and the proof credential, returns an identity.Caller, and calls real STS. // Construction alone makes no network request. fmt.Println(verifier != nil) // Output: true diff --git a/iamproof/generate.go b/iamproof/generate.go index 1ade42e..0f9d760 100644 --- a/iamproof/generate.go +++ b/iamproof/generate.go @@ -12,8 +12,10 @@ import ( "github.com/aws/aws-sdk-go-v2/aws/signer/v4" ) -// Token owns a versioned proof. Its zero value is unusable. Default string and -// debug formatting are redacted; Value explicitly exposes the credential. +// Token owns a versioned proof. +// Its zero value is unusable. +// Default string and debug formatting are redacted; +// Value explicitly exposes the credential. type Token struct { value string expires time.Time @@ -32,15 +34,15 @@ func (t Token) String() string { return "iamproof.Token(redacted)" } // GoString returns the same redacted description as String. func (t Token) GoString() string { return t.String() } -// Generator presigns proofs using its credentials provider. It is safe for -// concurrent use if the provider is. A zero Generator is not configured. +// Generator presigns proofs using its credentials provider. +// It is safe for concurrent use if the provider is. +// A zero Generator is not configured. type Generator struct { cfg configuration credentials aws.CredentialsProvider } -// NewGenerator requires an explicit standard STS region, a case-sensitive -// audience of 1-256 visible ASCII bytes without spaces, and a nonnil provider. +// NewGenerator requires an explicit standard STS region, a case-sensitive audience of 1-256 visible ASCII bytes without spaces, and a nonnil provider. // The provider owns credential caching, refresh and any retrieval I/O. func NewGenerator(region, audience string, credentials aws.CredentialsProvider) (*Generator, error) { if isNil(credentials) { @@ -53,9 +55,10 @@ func NewGenerator(region, audience string, credentials aws.CredentialsProvider) return &Generator{cfg: cfg, credentials: credentials}, nil } -// Generate retrieves fresh credentials and presigns locally, without calling -// STS. Failure returns a zero Token and a sanitized package or caller-context -// error. Oversized proofs fail with ErrProofTooLarge; credentials are not cut. +// Generate retrieves fresh credentials and presigns locally, without calling STS. +// Failure returns a zero Token and a sanitized package or caller-context error. +// Oversized proofs fail with ErrProofTooLarge; +// credentials are not cut. func (g *Generator) Generate(ctx context.Context) (Token, error) { if g == nil || g.credentials == nil || ctx == nil { return Token{}, ErrInvalidConfiguration @@ -74,8 +77,8 @@ func (g *Generator) Generate(ctx context.Context) (Token, error) { if !validAccessKey(creds.AccessKeyID) || creds.SecretAccessKey == "" || !utf8.ValidString(creds.SessionToken) || creds.CanExpire && !now.Before(creds.Expires) { return Token{}, ErrCredentialsUnavailable } - // A session longer than the entire decoded budget cannot fit. Check before - // query escaping or JSON encoding can amplify a provider's oversized token. + // A session longer than the entire decoded budget cannot fit. + // Check before query escaping or JSON encoding can amplify a provider's oversized token. if len(creds.SessionToken) > base64.RawURLEncoding.DecodedLen(maxProofBytes-3) { return Token{}, ErrProofTooLarge } @@ -97,8 +100,10 @@ func (g *Generator) Generate(ctx context.Context) (Token, error) { return Token{}, ErrCredentialsUnavailable } wire, err := json.Marshal(envelope{ - Audience: g.cfg.audience, Credential: q.Get("X-Amz-Credential"), - Date: q.Get("X-Amz-Date"), Signature: q.Get("X-Amz-Signature"), + Audience: g.cfg.audience, + Credential: q.Get("X-Amz-Credential"), + Date: q.Get("X-Amz-Date"), + Signature: q.Get("X-Amz-Signature"), SessionToken: q.Get("X-Amz-Security-Token"), }) if err != nil { diff --git a/iamproof/iamproof_test.go b/iamproof/iamproof_test.go index c30e476..cf2e818 100644 --- a/iamproof/iamproof_test.go +++ b/iamproof/iamproof_test.go @@ -30,8 +30,9 @@ const ( ) var testCredentials = aws.Credentials{ - AccessKeyID: "AKIAIOSFODNN7EXAMPLE", SecretAccessKey: "synthetic-secret-not-valid-in-AWS", - SessionToken: "synthetic+/=session-token", + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "synthetic-secret-not-valid-in-AWS", + SessionToken: "synthetic+/=session-token", } type roundTripFunc func(*http.Request) (*http.Response, error) @@ -111,8 +112,9 @@ func assertFailure(t testing.TB, caller identity.Caller, err, want error) { } } -// The transport recomputes a signature with synthetic credentials. This checks -// request reconstruction and tampering locally; it is not an AWS acceptance test. +// The transport recomputes a signature with synthetic credentials. +// This checks request reconstruction and tampering locally; +// it is not an AWS acceptance test. func checkingTransport(t testing.TB, region, host, arn string, creds aws.Credentials) http.RoundTripper { t.Helper() return roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -136,8 +138,7 @@ func checkingTransport(t testing.TB, region, host, arn string, creds aws.Credent } unsigned := req.Clone(req.Context()) unsigned.URL.RawQuery = url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}, "X-Amz-Expires": {"60"}}.Encode() - signed, _, err := v4.NewSigner(func(o *v4.SignerOptions) { o.DisableHeaderHoisting = true }).PresignHTTP(req.Context(), creds, unsigned, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "sts", region, when) + signed, _, err := v4.NewSigner(func(o *v4.SignerOptions) { o.DisableHeaderHoisting = true }).PresignHTTP(req.Context(), creds, unsigned, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "sts", region, when) if err != nil { t.Fatal(err) } @@ -208,9 +209,14 @@ func TestMalformedProofsDoNotReachSTS(t *testing.T) { return response(200, successXML(testARN)), nil })) cases := map[string]string{ - "empty": "", "scheme": "EdgeIAM " + token, "version": "v2." + token[3:], - "padding": token + "=", "newline": token[:10] + "\n" + token[10:], - "oversize": strings.Repeat("a", 8193), "null": rawToken("null"), "array": rawToken("[]"), + "empty": "", + "scheme": "EdgeIAM " + token, + "version": "v2." + token[3:], + "padding": token + "=", + "newline": token[:10] + "\n" + token[10:], + "oversize": strings.Repeat("a", 8193), + "null": rawToken("null"), + "array": rawToken("[]"), "trailing": rawToken(string(base) + "{}"), "duplicate": rawToken(`{"audience":"other",` + string(base[1:])), "utf8": rawToken(strings.Replace(string(base), testAudience, "\xff", 1)), @@ -390,23 +396,22 @@ func TestDependencyClassification(t *testing.T) { func TestXMLIdentityValidation(t *testing.T) { valid := successXML(testARN) for name, body := range map[string]string{ - "wrong_namespace": strings.ReplaceAll(valid, namespace, "https://attacker.invalid"), - "no_namespace": strings.ReplaceAll(valid, ` xmlns="`+namespace+`"`, ""), - "foreign_arn": strings.Replace(valid, "", ``, 1), - "duplicate_arn": strings.Replace(valid, "", ""+testARN+"", 1), - "duplicate_result": strings.Replace(valid, "", - "", 1), - "missing_account": strings.Replace(valid, "123456789012", "", 1), - "empty_id": strings.Replace(valid, "opaque:session", "", 1), - "wrong_account": strings.Replace(valid, "123456789012", "000000000000", 1), - "wrong_partition": strings.Replace(valid, "arn:aws:", "arn:aws-cn:", 1), - "bare_role": successXML("arn:aws:iam::123456789012:role/Operator"), - "nested_arn": strings.Replace(valid, testARN, ""+testARN+"", 1), - "second_doc": valid + valid, - "trailing": valid + "garbage", - "dtd": `` + valid, - "broken": valid[:len(valid)-1], - "oversized": valid + strings.Repeat(" ", 65536), + "wrong_namespace": strings.ReplaceAll(valid, namespace, "https://attacker.invalid"), + "no_namespace": strings.ReplaceAll(valid, ` xmlns="`+namespace+`"`, ""), + "foreign_arn": strings.Replace(valid, "", ``, 1), + "duplicate_arn": strings.Replace(valid, "", ""+testARN+"", 1), + "duplicate_result": strings.Replace(valid, "", "", 1), + "missing_account": strings.Replace(valid, "123456789012", "", 1), + "empty_id": strings.Replace(valid, "opaque:session", "", 1), + "wrong_account": strings.Replace(valid, "123456789012", "000000000000", 1), + "wrong_partition": strings.Replace(valid, "arn:aws:", "arn:aws-cn:", 1), + "bare_role": successXML("arn:aws:iam::123456789012:role/Operator"), + "nested_arn": strings.Replace(valid, testARN, ""+testARN+"", 1), + "second_doc": valid + valid, + "trailing": valid + "garbage", + "dtd": `` + valid, + "broken": valid[:len(valid)-1], + "oversized": valid + strings.Repeat(" ", 65536), } { t.Run(name, func(t *testing.T) { v := verifier(t, roundTripFunc(func(*http.Request) (*http.Response, error) { return response(200, body), nil })) diff --git a/iamproof/protocol.go b/iamproof/protocol.go index 8edba31..10a52ed 100644 --- a/iamproof/protocol.go +++ b/iamproof/protocol.go @@ -38,15 +38,13 @@ func configure(region, audience string) (configuration, error) { if len(region) > 63 || !regionPattern.MatchString(region) || strings.HasPrefix(region, "fips-") || !validAudience(audience) { return configuration{}, ErrInvalidConfiguration } - endpoint, err := sts.NewDefaultEndpointResolverV2().ResolveEndpoint(context.Background(), sts.EndpointParameters{ - Region: new(region), UseGlobalEndpoint: new(false), UseFIPS: new(false), UseDualStack: new(false), - }) + endpoint, err := sts.NewDefaultEndpointResolverV2().ResolveEndpoint(context.Background(), sts.EndpointParameters{Region: new(region), UseGlobalEndpoint: new(false), UseFIPS: new(false), UseDualStack: new(false)}) if err != nil { return configuration{}, ErrInvalidConfiguration } - // The V2 public endpoint does not expose its partition. Obtain that metadata - // from the SDK's public legacy resolver, and require both resolvers to agree - // on the standard endpoint. No private SDK imports or copied partition table. + // The V2 public endpoint does not expose its partition. + // Obtain that metadata from the SDK's public legacy resolver, and require both resolvers to agree on the standard endpoint. + // No private SDK imports or copied partition table. metadata, err := sts.NewDefaultEndpointResolver().ResolveEndpoint(region, sts.EndpointResolverOptions{}) if err != nil || metadata.PartitionID == "" || metadata.SigningRegion != region || metadata.URL != endpoint.URI.String() { return configuration{}, ErrInvalidConfiguration @@ -180,8 +178,8 @@ func (p envelope) request(ctx context.Context, cfg configuration) *http.Request if p.SessionToken != "" { query.Set("X-Amz-Security-Token", p.SessionToken) } - // SigV4 uses RFC 3986 space escaping, not form encoding's '+'. Preserve - // the SDK's encoding even for an unusual opaque session-token value. + // SigV4 uses RFC 3986 space escaping, not form encoding's '+'. + // Preserve the SDK's encoding even for an unusual opaque session-token value. req.URL.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20") return req } diff --git a/iamproof/verify.go b/iamproof/verify.go index 72fb2a3..57d4fc7 100644 --- a/iamproof/verify.go +++ b/iamproof/verify.go @@ -9,17 +9,20 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// Verifier authenticates each proof through its configured regional STS -// endpoint. It is safe for concurrent use if its transport is. Its zero value -// is not configured. It never obtains or uses server-side AWS credentials. +// Verifier authenticates each proof through its configured regional STS endpoint. +// It is safe for concurrent use if its transport is. +// Its zero value is not configured. +// It never obtains or uses server-side AWS credentials. type Verifier struct { cfg configuration client *http.Client timeout time.Duration } -// VerifierOption configures a verifier. Options apply in order; the last value -// for a setting wins. Nil options and invalid values fail construction. +// VerifierOption configures a verifier. +// Options apply in order; +// the last value for a setting wins. +// Nil options and invalid values fail construction. type VerifierOption func(*verifierConfig) error type verifierConfig struct { @@ -40,8 +43,8 @@ func WithTransport(transport http.RoundTripper) VerifierOption { } } -// WithTimeout sets a positive verification network deadline, defaulting to five -// seconds. The caller's earlier context deadline still wins. +// WithTimeout sets a positive verification network deadline, defaulting to five seconds. +// The caller's earlier context deadline still wins. func WithTimeout(timeout time.Duration) VerifierOption { return func(cfg *verifierConfig) error { if timeout <= 0 { @@ -53,8 +56,8 @@ func WithTimeout(timeout time.Duration) VerifierOption { } // NewVerifier requires the same region and audience as the client generator. -// Its owned HTTP client disables redirects and cookies. The default transport -// is http.DefaultTransport as configured when this constructor is called. +// Its owned HTTP client disables redirects and cookies. +// The default transport is http.DefaultTransport as configured when this constructor is called. func NewVerifier(region, audience string, opts ...VerifierOption) (*Verifier, error) { cfg, err := configure(region, audience) if err != nil { @@ -73,21 +76,22 @@ func NewVerifier(region, audience string, opts ...VerifierOption) (*Verifier, er return nil, ErrInvalidConfiguration } return &Verifier{ - cfg: cfg, timeout: network.timeout, + cfg: cfg, + timeout: network.timeout, client: &http.Client{Transport: network.transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, }, nil } -// Verify accepts Token.Value, without an HTTP authentication scheme. It returns -// an IAM caller with SourceVerifiedIAMProof only after STS confirms the proof, -// response invariants hold and the proof remains fresh. It does not install the -// caller in a context. Every failure returns an anonymous zero caller. +// Verify accepts Token.Value, without an HTTP authentication scheme. +// It returns an IAM caller with SourceVerifiedIAMProof only after STS confirms the proof, response invariants hold and the proof remains fresh. +// It does not install the caller in a context. +// Every failure returns an anonymous zero caller. // -// Malformed or rejected credentials match ErrInvalidProof. Dependency failures, -// internal timeouts and unexpected responses match ErrUnavailable. Caller -// cancellation/deadline errors are preserved, without unsafe dependency causes. +// Malformed or rejected credentials match ErrInvalidProof. +// Dependency failures, internal timeouts and unexpected responses match ErrUnavailable. +// Caller cancellation/deadline errors are preserved, without unsafe dependency causes. func (v *Verifier) Verify(ctx context.Context, token string) (identity.Caller, error) { if v == nil || v.client == nil || ctx == nil { return identity.Caller{}, ErrInvalidConfiguration @@ -126,8 +130,7 @@ func (v *Verifier) Verify(ctx context.Context, token string) (identity.Caller, e if err != nil { return identity.Caller{}, ErrUnavailable } - caller, err := identity.NewIAM(fields["Arn"], identity.SourceVerifiedIAMProof, - identity.WithIAMAccountID(fields["Account"]), identity.WithIAMPrincipalID(fields["UserId"])) + caller, err := identity.NewIAM(fields["Arn"], identity.SourceVerifiedIAMProof, identity.WithIAMAccountID(fields["Account"]), identity.WithIAMPrincipalID(fields["UserId"])) if err != nil { return identity.Caller{}, ErrUnavailable } diff --git a/iamproof/xml.go b/iamproof/xml.go index ee0b6d0..bd556b7 100644 --- a/iamproof/xml.go +++ b/iamproof/xml.go @@ -9,9 +9,9 @@ import ( const stsNamespace = "https://sts.amazonaws.com/doc/2011-06-15/" -// parseResponse consumes one complete, bounded document. Only direct children -// in the expected namespace can supply identity or error fields; duplicate -// results and fields fail instead of taking the last value as xml.Unmarshal does. +// parseResponse consumes one complete, bounded document. +// Only direct children in the expected namespace can supply identity or error fields; +// duplicate results and fields fail instead of taking the last value as xml.Unmarshal does. // Unknown metadata is ignored, but still parsed for well-formedness. func parseResponse(wire []byte, success bool) (map[string]string, error) { root, result := "GetCallerIdentityResponse", "GetCallerIdentityResult" diff --git a/identity/caller.go b/identity/caller.go index 2792263..73d7ee1 100644 --- a/identity/caller.go +++ b/identity/caller.go @@ -12,12 +12,12 @@ const ( KindIAM ) -// Source records the producer's assertion, not cryptographic evidence. Trusted -// application code must authenticate inputs before attributing a source. +// Source records the producer's assertion, not cryptographic evidence. +// Trusted application code must authenticate inputs before attributing a source. type Source uint8 -// Sources distinguish gateway assertions, verified tokens, custom mappings and -// IAM proofs verified online by STS. None is reserved for anonymous callers. +// Sources distinguish gateway assertions, verified tokens, custom mappings and IAM proofs verified online by STS. +// None is reserved for anonymous callers. const ( SourceNone Source = iota SourceGatewayAssertion @@ -27,9 +27,12 @@ const ( ) // Caller is an immutable identity snapshot with exactly one JWT or IAM view. -// Its zero value is anonymous. Constructors validate facts; they neither -// authenticate nor authorize. Copies may be shared concurrently. There is no -// identity equality contract or implicit JSON export. Diagnostics omit facts. +// Its zero value is anonymous. +// Constructors validate facts; +// they neither authenticate nor authorize. +// Copies may be shared concurrently. +// There is no identity equality contract or implicit JSON export. +// Diagnostics omit facts. type Caller struct { source Source jwt *JWT diff --git a/identity/caller_example_test.go b/identity/caller_example_test.go index 70df531..a82dc5c 100644 --- a/identity/caller_example_test.go +++ b/identity/caller_example_test.go @@ -9,11 +9,9 @@ import ( ) func ExampleNewJWT() { - // Synthetic facts for this example. In an application, an authenticating - // producer must establish these facts before constructing the caller. - claims, err := identity.NewClaims(map[string]any{ - "iss": "https://issuer.example", "client_id": "batch-worker", "scope": "orders.read", - }) + // Synthetic facts for this example. + // In an application, an authenticating producer must establish these facts before constructing the caller. + claims, err := identity.NewClaims(map[string]any{"iss": "https://issuer.example", "client_id": "batch-worker", "scope": "orders.read"}) if err != nil { panic(err) } @@ -30,7 +28,8 @@ func ExampleNewJWT() { } func ExampleWithCaller() { - // This validates a synthetic identifier; it does not authenticate it. + // 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) diff --git a/identity/caller_test.go b/identity/caller_test.go index 76d0294..8f36e5d 100644 --- a/identity/caller_test.go +++ b/identity/caller_test.go @@ -50,11 +50,22 @@ func TestIAMCallerForms(t *testing.T) { func TestIAMRejectsInvalidCallers(t *testing.T) { for _, resource := range []string{ - "role/Operator", "user/*", "user/A?ice", "user/", "user//Alice", - "user/team name/Alice", "user/team\x7f/Alice", "user/Alice/", "user/" + strings.Repeat("a", 65), + "role/Operator", + "user/*", + "user/A?ice", + "user/", + "user//Alice", + "user/team name/Alice", + "user/team\x7f/Alice", + "user/Alice/", + "user/" + strings.Repeat("a", 65), "user/" + strings.Repeat("a", 511) + "/Alice", - "assumed-role/Operator/a", "assumed-role/Operator/session/extra", "assumed-role/*/session", - "assumed-role/Operator/" + strings.Repeat("x", 65), "federated-user/x", "federated-user/" + strings.Repeat("x", 33), + "assumed-role/Operator/a", + "assumed-role/Operator/session/extra", + "assumed-role/*/session", + "assumed-role/Operator/" + strings.Repeat("x", 65), + "federated-user/x", + "federated-user/" + strings.Repeat("x", 33), } { for _, service := range []string{"iam", "sts"} { c, err := identity.NewIAM("arn:aws:"+service+"::123456789012:"+resource, identity.SourceGatewayAssertion) @@ -69,8 +80,11 @@ func TestIAMRejectsInvalidCallers(t *testing.T) { } } for _, opts := range [][]identity.IAMOption{ - {nil}, {identity.WithIAMAccountID("000000000000")}, {identity.WithIAMPrincipalID("")}, - {identity.WithIAMPrincipalID("\xff")}, {identity.WithIAMPrincipalID("id"), identity.WithIAMPrincipalID("id")}, + {nil}, + {identity.WithIAMAccountID("000000000000")}, + {identity.WithIAMPrincipalID("")}, + {identity.WithIAMPrincipalID("\xff")}, + {identity.WithIAMPrincipalID("id"), identity.WithIAMPrincipalID("id")}, {identity.WithIAMAccountID("123456789012"), identity.WithIAMAccountID("123456789012")}, } { if _, err := identity.NewIAM("arn:aws:iam::123456789012:root", identity.SourceGatewayAssertion, opts...); !errors.Is(err, identity.ErrInvalidCaller) { @@ -121,12 +135,20 @@ func TestJWTFactsAndOwnership(t *testing.T) { func TestJWTInvalidAndUnavailableFacts(t *testing.T) { for _, input := range []string{ - `{}`, `{"iss":"issuer"}`, `{"iss":1,"sub":"x"}`, `{"iss":"issuer","sub":""}`, - `{"iss":"issuer","sub":"x","client_id":null}`, `{"iss":"issuer","sub":"x","token_use":false}`, - `{"iss":"issuer","sub":"x","aud":[1]}`, `{"iss":"issuer","sub":"x","cognito:groups":"[Ops]"}`, - `{"iss":"issuer","sub":"x","scope":"read write"}`, `{"iss":"issuer","sub":"x","scope":"read\twrite"}`, - `{"iss":"issuer","sub":"x","scope":"read write"}`, `{"iss":"issuer","sub":"x","scope":""}`, - `{"iss":"issuer","sub":"x","scope":"read","scp":["write"]}`, `{"iss":"issuer","sub":"x","scp":"read"}`, + `{}`, + `{"iss":"issuer"}`, + `{"iss":1,"sub":"x"}`, + `{"iss":"issuer","sub":""}`, + `{"iss":"issuer","sub":"x","client_id":null}`, + `{"iss":"issuer","sub":"x","token_use":false}`, + `{"iss":"issuer","sub":"x","aud":[1]}`, + `{"iss":"issuer","sub":"x","cognito:groups":"[Ops]"}`, + `{"iss":"issuer","sub":"x","scope":"read write"}`, + `{"iss":"issuer","sub":"x","scope":"read\twrite"}`, + `{"iss":"issuer","sub":"x","scope":"read write"}`, + `{"iss":"issuer","sub":"x","scope":""}`, + `{"iss":"issuer","sub":"x","scope":"read","scp":["write"]}`, + `{"iss":"issuer","sub":"x","scp":"read"}`, } { c, err := identity.NewJWT(parseClaims(t, input), identity.SourceLocallyVerifiedToken) if !errors.Is(err, identity.ErrInvalidCaller) || c.Kind() != identity.KindAnonymous { @@ -201,7 +223,8 @@ func TestDedicatedScopes(t *testing.T) { t.Errorf("Scopes() = %v, %t, expected availability %t", values, known, scopes != nil) } } - // Root + two scalar values + names/text = 205; array + "read" = 132. + // Root + two scalar values + names/text = 205; + // array + "read" = 132. for _, maximum := range []int{336, 337} { claims, err := identity.NewClaims(map[string]any{"iss": "issuer", "sub": "x"}, identity.WithClaimsBudget(maximum)) if err != nil { diff --git a/identity/claims.go b/identity/claims.go index c69289e..b02ed48 100644 --- a/identity/claims.go +++ b/identity/claims.go @@ -1,6 +1,7 @@ // Package identity provides immutable callers, owned claims and context transport. -// Construction validates representation; producers authenticate, and application -// policy authorizes. The package performs no network or signature verification. +// Construction validates representation; +// producers authenticate, and application policy authorizes. +// The package performs no network or signature verification. package identity import ( @@ -31,12 +32,10 @@ const ( ClaimObject ) -// Representation describes the immediate input representation, not its original -// token encoding or whether an authenticating producer verified it. +// Representation describes the immediate input representation, not its original token encoding or whether an authenticating producer verified it. type Representation uint8 -// Claim representations retain the distinction between JSON, decoded values, -// and gateway text that may have lost its original JSON type. +// Claim representations retain the distinction between JSON, decoded values, and gateway text that may have lost its original JSON type. const ( RepresentationUnknown Representation = iota RepresentationJSON @@ -44,25 +43,28 @@ const ( RepresentationGatewayText ) -// Claims is an immutable object of named claims. Its zero value is empty. +// Claims is an immutable object of named claims. +// Its zero value is empty. // Views may share owned backing storage and may be read concurrently. -// Use Lookup and its checked accessors to inspect values; diagnostic formatting -// does not reveal claim names or contents. There is no implicit JSON export. +// Use Lookup and its checked accessors to inspect values; +// diagnostic formatting does not reveal claim names or contents. +// There is no implicit JSON export. type Claims struct { members map[string]claimNode budget int charge int } -// Claim is an immutable view of a value. The zero value has kind ClaimInvalid. +// Claim is an immutable view of a value. +// The zero value has kind ClaimInvalid. // Its representation describes the available input, not authentication strength. type Claim struct { node claimNode budget int } -// cost includes this node's entire subtree. It permits an object view to retain -// the original budget while accounting only for that object's contents. +// cost includes this node's entire subtree. +// It permits an object view to retain the original budget while accounting only for that object's contents. type claimNode struct { kind ClaimKind representation Representation @@ -73,7 +75,8 @@ type claimNode struct { object map[string]claimNode } -// ClaimsOption configures claims construction. A nil option is invalid. +// ClaimsOption configures claims construction. +// A nil option is invalid. // Scalar settings apply in order, with the last assignment winning. type ClaimsOption func(*claimsConfig) @@ -81,12 +84,12 @@ type claimsConfig struct { budget int } -// WithClaimsBudget sets the weighted resource allowance in bytes, defaulting to -// 256 KiB. Constructors reject a final setting outside 1..6 MiB. +// WithClaimsBudget sets the weighted resource allowance in bytes, defaulting to 256 KiB. +// Constructors reject a final setting outside 1..6 MiB. // Each value costs 64 bytes plus object-name, string and exact-number text bytes. // ParseClaims also limits the original JSON byte length to this maximum. -// The allowance is not an AWS quota or a heap cap. Nesting is limited separately -// to 64 containers, including the root, and cannot be disabled. +// The allowance is not an AWS quota or a heap cap. +// Nesting is limited separately to 64 containers, including the root, and cannot be disabled. func WithClaimsBudget(bytes int) ClaimsOption { return func(c *claimsConfig) { c.budget = bytes } } @@ -132,7 +135,8 @@ func (c Claim) Kind() ClaimKind { return c.node.kind } // Representation returns the immediate input representation. func (c Claim) Representation() Representation { return c.node.representation } -// Text reports whether the claim is a string. It never converts other types. +// Text reports whether the claim is a string. +// It never converts other types. func (c Claim) Text() (string, bool) { if c.node.kind != ClaimString { return "", false @@ -170,9 +174,10 @@ func (c Claim) Object() (Claims, bool) { } // NumberText reports whether an exact numeric representation is available. -// JSON and json.Number preserve their supplied literal; native integers use -// decimal text. Supplied float32/float64 and all strings return false, even for -// integral values. Exactness describes the supplied value, not lost upstream data. +// JSON and json.Number preserve their supplied literal; +// native integers use decimal text. +// Supplied float32/float64 and all strings return false, even for integral values. +// Exactness describes the supplied value, not lost upstream data. func (c Claim) NumberText() (string, bool) { if c.node.kind != ClaimNumber || c.node.text == "" { return "", false @@ -181,8 +186,9 @@ func (c Claim) NumberText() (string, bool) { } // Float64 explicitly converts a number to its nearest float64 representation. -// It reports false for nonnumbers or a conversion error; ordinary rounding is -// permitted. For supplied floating-point values it returns their stored value. +// It reports false for nonnumbers or a conversion error; +// ordinary rounding is permitted. +// For supplied floating-point values it returns their stored value. // Use NumberText and an exact parser when rounding is unacceptable. func (c Claim) Float64() (float64, bool) { if c.node.kind != ClaimNumber { diff --git a/identity/claims_bench_test.go b/identity/claims_bench_test.go index c805c6d..ce95ef3 100644 --- a/identity/claims_bench_test.go +++ b/identity/claims_bench_test.go @@ -16,10 +16,15 @@ func claimsFixture(name string) (map[string]any, int) { switch name { case "ordinary": return map[string]any{ - "iss": "https://issuer.example", "sub": "subject", "client_id": "client", - "scope": "orders.read orders.write", "token_use": "access", - "aud": []any{"orders", "inventory"}, "cognito:groups": []any{"staff", "billing"}, - "exp": float64(1900000000), "tenant": map[string]any{"id": "tenant", "active": true}, + "iss": "https://issuer.example", + "sub": "subject", + "client_id": "client", + "scope": "orders.read orders.write", + "token_use": "access", + "aud": []any{"orders", "inventory"}, + "cognito:groups": []any{"staff", "billing"}, + "exp": float64(1900000000), + "tenant": map[string]any{"id": "tenant", "active": true}, }, budget case "distinct_names", "distinct_names_6m": if name == "distinct_names_6m" { diff --git a/identity/claims_json.go b/identity/claims_json.go index b9f3b44..2d2f7b7 100644 --- a/identity/claims_json.go +++ b/identity/claims_json.go @@ -8,10 +8,11 @@ import ( ) // ParseClaims validates exactly one JSON object and owns its semantic contents. -// It preserves numeric token text, but not whitespace, member order or string -// escape spelling. It rejects duplicate names, invalid UTF-8 and trailing values. +// It preserves numeric token text, but not whitespace, member order or string escape spelling. +// It rejects duplicate names, invalid UTF-8 and trailing values. // Input bytes must not change during construction and are not retained afterward. -// Invalid input matches ErrInvalidClaims; byte exhaustion is a ClaimsLimitError. +// Invalid input matches ErrInvalidClaims; +// byte exhaustion is a ClaimsLimitError. // The representation is received JSON, not proof of original JWT fidelity. func ParseClaims(data jsontext.Value, opts ...ClaimsOption) (Claims, error) { maximum, err := claimsBudget(opts) @@ -32,8 +33,8 @@ func ParseClaims(data jsontext.Value, opts ...ClaimsOption) (Claims, error) { if _, err := d.ReadToken(); err != io.EOF { return Claims{}, ErrInvalidClaims } - // Validation and accounting precede owned-tree allocation. The decoder may - // allocate its own bounded wire buffer and duplicate-name tracking on pass one. + // Validation and accounting precede owned-tree allocation. + // The decoder may allocate its own bounded wire buffer and duplicate-name tracking on pass one. d.Reset(bytes.NewReader(data)) budget.remaining = maximum n, err := readJSONClaim(d, &budget, 0, true) diff --git a/identity/claims_test.go b/identity/claims_test.go index dcd43d7..92e18af 100644 --- a/identity/claims_test.go +++ b/identity/claims_test.go @@ -35,8 +35,13 @@ func lookup(t *testing.T, c identity.Claims, name string) identity.Claim { func TestClaimsPresenceAndReads(t *testing.T) { c := parseClaims(t, `{"null":null,"false":false,"true":true,"text":"","number":1,"array":[],"object":{}}`) for name, kind := range map[string]identity.ClaimKind{ - "null": identity.ClaimNull, "false": identity.ClaimBoolean, "true": identity.ClaimBoolean, - "text": identity.ClaimString, "number": identity.ClaimNumber, "array": identity.ClaimArray, "object": identity.ClaimObject, + "null": identity.ClaimNull, + "false": identity.ClaimBoolean, + "true": identity.ClaimBoolean, + "text": identity.ClaimString, + "number": identity.ClaimNumber, + "array": identity.ClaimArray, + "object": identity.ClaimObject, } { v := lookup(t, c, name) if v.Kind() != kind || v.Representation() != identity.RepresentationJSON { @@ -201,15 +206,21 @@ func TestClaimsOwnership(t *testing.T) { func TestNilContainerKinds(t *testing.T) { c, err := identity.NewClaims(map[string]any{ - "map": map[string]any(nil), "strings_map": map[string]string(nil), - "array": []any(nil), "strings": []string(nil), "null": nil, + "map": map[string]any(nil), + "strings_map": map[string]string(nil), + "array": []any(nil), + "strings": []string(nil), + "null": nil, }) if err != nil { t.Fatal(err) } for name, want := range map[string]identity.ClaimKind{ - "map": identity.ClaimObject, "strings_map": identity.ClaimObject, - "array": identity.ClaimArray, "strings": identity.ClaimArray, "null": identity.ClaimNull, + "map": identity.ClaimObject, + "strings_map": identity.ClaimObject, + "array": identity.ClaimArray, + "strings": identity.ClaimArray, + "null": identity.ClaimNull, } { if got := lookup(t, c, name).Kind(); got != want { t.Errorf("Kind(%s) = %v, want %v", name, got, want) @@ -228,14 +239,25 @@ func TestInvalidTypedClaims(t *testing.T) { type customString string type customMap map[string]any for name, input := range map[string]any{ - "nan": math.NaN(), "infinity": math.Inf(1), "float32_infinity": float32(math.Inf(-1)), - "custom": customString("text"), "custom_map": customMap{}, "pointer": new(1), - "uintptr": uintptr(1), "bytes": []byte("text"), "struct": hostileClaim{}, - "invalid_utf8": "\xff", "invalid_key": map[string]any{"\xff": nil}, - "invalid_number": json.Number("secret"), "spaced_number": json.Number(" 1"), - "trailing_space": json.Number("1 "), "leading_zero": json.Number("01"), - "multiple_numbers": json.Number("1 2"), "null_number": json.Number("null"), - "empty_number": json.Number(""), "hex_number": json.Number("0x1p2"), + "nan": math.NaN(), + "infinity": math.Inf(1), + "float32_infinity": float32(math.Inf(-1)), + "custom": customString("text"), + "custom_map": customMap{}, + "pointer": new(1), + "uintptr": uintptr(1), + "bytes": []byte("text"), + "struct": hostileClaim{}, + "invalid_utf8": "\xff", + "invalid_key": map[string]any{"\xff": nil}, + "invalid_number": json.Number("secret"), + "spaced_number": json.Number(" 1"), + "trailing_space": json.Number("1 "), + "leading_zero": json.Number("01"), + "multiple_numbers": json.Number("1 2"), + "null_number": json.Number("null"), + "empty_number": json.Number(""), + "hex_number": json.Number("0x1p2"), } { t.Run(name, func(t *testing.T) { c, err := identity.NewClaims(map[string]any{"secret": input}) @@ -253,9 +275,19 @@ func TestInvalidTypedClaims(t *testing.T) { func TestInvalidJSONClaims(t *testing.T) { for _, input := range []string{ - ``, `null`, `[]`, `42`, `{"secret":`, `{"secret":1,"secret":2}`, - `{"x":1,"\u0078":2}`, `{} {}`, `{} secret`, "{\"secret\":\"\xff\"}", - `{"x":[1,]}`, `{"x":{"a":1,"a":2}}`, `{"x":"\ud800"}`, + ``, + `null`, + `[]`, + `42`, + `{"secret":`, + `{"secret":1,"secret":2}`, + `{"x":1,"\u0078":2}`, + `{} {}`, + `{} secret`, + "{\"secret\":\"\xff\"}", + `{"x":[1,]}`, + `{"x":{"a":1,"a":2}}`, + `{"x":"\ud800"}`, } { c, err := identity.ParseClaims(jsontext.Value(input)) if !errors.Is(err, identity.ErrInvalidClaims) || c.Len() != 0 { @@ -312,9 +344,7 @@ func TestClaimsBudgets(t *testing.T) { if !errors.Is(err, identity.ErrClaimsLimit) { t.Errorf("wire length greater than 64 accepted: %v", err) } - for _, opts := range [][]identity.ClaimsOption{ - {nil}, {identity.WithClaimsBudget(0)}, {identity.WithClaimsBudget(-1)}, {identity.WithClaimsBudget(6*1024*1024 + 1)}, - } { + for _, opts := range [][]identity.ClaimsOption{{nil}, {identity.WithClaimsBudget(0)}, {identity.WithClaimsBudget(-1)}, {identity.WithClaimsBudget(6*1024*1024 + 1)}} { if _, err := identity.NewClaims(nil, opts...); err == nil { t.Error("invalid constructor configuration accepted") } diff --git a/identity/claims_typed.go b/identity/claims_typed.go index b3df6f9..559a088 100644 --- a/identity/claims_typed.go +++ b/identity/claims_typed.go @@ -11,19 +11,22 @@ import ( ) // NewClaims validates and owns already decoded values without a JSON round trip. -// Supported recursive values are nil, bool, string, built-in integers except -// uintptr, finite float32/float64, json.Number, map[string]any, map[string]string, -// []any and []string. Other defined types, pointers, structs and byte slices are -// rejected without calling user methods. A nil map is an empty object; a typed -// nil nested map/slice retains its object/array kind. A nil interface is null. -// Inputs must not change during construction; afterward they may be mutated. -// Invalid input matches ErrInvalidClaims; byte exhaustion is a ClaimsLimitError. +// Supported recursive values are nil, bool, string, built-in integers except uintptr, finite float32/float64, json.Number, map[string]any, map[string]string, []any and []string. +// Other defined types, pointers, structs and byte slices are rejected without calling user methods. +// A nil map is an empty object; +// a typed nil nested map/slice retains its object/array kind. +// A nil interface is null. +// Inputs must not change during construction; +// afterward they may be mutated. +// Invalid input matches ErrInvalidClaims; +// byte exhaustion is a ClaimsLimitError. func NewClaims(values map[string]any, opts ...ClaimsOption) (Claims, error) { return newTypedClaims(values, RepresentationDecoded, opts) } // NewTextClaims owns gateway text without parsing embedded JSON or numbers. -// All values retain RepresentationGatewayText. Nil means an empty object. +// All values retain RepresentationGatewayText. +// Nil means an empty object. // Ownership, options and errors follow NewClaims. func NewTextClaims(values map[string]string, opts ...ClaimsOption) (Claims, error) { return newTypedClaims(values, RepresentationGatewayText, opts) @@ -46,8 +49,9 @@ func newTypedClaims(values any, representation Representation, opts []ClaimsOpti return Claims{members: n.object, budget: maximum, charge: int(n.cost)}, nil } -// readTypedClaim preflights without copying when build is false. Container depth -// bounds cycles; each occurrence of a shared subtree consumes its own allowance. +// readTypedClaim preflights without copying when build is false. +// Container depth bounds cycles; +// each occurrence of a shared subtree consumes its own allowance. func readTypedClaim(v any, representation Representation, budget *claimBudget, depth int, build bool) (claimNode, error) { start := budget.remaining if err := budget.take(claimCharge); err != nil { diff --git a/identity/context.go b/identity/context.go index 9205f45..8bf9597 100644 --- a/identity/context.go +++ b/identity/context.go @@ -11,10 +11,11 @@ func FromContext(ctx context.Context) Caller { return caller } -// WithCaller installs caller in a derived context. 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. +// WithCaller installs caller in a derived context. +// 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) { if ctx == nil { return nil, ErrInvalidCaller diff --git a/identity/errors.go b/identity/errors.go index 24eaa29..0635cd7 100644 --- a/identity/errors.go +++ b/identity/errors.go @@ -7,15 +7,14 @@ var ( ErrInvalidCaller = errors.New("identity: invalid caller") // ErrConflict indicates an attempt to replace an established caller. ErrConflict = errors.New("identity: caller conflict") - // ErrInvalidClaims indicates malformed JSON, unsupported typed values, - // invalid UTF-8, nonfinite numbers, or excessive container nesting. + // ErrInvalidClaims indicates malformed JSON, unsupported typed values, invalid UTF-8, nonfinite numbers, or excessive container nesting. ErrInvalidClaims = errors.New("identity: invalid claims") // ErrClaimsLimit indicates exhaustion of the wire or weighted byte allowance. ErrClaimsLimit = errors.New("identity: claims budget exceeded") ) -// ClaimsLimitError describes a claims byte allowance failure. Its diagnostics -// contain no claim names, values, or underlying parser errors. +// ClaimsLimitError describes a claims byte allowance failure. +// Its diagnostics contain no claim names, values, or underlying parser errors. // The zero value has an unspecified maximum of zero. type ClaimsLimitError struct { maximum int64 diff --git a/identity/iam.go b/identity/iam.go index 34acdc3..829a265 100644 --- a/identity/iam.go +++ b/identity/iam.go @@ -5,8 +5,7 @@ import ( "unicode/utf8" ) -// IAMPrincipalType describes the supported request-caller form, not an IAM -// resource-policy Principal expression. +// IAMPrincipalType describes the supported request-caller form, not an IAM resource-policy Principal expression. type IAMPrincipalType uint8 // Supported IAM principal forms preserve session identities. @@ -29,7 +28,8 @@ type IAM struct { principalID string } -// IAMOption supplies an additional caller fact. Nil and duplicate options fail. +// IAMOption supplies an additional caller fact. +// Nil and duplicate options fail. type IAMOption func(*iamConfig) error type iamConfig struct { @@ -39,8 +39,8 @@ type iamConfig struct { principalIDSet bool } -// WithIAMAccountID cross-checks an independently supplied caller account against -// the ARN. Do not pass API Gateway's API-owner requestContext.accountId here. +// WithIAMAccountID cross-checks an independently supplied caller account against the ARN. +// Do not pass API Gateway's API-owner requestContext.accountId here. func WithIAMAccountID(account string) IAMOption { return func(c *iamConfig) error { if c.accountSet || !validAccount(account) { @@ -63,10 +63,10 @@ func WithIAMPrincipalID(id string) IAMOption { } } -// NewIAM validates and owns an exact root, user, assumed-role session or -// federated-user ARN. Bare role ARNs and policy patterns are not caller forms. -// Literal punctuation in a valid IAM user path is preserved without wildcard -// interpretation. Source must be gateway, custom assertion or verified IAM proof. +// NewIAM validates and owns an exact root, user, assumed-role session or federated-user ARN. +// Bare role ARNs and policy patterns are not caller forms. +// Literal punctuation in a valid IAM user path is preserved without wildcard interpretation. +// Source must be gateway, custom assertion or verified IAM proof. // Errors match ErrInvalidCaller and contain no supplied identifiers. func NewIAM(principalARN string, source Source, opts ...IAMOption) (Caller, error) { if source != SourceGatewayAssertion && source != SourceCustomAssertion && source != SourceVerifiedIAMProof { @@ -118,9 +118,12 @@ func NewIAM(principalARN string, source Source, opts ...IAMOption) (Caller, erro return Caller{}, ErrInvalidCaller } return Caller{source: source, iam: &IAM{ - arn: strings.Clone(principalARN), partition: strings.Clone(parts[1]), - account: strings.Clone(parts[4]), service: strings.Clone(parts[2]), - principalType: form, principalID: strings.Clone(cfg.principalID), + arn: strings.Clone(principalARN), + partition: strings.Clone(parts[1]), + account: strings.Clone(parts[4]), + service: strings.Clone(parts[2]), + principalType: form, + principalID: strings.Clone(cfg.principalID), }}, nil } @@ -172,8 +175,8 @@ func validUserResource(resource string) bool { if last == -1 { return true // The default IAM path is "/". } - // The ARN's user/ separator contributes the path's leading slash. Follow - // CreateUser's formal path regex (U+0021..U+007E), not its prose DEL endpoint. + // The ARN's user/ separator contributes the path's leading slash. + // Follow CreateUser's formal path regex (U+0021..U+007E), not its prose DEL endpoint. path := resource[:last+1] if len(path)+1 > 512 || len(path) < 2 { return false diff --git a/identity/jwt.go b/identity/jwt.go index 386d008..c3f05e6 100644 --- a/identity/jwt.go +++ b/identity/jwt.go @@ -25,7 +25,8 @@ type stringSet struct { known bool } -// JWTOption supplies a separate assertion. Nil or duplicate options are invalid. +// JWTOption supplies a separate assertion. +// Nil or duplicate options are invalid. type JWTOption func(*jwtConfig) error type jwtConfig struct { @@ -33,11 +34,13 @@ type jwtConfig struct { scopesSet bool } -// WithGatewayScopes supplies API Gateway's dedicated scopes collection. It is -// valid only with SourceGatewayAssertion. Nil means unavailable; a nonnil empty -// slice means known empty. Interpretable claim scopes must agree as exact sets. -// The supplied collection consumes the claims' remaining weighted budget before -// copying, even when its values duplicate a claim. Do not mutate it during NewJWT. +// WithGatewayScopes supplies API Gateway's dedicated scopes collection. +// It is valid only with SourceGatewayAssertion. +// Nil means unavailable; +// a nonnil empty slice means known empty. +// Interpretable claim scopes must agree as exact sets. +// The supplied collection consumes the claims' remaining weighted budget before copying, even when its values duplicate a claim. +// Do not mutate it during NewJWT. func WithGatewayScopes(scopes []string) JWTOption { return func(c *jwtConfig) error { if c.scopesSet { @@ -48,10 +51,11 @@ func WithGatewayScopes(scopes []string) JWTOption { } } -// NewJWT validates normalized facts and retains immutable claims. It requires -// a nonempty issuer and a subject or client_id, without conflating those fields. +// NewJWT validates normalized facts and retains immutable claims. +// It requires a nonempty issuer and a subject or client_id, without conflating those fields. // Allowed sources are gateway, locally verified token and custom assertion. -// It does not verify signatures or expiry. Invalid inputs match ErrInvalidCaller; +// It does not verify signatures or expiry. +// Invalid inputs match ErrInvalidCaller; // a dedicated-scope budget failure additionally matches ErrClaimsLimit. func NewJWT(claims Claims, source Source, opts ...JWTOption) (Caller, error) { if source != SourceGatewayAssertion && source != SourceLocallyVerifiedToken && source != SourceCustomAssertion { @@ -105,7 +109,8 @@ func NewJWT(claims Claims, source Source, opts ...JWTOption) (Caller, error) { if !ok || s == "" { return Caller{}, ErrInvalidCaller } - // Validate before allocating; whitespace is specifically ASCII SP. + // Validate before allocating; + // whitespace is specifically ASCII SP. for part := range strings.SplitSeq(s, " ") { if !validScope(part) { return Caller{}, ErrInvalidCaller diff --git a/internal/claimprobe/probe_test.go b/internal/claimprobe/probe_test.go index 00b2bee..8a955b0 100644 --- a/internal/claimprobe/probe_test.go +++ b/internal/claimprobe/probe_test.go @@ -1,6 +1,6 @@ -// Package claimprobe measures a candidate owned claim tree. Nothing in edge -// imports it. This is a storage experiment, not an identity implementation: -// provenance, full input-type support, JWT projection and public APIs are absent. +// Package claimprobe measures a candidate owned claim tree. +// Nothing in edge imports it. +// This is a storage experiment, not an identity implementation: provenance, full input-type support, JWT projection and public APIs are absent. package claimprobe import ( @@ -46,8 +46,9 @@ func charge(remaining *int, n int) bool { return true } -// preflight counts every occurrence of a shared subtree. The depth bound also -// terminates cycles without allocating a visited map. It calls no user methods. +// preflight counts every occurrence of a shared subtree. +// The depth bound also terminates cycles without allocating a visited map. +// It calls no user methods. func preflight(v any, remaining *int, depth int) bool { if !charge(remaining, nodeCharge) { return false @@ -84,8 +85,8 @@ func preflight(v any, remaining *int, depth int) bool { } } -// copyNode is only called after preflight. Inputs must remain unchanged until -// copying completes, as with ordinary Go maps and slices passed to a function. +// copyNode is only called after preflight. +// Inputs must remain unchanged until copying completes, as with ordinary Go maps and slices passed to a function. func copyNode(v any) node { switch v := v.(type) { case nil: @@ -129,10 +130,15 @@ func fixture(name string) (map[string]any, int) { switch name { case "ordinary": return map[string]any{ - "iss": "https://issuer.example", "sub": "subject", "client_id": "client", - "scope": "orders.read orders.write", "token_use": "access", - "aud": []any{"orders", "inventory"}, "cognito:groups": []any{"staff", "billing"}, - "exp": float64(1900000000), "tenant": map[string]any{"id": "tenant", "active": true}, + "iss": "https://issuer.example", + "sub": "subject", + "client_id": "client", + "scope": "orders.read orders.write", + "token_use": "access", + "aud": []any{"orders", "inventory"}, + "cognito:groups": []any{"staff", "billing"}, + "exp": float64(1900000000), + "tenant": map[string]any{"id": "tenant", "active": true}, }, budget case "distinct_names", "distinct_names_1m", "distinct_names_6m": if name == "distinct_names_1m" { @@ -270,8 +276,9 @@ func TestSDKNumberFidelity(t *testing.T) { } } -// scanJSON measures bounded lexical validation separately from the tree. It -// uses jsontext directly; it does not decode through map[string]any and float64. +// scanJSON measures bounded lexical validation separately from the tree. +// It uses jsontext directly; +// it does not decode through map[string]any and float64. func scanJSON(input []byte, budget int) error { if len(input) > budget { return errRejected diff --git a/internal/headerprobe/probe_test.go b/internal/headerprobe/probe_test.go index 1a11559..99e9ff4 100644 --- a/internal/headerprobe/probe_test.go +++ b/internal/headerprobe/probe_test.go @@ -1,6 +1,6 @@ -// Package headerprobe measures a candidate for decision 0010. Nothing in edge -// imports this package. This is an allocation experiment, not the response -// header implementation: filtering, semantic validation and projection are absent. +// Package headerprobe measures a candidate for decision 0010. +// Nothing in edge imports this package. +// This is an allocation experiment, not the response header implementation: filtering, semantic validation and projection are absent. package headerprobe import ( @@ -15,8 +15,8 @@ import ( const budget = 6 * 1024 * 1024 -// preflight uses incremental subtraction so neither long names nor large value -// slices can overflow a summed charge. Empty slices still cost a name plus 32. +// preflight uses incremental subtraction so neither long names nor large value slices can overflow a summed charge. +// Empty slices still cost a name plus 32. func preflight(h http.Header, remaining int) bool { for name, values := range h { for i := range max(1, len(values)) { @@ -116,16 +116,15 @@ func BenchmarkSnapshot(b *testing.B) { } } -// TestSnapshotMemory reports incremental live heap after GC, with the input -// deliberately kept alive in both samples. It excludes the application's input -// allocations and is not a peak-memory/RSS measurement. Repeat in a quiet process. +// TestSnapshotMemory reports incremental live heap after GC, with the input deliberately kept alive in both samples. +// It excludes the application's input allocations and is not a peak-memory/RSS measurement. +// Repeat in a quiet process. func TestSnapshotMemory(t *testing.T) { t.Logf("toolchain=%s target=%s/%s", runtime.Version(), runtime.GOOS, runtime.GOARCH) for _, name := range cases { t.Run(name, func(t *testing.T) { h := fixture(name) - // Two collections discard both active and victim sync.Pool entries - // from previous cases, including the JSON encoder's large buffers. + // Two collections discard both active and victim sync.Pool entries from previous cases, including the JSON encoder's large buffers. runtime.GC() runtime.GC() var before, after runtime.MemStats @@ -137,8 +136,8 @@ func TestSnapshotMemory(t *testing.T) { runtime.KeepAlive(h) runtime.KeepAlive(out) retained := int64(after.HeapAlloc) - int64(before.HeapAlloc) - // Encoding is intentionally outside the snapshot memory sample. This - // is ordinary JSON v2, not the production bounded response encoder. + // Encoding is intentionally outside the snapshot memory sample. + // This is ordinary JSON v2, not the production bounded response encoder. encoded, err := json.Marshal(out) if err != nil { t.Fatal(err) diff --git a/internal/streamprobe/http_test.go b/internal/streamprobe/http_test.go index 8eb833d..dc2e213 100644 --- a/internal/streamprobe/http_test.go +++ b/internal/streamprobe/http_test.go @@ -42,8 +42,8 @@ func TestHTTPStreamingHeaderReference(t *testing.T) { if err != nil { t.Fatal(err) } - // Only header behavior is under test; disable automatic decompression - // for the synthetic encoding fixture. + // Only header behavior is under test; + // disable automatic decompression for the synthetic encoding fixture. r.Header.Set("Accept-Encoding", "identity") response, err := server.Client().Do(r) if err != nil { diff --git a/internal/streamprobe/runtime_test.go b/internal/streamprobe/runtime_test.go index c0ac2f6..2f858ae 100644 --- a/internal/streamprobe/runtime_test.go +++ b/internal/streamprobe/runtime_test.go @@ -31,8 +31,7 @@ import ( const eventJSON = `{"httpMethod":"GET","path":"/","requestContext":{"apiId":"example"},"number":9007199254740993,"duplicate":1,"duplicate":2}` const integrationContentType = "application/vnd.awslambda.http-integration-response" -// SDK reader dispatch must retain the concrete value's ContentType and Close -// methods even though the function's declared result is io.ReadCloser. +// SDK reader dispatch must retain the concrete value's ContentType and Close methods even though the function's declared result is io.ReadCloser. type probeStream struct { body io.Reader url string @@ -114,8 +113,7 @@ func TestRuntimeStreamingProbeProcess(t *testing.T) { return } if mode == "sdk_gateway" { - // Exercise the SDK's documented REST response type directly, without - // edge's adapter or prefix encoder, to isolate Runtime API behavior. + // Exercise the SDK's documented REST response type directly, without edge's adapter or prefix encoder, to isolate Runtime API behavior. lambda.Start(func(ctx context.Context) (*events.APIGatewayProxyStreamingResponse, error) { url := "http://" + os.Getenv("AWS_LAMBDA_RUNTIME_API") body := io.MultiReader(strings.NewReader("first\n"), &gatedTail{ctx: ctx, url: url, tail: bytes.NewReader([]byte("last\n"))}) @@ -208,8 +206,8 @@ func runEdgeStream(t *testing.T, mode string) { _ = s.Close() return nil, errors.New("edge stream lost integration content type") } - // Forward the production reader unchanged; the wrapper only records that - // SDK Close joined edge cleanup before the next invocation is requested. + // Forward the production reader unchanged; + // the wrapper only records that SDK Close joined edge cleanup before the next invocation is requested. return &probeStream{body: s, url: url}, nil } if mode == "edge_typed" { @@ -277,8 +275,7 @@ func TestSDKStreamsThroughRuntimeAPI(t *testing.T) { t.Error(err) } case "/2018-06-01/runtime/invocation/probe/response": - // Require the first application body segment, not just metadata, - // before releasing the producer's second segment. + // Require the first application body segment, not just metadata, before releasing the producer's second segment. reader := bufio.NewReader(r.Body) var initial []byte var err error @@ -331,8 +328,8 @@ func TestSDKStreamsThroughRuntimeAPI(t *testing.T) { } defer func() { cancel() - // The runtime normally loops forever; this test deliberately - // terminates its subprocess after observing the next invocation. + // The runtime normally loops forever; + // this test deliberately terminates its subprocess after observing the next invocation. if err := cmd.Wait(); err != nil && ctx.Err() == nil { t.Errorf("runtime exited: %v; output: %s", err, output.String()) } @@ -377,8 +374,7 @@ func TestSDKStreamsThroughRuntimeAPI(t *testing.T) { t.Fatal("runtime did not close stream before requesting next invocation") } } - // Observe compatibility details without asserting that a future SDK - // must retain today's omissions or connection reuse behavior. + // Observe compatibility details without asserting that a future SDK must retain today's omissions or connection reuse behavior. t.Logf("runtime-request-close=%v response-connection-reused-for-next=%v", got.closeConnection, nextConnection.Load() == got.connection) }) } diff --git a/invocation.go b/invocation.go index b2a47a6..99e15d4 100644 --- a/invocation.go +++ b/invocation.go @@ -9,22 +9,22 @@ import ( "github.com/asteroid-computing/go-lambda-edge/identity" ) -// invocation owns the transport-created request and original body. The context -// is passed separately so every stage uses the same explicit lifetime. +// invocation owns the transport-created request and original body. +// The context is passed separately so every stage uses the same explicit lifetime. type invocation struct { request *http.Request body io.ReadCloser } -// ownRequest must run immediately after successful conversion, before identity -// or application code can fail or replace the request's Body field. +// ownRequest must run immediately after successful conversion, before identity or application code can fail or replace the request's Body field. func (inv *invocation) ownRequest(r *http.Request) { inv.request = r inv.body = r.Body } -// cleanup runs once under the invocation owner's control, after application -// execution. Transport errors remain primary; cleanup details stay private. +// cleanup runs once under the invocation owner's control, after application execution. +// Transport errors remain primary; +// cleanup details stay private. func (inv *invocation) cleanup(err error) error { if inv.body != nil { if closeErr := inv.body.Close(); closeErr != nil { @@ -53,8 +53,9 @@ func invocationContext(parent context.Context) (context.Context, context.CancelF return ctx, cancel, nil } -// withInvocation scopes conversion, identity, serving, and finalization. A panic -// passes through after cleanup. The caller must discard its result on error. +// withInvocation scopes conversion, identity, serving, and finalization. +// A panic passes through after cleanup. +// The caller must discard its result on error. func withInvocation(parent context.Context, run func(context.Context, *invocation) error) (err error) { ctx, cancel, err := invocationContext(parent) if err != nil { @@ -64,8 +65,8 @@ func withInvocation(parent context.Context, run func(context.Context, *invocatio var inv invocation defer func() { err = inv.cleanup(err) - // Normal child cancellation is not a failed invocation. Preserve an - // existing operation/cleanup error when the parent also canceled. + // Normal child cancellation is not a failed invocation. + // Preserve an existing operation/cleanup error when the parent also canceled. if err == nil { err = parent.Err() } diff --git a/request.go b/request.go index 253359e..9400b24 100644 --- a/request.go +++ b/request.go @@ -59,13 +59,12 @@ func requestV2(ctx context.Context, event events.APIGatewayV2HTTPRequest) (*http return nil, invocationError("request", ErrInvalidEvent, "invalid rawPath encoding") } u := &url.URL{Path: path, RawPath: event.RawPath, RawQuery: event.RawQueryString} - // EscapedPath ignores an invalid RawPath hint. Reject rather than silently - // replacing raw delimiters, whitespace, or an invalid encoded spelling. + // EscapedPath ignores an invalid RawPath hint. + // Reject rather than silently replacing raw delimiters, whitespace, or an invalid encoded spelling. if u.EscapedPath() != event.RawPath { return nil, invocationError("request", ErrInvalidEvent, "invalid rawPath syntax") } - // Query decoding remains the handler's job, but a request target cannot - // contain literal ASCII whitespace or control bytes on an HTTP request line. + // Query decoding remains the handler's job, but a request target cannot contain literal ASCII whitespace or control bytes on an HTTP request line. for i := 0; i < len(event.RawQueryString); i++ { if event.RawQueryString[i] <= ' ' || event.RawQueryString[i] == 0x7f { return nil, invocationError("request", ErrInvalidEvent, "invalid raw query syntax") @@ -106,8 +105,8 @@ func requestHeaders(single map[string]string, multi map[string][]string) (http.H header[name] = append(header[name], value) } } - // Keep the original multivalue lists for cross-map deduplication. Repeated - // single values under differently cased keys must not deduplicate each other. + // Keep the original multivalue lists for cross-map deduplication. + // Repeated single values under differently cased keys must not deduplicate each other. multiValues := header.Clone() for _, key := range slices.Sorted(maps.Keys(single)) { if !validToken(key) { diff --git a/response_encoding.go b/response_encoding.go index 21026ac..ae4bd32 100644 --- a/response_encoding.go +++ b/response_encoding.go @@ -10,28 +10,30 @@ import ( "unicode/utf8" ) -// Lambda's documented MB unit is binary. This is the complete buffered payload -// budget, not an allowance for a body plus an uncounted JSON envelope. +// Lambda's documented MB unit is binary. +// This is the complete buffered payload budget, not an allowance for a body plus an uncounted JSON envelope. const maxResponseBytes = 6 * 1024 * 1024 var errResponseTooLarge = errors.New("edge: response exceeds buffered payload limit") -// marshalResponse is only for the raw invocation path. Typed entry points leave -// envelope serialization, and its exact final size, to their caller. +// marshalResponse is only for the raw invocation path. +// Typed entry points leave envelope serialization, and its exact final size, to their caller. func marshalResponse(response any) ([]byte, error) { var out responseBuffer if err := json.MarshalWrite(&out, response); err != nil { if errors.Is(err, errResponseTooLarge) { return nil, limitError("encode", "buffered_envelope", maxResponseBytes, errResponseTooLarge) } - // Codec errors can include application values. Do not expose that chain. + // Codec errors can include application values. + // Do not expose that chain. return nil, invocationError("encode", ErrResponse, "response JSON encoding failed") } return out.data, nil } -// responseBuffer bounds both length and backing capacity. MarshalWrite can still -// allocate temporary codec storage; this is not a total-memory guarantee. +// responseBuffer bounds both length and backing capacity. +// MarshalWrite can still allocate temporary codec storage; +// this is not a total-memory guarantee. type responseBuffer struct { data []byte // A zero limit selects the buffered-response ceiling. @@ -57,8 +59,8 @@ func (b *responseBuffer) Write(p []byte) (int, error) { return len(p), nil } -// encodeResponseBody receives the finalized, canonical response headers. It -// changes only the gateway envelope representation, never the content bytes. +// encodeResponseBody receives the finalized, canonical response headers. +// It changes only the gateway envelope representation, never the content bytes. func encodeResponseBody(body []byte, header http.Header) (string, bool, error) { if len(body) > maxResponseBytes { return "", false, limitError("response", "buffered_body", maxResponseBytes, errResponseTooLarge) diff --git a/response_encoding_test.go b/response_encoding_test.go index 9623a9f..3171590 100644 --- a/response_encoding_test.go +++ b/response_encoding_test.go @@ -125,8 +125,7 @@ func TestMarshalResponseSDKTypes(t *testing.T) { } func TestMarshalResponseEnvelopeLimit(t *testing.T) { - // Measure the actual codec overhead rather than coupling the test to field - // order, optional-field tags, or whitespace choices in the pinned SDK. + // Measure the actual codec overhead rather than coupling the test to field order, optional-field tags, or whitespace choices in the pinned SDK. response := events.APIGatewayV2HTTPResponse{StatusCode: 200} empty, err := marshalResponse(response) if err != nil { diff --git a/response_headers.go b/response_headers.go index 9f6f075..103ffc0 100644 --- a/response_headers.go +++ b/response_headers.go @@ -19,8 +19,9 @@ var ( errResponseHeaderRepeat = invocationError("response", ErrResponse, "repeated response field cannot be represented in payload 2.0") ) -// responseHeaders owns its map and value slices. Strings are immutable and may -// share storage with the input. One writer owns the snapshot until completion. +// responseHeaders owns its map and value slices. +// Strings are immutable and may share storage with the input. +// One writer owns the snapshot until completion. // remaining never refunds bytes trimmed, removed or suppressed at commitment. type responseHeaders struct { fields http.Header @@ -30,8 +31,8 @@ type responseHeaders struct { noContentLength bool } -// canSniffType uses the committed, trimmed fields. A later nonblank encoding -// value still suppresses detection if an earlier repeated value was empty. +// canSniffType uses the committed, trimmed fields. +// A later nonblank encoding value still suppresses detection if an earlier repeated value was empty. func (h *responseHeaders) canSniffType() bool { if _, present := h.fields["Content-Type"]; present || h.noContentType { return false @@ -109,8 +110,7 @@ func snapshotResponseHeaders(fields http.Header, budget int) (*responseHeaders, } h.fields[name] = values } - // Check before Connection removal: a nomination must not hide an attempt - // to declare trailers or upgrade the response protocol. + // Check before Connection removal: a nomination must not hide an attempt to declare trailers or upgrade the response protocol. if len(h.fields["Trailer"]) != 0 { return nil, errResponseTrailers } @@ -154,9 +154,10 @@ func (h *responseHeaders) remove(name string) { } } -// automatic adds a canonical field only when absent and not suppressed. It is -// used for generated Content-Type/Content-Length; removing a field never creates -// new budget. A failed addition leaves both fields and remaining unchanged. +// automatic adds a canonical field only when absent and not suppressed. +// It is used for generated Content-Type/Content-Length; +// removing a field never creates new budget. +// A failed addition leaves both fields and remaining unchanged. func (h *responseHeaders) automatic(name, value string) error { if _, present := h.fields[name]; present { return nil diff --git a/response_headers_test.go b/response_headers_test.go index 35e9aec..daed097 100644 --- a/response_headers_test.go +++ b/response_headers_test.go @@ -21,10 +21,14 @@ func mustSnapshot(t testing.TB, fields http.Header, budget int) *responseHeaders func TestResponseHeaderSnapshotOwnership(t *testing.T) { fields := http.Header{ - "X-Mixed": {" \tfirst\t ", "second"}, "x-mixed": {"third"}, - "X-Empty": {""}, "X-Suppressed": nil, - "Vary": nil, "vary": {"Origin"}, - "X-Unicode": {"café"}, "X-Interior": {"a\t b"}, + "X-Mixed": {" \tfirst\t ", "second"}, + "x-mixed": {"third"}, + "X-Empty": {""}, + "X-Suppressed": nil, + "Vary": nil, + "vary": {"Origin"}, + "X-Unicode": {"café"}, + "X-Interior": {"a\t b"}, } h := mustSnapshot(t, fields, defaultResponseHeaderBudget) fields["X-Mixed"][0] = "changed" @@ -88,14 +92,21 @@ func TestResponseHeaderValidation(t *testing.T) { func TestResponseHeaderConnectionFiltering(t *testing.T) { fields := http.Header{ - "Connection": {" custom , content-type, ,CONTENT-LENGTH, "}, - "connection": {"Other,Connection"}, - "Custom": {"secret"}, "Other": {"hidden"}, - "Content-Type": {"application/json"}, "Content-Length": {"bad", "length"}, - "Proxy-Connection": {"close"}, "Keep-Alive": {"timeout=5"}, - "Proxy-Authenticate": {"Basic"}, "Proxy-Authorization": {"credentials"}, - "Te": {"trailers"}, "Transfer-Encoding": {"chunked"}, - "Trailer": nil, "Upgrade": {}, "Remain": {"visible"}, + "Connection": {" custom , content-type, ,CONTENT-LENGTH, "}, + "connection": {"Other,Connection"}, + "Custom": {"secret"}, + "Other": {"hidden"}, + "Content-Type": {"application/json"}, + "Content-Length": {"bad", "length"}, + "Proxy-Connection": {"close"}, + "Keep-Alive": {"timeout=5"}, + "Proxy-Authenticate": {"Basic"}, + "Proxy-Authorization": {"credentials"}, + "Te": {"trailers"}, + "Transfer-Encoding": {"chunked"}, + "Trailer": nil, + "Upgrade": {}, + "Remain": {"visible"}, } h := mustSnapshot(t, fields, defaultResponseHeaderBudget) if !maps.EqualFunc(h.fields, http.Header{"Remain": {"visible"}}, slices.Equal[[]string]) { @@ -158,8 +169,8 @@ func TestResponseHeaderBudgetAndAutomaticFields(t *testing.T) { } } tooMany := http.Header{"A": make([]string, defaultResponseHeaderBudget/33+1)} - // Charging must not copy rejected input. The caller now separately allocates - // a bounded InvocationError describing the configured limit. + // Charging must not copy rejected input. + // The caller now separately allocates a bounded InvocationError describing the configured limit. if allocations := testing.AllocsPerRun(10, func() { _, _ = chargeResponseHeaders(tooMany, defaultResponseHeaderBudget) }); allocations != 0 { @@ -178,7 +189,8 @@ func TestResponseContentLength(t *testing.T) { want int64 set bool }{ - {values: nil}, {values: []string{}}, + {values: nil}, + {values: []string{}}, {values: []string{"0"}, set: true}, {values: []string{" \t00012 "}, want: 12, set: true}, {values: []string{"9223372036854775807"}, want: 9223372036854775807, set: true}, @@ -199,13 +211,34 @@ func TestResponseHeaderV2Projection(t *testing.T) { t.Errorf("suppressed fields produced V1/V2 output: %v, %v, %v", v2, cookies, err) } }) - // Each audited name has a fixture containing complete values. This tests - // conversion and ordering, not a second implementation of each field grammar. + // Each audited name has a fixture containing complete values. + // This tests conversion and ordering, not a second implementation of each field grammar. for _, name := range []string{ - "Accept-Ranges", "Allow", "Content-Encoding", "Content-Language", "Vary", "WWW-Authenticate", "Cache-Control", - "Access-Control-Allow-Headers", "Access-Control-Allow-Methods", "Access-Control-Expose-Headers", - "Link", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Referrer-Policy", "Server-Timing", "Accept-Patch", "Accept-CH", - "Cache-Status", "Proxy-Status", "Content-Digest", "Repr-Digest", "Want-Content-Digest", "Want-Repr-Digest", "Signature", "Signature-Input", + "Accept-Ranges", + "Allow", + "Content-Encoding", + "Content-Language", + "Vary", + "WWW-Authenticate", + "Cache-Control", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Expose-Headers", + "Link", + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "Referrer-Policy", + "Server-Timing", + "Accept-Patch", + "Accept-CH", + "Cache-Status", + "Proxy-Status", + "Content-Digest", + "Repr-Digest", + "Want-Content-Digest", + "Want-Repr-Digest", + "Signature", + "Signature-Input", } { t.Run(name, func(t *testing.T) { h := mustSnapshot(t, http.Header{name: {"first", "second"}}, defaultResponseHeaderBudget) @@ -230,7 +263,9 @@ func TestResponseHeaderV2Projection(t *testing.T) { "Set-Cookie": {"a=1; Expires=Wed, 21 Oct 2030 07:28:00 GMT", "b=2"}, "WWW-Authenticate": {`Digest realm="a,b", nonce="x"`, `Basic realm="other"`}, "Signature-Input": {`sig1=("@method" "@path");created=1`, `sig2=("content-digest");created=2`}, - "Custom": {"literal, comma"}, "Empty": {""}, "Suppressed": nil, + "Custom": {"literal, comma"}, + "Empty": {""}, + "Suppressed": nil, }, defaultResponseHeaderBudget) fields, cookies, err := h.v2() if err != nil { diff --git a/stream.go b/stream.go index 66687fd..2a50177 100644 --- a/stream.go +++ b/stream.go @@ -14,8 +14,8 @@ var ( errStreamUnpublished = invocationError("stream", ErrStream, "stream producer ended without publishing a response") ) -// streamOutput belongs to the producer. publish accepts an already validated, -// bounded wire prefix and completes the handoff before body writes may block. +// streamOutput belongs to the producer. +// publish accepts an already validated, bounded wire prefix and completes the handoff before body writes may block. // Its caller must supply sanitized errors and publish exactly once. type streamOutput struct { publish func([]byte) error @@ -23,8 +23,9 @@ type streamOutput struct { } // responseStream separates producer lifetime from the entry point's stack. -// mu protects handoff, interruption, and terminal commitment. done publishes the -// final error/panic after cleanup and reporting. readMu only serializes readers; +// mu protects handoff, interruption, and terminal commitment. +// done publishes the final error/panic after cleanup and reporting. +// readMu only serializes readers; // interruption and Close must never acquire it while a Read can be blocked. type responseStream struct { reader *io.PipeReader @@ -48,9 +49,9 @@ type responseStream struct { closeOnce sync.Once } -// startStream performs preparation synchronously, then transfers request cleanup -// to one producer. Until publication, errors return directly and producer panics -// are rethrown here. The caller must consume and Close a successful result. +// startStream performs preparation synchronously, then transfers request cleanup to one producer. +// Until publication, errors return directly and producer panics are rethrown here. +// The caller must consume and Close a successful result. // prepare and produce are private transport callbacks, not application hooks. func startStream(parent context.Context, prepare func(context.Context, *invocation) error, produce func(context.Context, *streamOutput) error, report func(context.Context, error)) (result *responseStream, err error) { ctx, cancel, err := invocationContext(parent) @@ -124,8 +125,8 @@ func startStream(parent context.Context, prepare func(context.Context, *invocati case prefix := <-s.offer: s.mu.Lock() if !s.settled && s.stopErr == nil && ctx.Err() == nil { - // This acknowledgement is the error boundary. The producer cannot - // finish publication or write until the stream has an owner. + // This acknowledgement is the error boundary. + // The producer cannot finish publication or write until the stream has an owner. s.handedOff = true s.source = io.MultiReader(bytes.NewReader(prefix), reader) close(s.accept) @@ -145,9 +146,8 @@ func startStream(parent context.Context, prepare func(context.Context, *invocati return nil, s.err } -// interrupt never joins the producer: it is also called by the cancellation -// watcher that the producer itself must join. Closing the reader unblocks both -// directions of the pipe, including writes when no consumer is reading. +// interrupt never joins the producer: it is also called by the cancellation watcher that the producer itself must join. +// Closing the reader unblocks both directions of the pipe, including writes when no consumer is reading. func (s *responseStream) interrupt(err error) { s.mu.Lock() defer s.mu.Unlock() @@ -165,8 +165,8 @@ func (s *responseStream) interrupt(err error) { func (s *responseStream) finish(ctx context.Context, err error, panicValue any, report func(context.Context, error)) { s.mu.Lock() s.settled = true - // A pipe-close error is only a consequence of interruption. Other producer - // and cleanup faults retain precedence over concurrent cancellation. + // A pipe-close error is only a consequence of interruption. + // Other producer and cleanup faults retain precedence over concurrent cancellation. if err == nil || err == io.ErrClosedPipe { if s.stopErr != nil { err = s.stopErr @@ -221,8 +221,8 @@ func (s *responseStream) Read(p []byte) (int, error) { } n, err := s.source.Read(p) if err != nil { - // Interruption can close the pipe before cleanup finishes. Never expose - // terminal status until producer cleanup and reporting have completed. + // Interruption can close the pipe before cleanup finishes. + // Never expose terminal status until producer cleanup and reporting have completed. <-s.done s.cancel() if s.err != nil { @@ -253,8 +253,8 @@ func (*responseStream) ContentType() string { return "application/vnd.awslambda.http-integration-response" } -// MarshalJSON deliberately declines serialization so the SDK selects its -// io.Reader response path. Edge's framing codec uses JSON v2 separately. +// MarshalJSON deliberately declines serialization so the SDK selects its io.Reader response path. +// Edge's framing codec uses JSON v2 separately. func (*responseStream) MarshalJSON() ([]byte, error) { return nil, invocationError("encode", ErrStream, "streaming response cannot be marshaled as JSON") } diff --git a/stream_encoding.go b/stream_encoding.go index 31cba3e..b761897 100644 --- a/stream_encoding.go +++ b/stream_encoding.go @@ -5,8 +5,8 @@ import ( "errors" ) -// Edge's conservative interpretation includes the entire eight-NUL delimiter -// within AWS's documented first 16 KB. This is not a stream body limit. +// Edge's conservative interpretation includes the entire eight-NUL delimiter within AWS's documented first 16 KB. +// This is not a stream body limit. const maxStreamPrefixBytes = 16000 var errStreamPrefixTooLarge = errors.New("edge: response metadata exceeds streaming prefix limit") @@ -26,7 +26,8 @@ func encodeStreamPrefix(status int, headers *responseHeaders) ([]byte, error) { } return nil, invocationError("encode", ErrResponse, "streaming metadata JSON encoding failed") } - // Allocate exactly the framed length. The final zeroed bytes are the delimiter. + // Allocate exactly the framed length. + // The final zeroed bytes are the delimiter. prefix := make([]byte, len(out.data)+8) copy(prefix, out.data) return prefix, nil diff --git a/stream_encoding_test.go b/stream_encoding_test.go index 8647d51..10967b0 100644 --- a/stream_encoding_test.go +++ b/stream_encoding_test.go @@ -14,8 +14,10 @@ import ( func TestStreamPrefixMetadata(t *testing.T) { h := mustSnapshot(t, http.Header{ - "Custom": {"first", "second"}, "Set-Cookie": {"a=1", "b=2"}, - "Quoted": {"a\t\"b"}, "Suppressed": nil, + "Custom": {"first", "second"}, + "Set-Cookie": {"a=1", "b=2"}, + "Quoted": {"a\t\"b"}, + "Suppressed": nil, }, defaultResponseHeaderBudget) prefix, err := encodeStreamPrefix(201, h) if err != nil { diff --git a/stream_test.go b/stream_test.go index 84efeb3..779811b 100644 --- a/stream_test.go +++ b/stream_test.go @@ -440,8 +440,8 @@ func (r *finalBytesReader) Read(p []byte) (int, error) { func TestStreamDefersErrorAccompanyingBytes(t *testing.T) { for _, terminal := range []error{io.EOF, http.ErrContentLength} { - // Inject a legal Reader result that the current SDK mishandles. This - // protects the runtime-facing Read contract independently of io.Pipe. + // Inject a legal Reader result that the current SDK mishandles. + // This protects the runtime-facing Read contract independently of io.Pipe. done := make(chan struct{}) close(done) s := &responseStream{ diff --git a/streaming_adapter.go b/streaming_adapter.go index f33f48c..c956886 100644 --- a/streaming_adapter.go +++ b/streaming_adapter.go @@ -10,25 +10,28 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// StreamingAdapter serves REST API proxy events through an ordinary HTTP handler -// and returns a metadata-prefixed body stream. Construct it with NewStreaming; -// its zero value is unusable. Configuration is immutable; the application owns -// handler concurrency safety. HTTP APIs and request streaming are unsupported. +// StreamingAdapter serves REST API proxy events through an ordinary HTTP handler and returns a metadata-prefixed body stream. +// Construct it with NewStreaming; +// its zero value is unusable. +// Configuration is immutable; +// the application owns handler concurrency safety. +// HTTP APIs and request streaming are unsupported. // // Deployment requires a REST STREAM integration using InvokeWithResponseStream. -// SDK Runtime API compatibility remains unverified against live AWS; see the -// module's streaming guide for the documented SDK/header/connection qualification. +// SDK Runtime API compatibility remains unverified against live AWS; +// see the module's streaming guide for the documented SDK/header/connection qualification. type StreamingAdapter struct { handler http.Handler config config } -// WithStreamErrorReporter reports a sanitized terminal failure once after -// producer cleanup and before stream completion. It applies only to NewStreaming; -// New rejects it. A final nil callback is invalid. The callback receives the -// invocation context, possibly canceled, and must return promptly. Pre-handoff -// failures are returned directly and not reported. A callback panic is recovered -// and adds a sanitized ErrStream fault without replacing the original failure. +// WithStreamErrorReporter reports a sanitized terminal failure once after producer cleanup and before stream completion. +// It applies only to NewStreaming; +// New rejects it. +// A final nil callback is invalid. +// The callback receives the invocation context, possibly canceled, and must return promptly. +// Pre-handoff failures are returned directly and not reported. +// A callback panic is recovered and adds a sanitized ErrStream fault without replacing the original failure. // It must not read or close the same stream: reporting precedes its completion. func WithStreamErrorReporter(report func(context.Context, error)) Option { return func(c *config) { @@ -38,7 +41,8 @@ func WithStreamErrorReporter(report func(context.Context, error)) Option { // NewStreaming validates handler and options without starting I/O or goroutines. // It shares New's handler, header-budget, identity-budget and gateway defaults. -// Streaming never infers Content-Length; supply it explicitly when needed. +// Streaming never infers Content-Length; +// supply it explicitly when needed. // Authenticate and authorize before writing or flushing any response. func NewStreaming(handler http.Handler, opts ...Option) (*StreamingAdapter, error) { cfg, err := configureAdapter(handler, opts) @@ -51,17 +55,20 @@ func NewStreaming(handler http.Handler, opts ...Option) (*StreamingAdapter, erro return &StreamingAdapter{handler: handler, config: cfg}, nil } -// Handle validates raw REST event JSON with JSON v2 and returns a stream that -// callers must consume and Close. Register lambda.Start(adapter.Handle), not the -// adapter object or the buffered Handler.Invoke boundary. The SDK owns its initial -// jsontext.Value capture; edge owns strict event decoding and prefix encoding. -// Explicit HTTP API version envelopes are rejected. Do not mutate event during -// the call. Request resources remain owned until producer cleanup completes. +// Handle validates raw REST event JSON with JSON v2 and returns a stream that callers must consume and Close. +// Register lambda.Start(adapter.Handle), not the adapter object or the buffered Handler.Invoke boundary. +// The SDK owns its initial jsontext.Value capture; +// edge owns strict event decoding and prefix encoding. +// Explicit HTTP API version envelopes are rejected. +// Do not mutate event during the call. +// Request resources remain owned until producer cleanup completes. // -// Pre-handoff failures return a nil stream. Early producer panics propagate after -// cleanup; late panics become sanitized terminal errors. After handoff, failures -// cannot replace status or bytes. Close cancels and joins the producer; a handler -// ignoring cancellation/write errors can delay Close until invocation timeout. +// Pre-handoff failures return a nil stream. +// Early producer panics propagate after cleanup; +// late panics become sanitized terminal errors. +// After handoff, failures cannot replace status or bytes. +// Close cancels and joins the producer; +// a handler ignoring cancellation/write errors can delay Close until invocation timeout. func (a *StreamingAdapter) Handle(ctx context.Context, event jsontext.Value) (io.ReadCloser, error) { if err := a.valid(); err != nil { return nil, err @@ -78,12 +85,14 @@ func (a *StreamingAdapter) Handle(ctx context.Context, event jsontext.Value) (io }, true) } -// HandleV1 accepts an already typed REST event, avoiding request envelope JSON -// work inside edge. Register lambda.Start(adapter.HandleV1). The caller/upstream -// codec owns wire validation and numeric fidelity. The V1 type also represents -// HTTP API 1.0; it cannot prove the deployment is a REST streaming integration. -// Its stream ownership and error contract match Handle. Inputs must not change -// during the call. Streaming metadata still requires direct JSON v2 encoding. +// HandleV1 accepts an already typed REST event, avoiding request envelope JSON work inside edge. +// Register lambda.Start(adapter.HandleV1). +// The caller/upstream codec owns wire validation and numeric fidelity. +// The V1 type also represents HTTP API 1.0; +// it cannot prove the deployment is a REST streaming integration. +// Its stream ownership and error contract match Handle. +// Inputs must not change during the call. +// Streaming metadata still requires direct JSON v2 encoding. func (a *StreamingAdapter) HandleV1(ctx context.Context, event events.APIGatewayProxyRequest) (io.ReadCloser, error) { if err := a.valid(); err != nil { return nil, err diff --git a/streaming_bench_test.go b/streaming_bench_test.go index c06ef14..6cc594e 100644 --- a/streaming_bench_test.go +++ b/streaming_bench_test.go @@ -22,7 +22,8 @@ func BenchmarkStreamingPublic(b *testing.B) { {name: "5MiB", bytes: 5 * 1024 * 1024}, } { b.Run(size.name, func(b *testing.B) { - // Reuse one bounded application chunk; never allocate a whole body. + // Reuse one bounded application chunk; + // never allocate a whole body. chunk := bytes.Repeat([]byte("x"), min(size.bytes, 4096)) adapter, err := edge.NewStreaming(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") @@ -65,8 +66,7 @@ func BenchmarkStreamingPublic(b *testing.B) { if err != nil { b.Fatal(err) } - // Explicit incremental reads avoid io.ReadAll and io.Copy's - // optional ReaderFrom/WriterTo shortcuts or buffer pooling. + // Explicit incremental reads avoid io.ReadAll and io.Copy's optional ReaderFrom/WriterTo shortcuts or buffer pooling. total := 0 for { n, readErr := stream.Read(buffer) diff --git a/streaming_example_test.go b/streaming_example_test.go index f9c7033..5f06927 100644 --- a/streaming_example_test.go +++ b/streaming_example_test.go @@ -36,8 +36,9 @@ func ExampleStreamingAdapter_Handle() { if err != nil { panic(err) } - // A Lambda main registers lambda.Start(adapter.Handle). This direct call - // demonstrates reader ownership locally; it makes no AWS calls. + // A Lambda main registers lambda.Start(adapter.Handle). + // This direct call demonstrates reader ownership locally; + // it makes no AWS calls. stream, err := adapter.Handle(context.Background(), jsontext.Value(`{"httpMethod":"GET","path":"/","requestContext":{"apiId":"example"}}`)) if err != nil { panic(err) @@ -72,8 +73,9 @@ func ExampleStreamingAdapter_HandleV1() { if err != nil { panic(err) } - // A Lambda main may register lambda.Start(adapter.HandleV1). Input envelope - // decoding then belongs to the SDK; output metadata still uses JSON v2 in edge. + // A Lambda main may register lambda.Start(adapter.HandleV1). + // Input envelope decoding then belongs to the SDK; + // output metadata still uses JSON v2 in edge. stream, err := adapter.HandleV1(context.Background(), exampleRESTEvent()) if err != nil { panic(err) @@ -104,7 +106,8 @@ func ExampleNewStreaming_gzip() { adapter, err := edge.NewStreaming(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Encoding", "gzip") - // The example always emits gzip; a real route negotiates Accept-Encoding. + // The example always emits gzip; + // a real route negotiates Accept-Encoding. compressed := gzip.NewWriter(w) defer compressed.Close() for _, chunk := range []string{"first\n", "last\n"} { @@ -149,8 +152,9 @@ func exampleRESTEvent() events.APIGatewayProxyRequest { return events.APIGatewayProxyRequest{HTTPMethod: "GET", Path: "/", RequestContext: events.APIGatewayProxyRequestContext{APIID: "example"}} } -// This collector is only for the small local examples. Lambda's runtime consumes -// the reader incrementally; do not collect the stream in a production wrapper. +// This collector is only for the small local examples. +// Lambda's runtime consumes the reader incrementally; +// do not collect the stream in a production wrapper. func readExampleStream(stream io.ReadCloser) []byte { defer stream.Close() wire, err := io.ReadAll(stream) diff --git a/streaming_writer.go b/streaming_writer.go index 31f375c..636c884 100644 --- a/streaming_writer.go +++ b/streaming_writer.go @@ -8,8 +8,9 @@ import ( ) // streamingWriter has one handler owner and retains at most 512 body bytes. -// Its context belongs to that invocation, not the reusable adapter. Logical -// HTTP commitment freezes headers; publish transfers ownership to the reader. +// Its context belongs to that invocation, not the reusable adapter. +// Logical HTTP commitment freezes headers; +// publish transfers ownership to the reader. type streamingWriter struct { ctx context.Context out *streamOutput @@ -118,8 +119,8 @@ func (w *streamingWriter) Write(p []byte) (int, error) { return accepted + n, err } -// publish validates all metadata before acknowledging handoff, then drains any -// previously accepted sniff bytes. A pipe error cannot undo accepted bytes. +// publish validates all metadata before acknowledging handoff, then drains any previously accepted sniff bytes. +// A pipe error cannot undo accepted bytes. func (w *streamingWriter) publish() error { if err := w.ready(); err != nil { return err diff --git a/streaming_writer_test.go b/streaming_writer_test.go index 642b7df..47c006f 100644 --- a/streaming_writer_test.go +++ b/streaming_writer_test.go @@ -266,9 +266,7 @@ func (partialStreamWriter) Write(p []byte) (int, error) { func TestStreamingPartialWriteCountsAndSanitization(t *testing.T) { for _, sniff := range []bool{false, true} { - w := newStreamingWriter(t.Context(), "GET", defaultResponseHeaderBudget, &streamOutput{ - publish: func([]byte) error { return nil }, body: partialStreamWriter{}, - }) + w := newStreamingWriter(t.Context(), "GET", defaultResponseHeaderBudget, &streamOutput{publish: func([]byte) error { return nil }, body: partialStreamWriter{}}) if sniff { if n, err := w.Write(bytes.Repeat([]byte("x"), 511)); n != 511 || err != nil { t.Fatalf("buffered Write=%d %v", n, err) diff --git a/testdata/events/README.md b/testdata/events/README.md index 48a4cc4..a36bcf2 100644 --- a/testdata/events/README.md +++ b/testdata/events/README.md @@ -1,14 +1,11 @@ # Decoder fixtures -These are synthetic integration events derived from the documented AWS payload -shapes, with focused edge cases added. They are not captured production requests, -SDK-generated golden files, or copies of Beakley tests. +These are synthetic integration events derived from the documented AWS payload shapes, with focused edge cases added. +They are not captured production requests, SDK-generated golden files, or copies of Beakley tests. -- `rest.json`: REST proxy shape, decoded path containing delimiters, independent - single/multivalue maps, and application-invalid JSON left as an opaque body. +- `rest.json`: REST proxy shape, decoded path containing delimiters, independent single/multivalue maps, and application-invalid JSON left as an opaque body. - `http-v1.json`: HTTP API payload 1.0 and documented null optional fields. -- `http-v2.json`: HTTP API payload 2.0, escaped path, raw repeated query values, - cookies, binary body, native authorizer, and a synthetic future field. +- `http-v2.json`: HTTP API payload 2.0, escaped path, raw repeated query values, cookies, binary body, native authorizer, and a synthetic future field. Sources: