refactor(apigateway): upstream authentication and transport policy move to a shared seam under internal/, so a second HTTP-based connection kind reuses them instead of copying them (#1647) - #1653
Merged
Conversation
…ve to a shared seam under internal/, so a second HTTP-based connection kind reuses them instead of copying them (#1647) What a connection kind does to reach an HTTP upstream now lives in one place. `internal/upstreamauth` holds the `Authenticator` and every auth mode (`none`, `bearer`, `api_key`, `basic`, `oauth`, `mtls`), the operator-owned static headers and the header names a model may not claim, the connect and call timeouts, the response read cap, and the TLS material the handshake presents. It owns the configuration keys those rules belong to and validates them. `internal/cfgmap` holds the typed readers over the `map[string]any` a stored connection arrives as. Nothing an operator or a model sees changes. `pkg/toolkits/apigateway` keeps its exported `Config` field for field, and `apigateway.Authenticator`, `NewAuthenticator`, `ErrNeedsReauth` and the `AuthMode*`, `CredentialPlacement*` and `OAuth2AuthStyle*` constants are aliased back, so the toolkit's API is spelled exactly as before. `Config.upstream()` and `configFromUpstream()` in the new `upstream.go` are the one place the toolkit's config and the seam's are joined; an `internal/` type cannot be embedded in a public struct, so the two are mapped rather than shared. Error text stays the caller's. `upstreamauth.Config.ErrPrefix` names the calling kind in every message the seam produces, and the same field is threaded through `apigwtls.Material`, so a refused connection save still reads `apigateway: credential is required when auth_mode is "bearer"` rather than naming a seam that appears in no configuration file. `ErrNeedsReauth` carries no prefix of its own and is wrapped by `Apply` in the kind's voice, so the rendered message is unchanged and `errors.Is` still matches through the alias. The order an operator sees errors in is preserved by exposing each validator separately (`ValidateAuth`, `ValidateTransport`, `ValidateStaticHeaders`, `ValidateIdentityPassthrough`, `ValidateTLSMaterial`) alongside a composite `Validate`: the toolkit interleaves its own `base_url`, `trust_level` and `max_inline_bytes` checks between them exactly where it did before. `upstreamauth.SetConnOAuthStore` and `SetAuthEvents` replace the three `*oauth2AuthorizationCodeAuth` type assertions the toolkit made over its connections. The memguard result shapes and `internal_transport.go` stay in the toolkit: tool-surface wording and an in-process routing facility, not upstream transport policy. `pkg/toolkits/apigateway` goes from 9,385 to 8,367 hand-written non-test lines in 22 files, against the 9,600-line ceiling `TestPackageSizeBudget` enforces for `pkg/`. The seam is 1,380 lines against the 3,418-line ceiling for `internal/`. Acceptance runs against the api-test fixture on the local stack and reads each credential off the fixture's own echo of the request it received, so what the platform puts on the wire is observed where it lands: no credential under `none`, the configured name and value for an api key in a header and in the query, an Authorization header under `basic`, and under `bearer` the fixture's 401 where the same path with no credential answers 200. It also covers the operator's pinned header, the three headers a model may not set, both wire forms of `body`, and six configuration refusals asserted against the literal text an operator reads. Transcript in `build/1647/acceptance.md`. Closes #1647
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1653 +/- ##
==========================================
+ Coverage 91.59% 91.72% +0.12%
==========================================
Files 777 781 +4
Lines 77877 78003 +126
==========================================
+ Hits 71332 71545 +213
+ Misses 4275 4201 -74
+ Partials 2270 2257 -13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Reaching an HTTP upstream is one job, and until now only one connection kind knew how to do it. The
Authenticatorand its six modes, the operator-owned static headers, the timeouts, the response read cap and the TLS material all lived insidepkg/toolkits/apigateway, private tokind: api.They now live in
internal/upstreamauth, built by any HTTP-based connection kind.internal/cfgmapsits beside it with the typed readers over themap[string]anya stored connection arrives as, so the seam can own its own configuration keys without a sixth copy ofgetString/getDurationappearing in the tree.Nothing an operator or a model sees changes: same configuration keys, same tool schemas, same admin routes, same error text.
The seam
internal/upstreamauthholds:Authenticatorand every mode —none,bearer,api_key(header or query, any header name),basic,oauth(both grants, throughpkg/connoauth),mtls(throughinternal/apigwtls) — with the token-fetch error scrubber that keeps a client secret or a token URL's userinfo out of any message the model sees.static_headers, the header names a model may not claim (Authorization, the one this connection's auth mode owns, and every name the operator pinned), and the hop-by-hop and net/http-managed names an operator may not pin.CheckRedirectthat refuses to follow a 3xx so a redirect cannot carry the connection's credential to a host the operator never configured.auth_mode,credential,api_key_header,api_key_param,api_key_placement,username,password, the OAuth keys,connect_timeout,call_timeout,max_response_bytes,static_headers,mtls_client_cert_pem,mtls_client_key_pem,tls_ca_bundle_pem,identity_passthrough.internal/apigwtlsandinternal/apigwmetricskeep their names and their contents; the TLS package is now reached through the seam rather than from the toolkit.How the toolkit's surface survives the move
pkg/toolkits/apigatewaykeeps its exportedConfigfield for field.Authenticator,NewAuthenticator,ErrNeedsReauthand theAuthMode*,CredentialPlacement*andOAuth2AuthStyle*constants are aliased back, so every name an importer could reach is spelled exactly as before.Config.upstream()andconfigFromUpstream()in the newpkg/toolkits/apigateway/upstream.goare the single place the toolkit's config and the seam's are joined. They are a mapping rather than an embed because aninternal/type in a public struct would be unnameable to a library consumer and would tripTestPublicSurfacePolicy; keeping the two in one file is what makes a field added to either side have exactly one place to be joined up.Three details carry the behavior across intact:
Error text stays the caller's.
upstreamauth.Config.ErrPrefixnames the calling kind in every message the seam produces, and the same field is threaded throughapigwtls.Material. A refused connection save still readsapigateway: credential is required when auth_mode is "bearer"and not the name of a seam that appears in no configuration file. A second kind supplies its own prefix and gets its own voice for free.ErrNeedsReauthkeeps its rendered message and its identity. The sentinel carries no prefix of its own;Applywraps it in the kind's voice, so the text an operator reads is byte for byte what it was anderrors.Isstill matches throughapigateway.ErrNeedsReauth.The order an operator sees errors in is unchanged. The seam exposes each validator separately —
ValidateAuth,ValidateTransport,ValidateStaticHeaders,ValidateIdentityPassthrough,ValidateTLSMaterial— alongside a compositeValidatefor a kind with no keys of its own. The toolkit calls them individually so its ownbase_url,trust_levelandmax_inline_byteschecks stay interleaved exactly where they were.upstreamauth.SetConnOAuthStoreandSetAuthEventstake anAuthenticatorand report whether it took the value, replacing the three*oauth2AuthorizationCodeAuthtype assertions the toolkit made when threading the OAuth token store over its connections.What deliberately did not move
The memguard result shapes (
budgetError.result,bodyTooLargeResult,structuredErrorResult,nonInlineableBodyError) are tool-surface wording, not transport, and stay in the toolkit. So doesinternal_transport.go: the in-process round tripper behindhandler: internalis a routing facility for the platform's own admin API, not a way of reaching an upstream.internal/cfgmapis used by the seam and bypkg/toolkits/apigateway. Thes3,trino,datahubandgatewaytoolkits still carry their own copies of the same readers; consolidating those is a separate change.Size
pkg/toolkits/apigatewayinternal/upstreamauthinternal/cfgmapThe ceilings are the ones
TestPackageSizeBudgetenforces (maxPackageLOC,maxInternalPackageLOC). At 9,385 the toolkit had 215 lines of headroom, so this was a precondition for further work onkind: apigenerally, not only for a second kind.TestPackageImportRatchet's allowlist gains the seam's edges (internal/upstreamauthtointernal/apigwtls,internal/cfgmap,internal/membudget,pkg/authevents,pkg/connoauth) and the toolkit's two new ones;pkg/toolkits/apigateway -> internal/apigwtlsis gone, since the toolkit no longer reaches TLS directly.go list -deps ./internal/upstreamauthnames no toolkit package.Verification
Acceptance through the real surface.
test/acceptance/issue_1647_test.go, 23 subtests, run as a real MCP client against the platform on the local stack (make dev). The upstream is the api-test fixture the deployment runs, not a stand-in: its/v1/echoreturns the method, path, query and headers of the request as it arrived, so each credential is observed where it lands rather than where it is built.noneattaches noAuthorizationand noX-API-Key.api_keyin a custom header carries the configured name and value; in the query, the configured parameter and value, and no header.api_keyin the default header authenticates the caller — the fixture'swhoamireportsapikey/dev-fixture.basicattaches exactly oneAuthorizationheader.bearerattaches the token: the fixture answers 401invalid credentialwhere the same path with no credential answers 200.Authorization, the header its connection's auth mode owns, or a pinned header.apigateway: mtls_client_cert_pem and mtls_client_key_pem are required,apigateway: call_timeout must be positive).Wire forms:
api_invoke_endpoint.bodyis untyped and admits an object and a string of JSON. Both are sent as literaltools/callparams and the upstream receives the same document. Transcript, including theWire forms:line and the binary's build time against the last source edit, is atbuild/1647/acceptance.md.Gates.
make verifygreen. Patch coverage 99.5% (594 of 597 changed executable lines);internal/upstreamauth99.3%,internal/cfgmap100%,internal/apigwtlsup from 87.3% to 93.4%.make lintreports 0 issues.gosecclean on the changed packages. No dead code in the new packages. The API gateway's own suites pass with no assertion changed, with one exception: the test that provedSetConnOAuthStorere-threads an already-materialized authenticator did so by reading a private field that is no longer reachable from the toolkit, and now asserts the same contract by behavior — after the store is threaded, the connection's authenticator reportsErrNeedsReauthrather than "token store not wired". A second test covers the other ordering, a connection registered after the wiring.Documentation.
docs/library/stability.mdanddocs/llms-full.txtname the seam and record that the toolkit's API, configuration keys and error messages are unchanged;CLAUDE.md'sinternal/inventory gains both packages.Closes #1647