Skip to content

feat: per-request forward routing via optional sdk.RequestRouter#104

Open
arnaugiralt wants to merge 21 commits into
masterfrom
feat/request-router-forwarding
Open

feat: per-request forward routing via optional sdk.RequestRouter#104
arnaugiralt wants to merge 21 commits into
masterfrom
feat/request-router-forwarding

Conversation

@arnaugiralt
Copy link
Copy Markdown
Member

@arnaugiralt arnaugiralt commented May 18, 2026

Summary

Adds a new optional SDK capability, sdk.RequestRouter, that lets a plugin make a per-request decision: forward this request to a different upstream, or fall through to credential injection and the vendor call (today's behavior). The contrib Mux implements it. The proxy core gains one branch in handleProxy to honor a forward action.

This unlocks two related use cases with a single mechanism:

  1. Pure forwarding — a customer who wants Chaperone to act as a Connect-shaped mTLS ingress and forward every request into their own HTTP stack (which handles cred injection, response filtering, and the vendor call themselves).
  2. Migration matcher — during a transition from Company A credentials to Company B's system, some resellers are forwarded to Company B while others continue to use Chaperone-injected Company A credentials. Per-request decision, controlled by route matching.

Pure forwarding is the degenerate case: a single wildcard route with a forward action. Existing plugins that don't implement RequestRouter are completely unaffected.

Request flow

flowchart TB
    A[Request arrives over mTLS] --> B[Trace ID + Logging + Metrics]
    B --> C[Panic recovery]
    C --> D[Allow-list validates<br/>X-Connect-Target-URL]
    D --> E[Parse TransactionContext]
    E --> F{Plugin implements<br/>RequestRouter?}
    F -- no --> G[injectCredentials]
    F -- yes --> H[RouteRequest tx req]
    H --> I{action.ForwardTo<br/>set?}
    I -- empty / nil --> G
    I -- non-empty --> J[Look up<br/>forwardProxies name]
    J --> K{Found?}
    K -- no --> L[500 internal<br/>configuration error]
    K -- yes --> M[Strip inbound Authorization<br/>Add bearer token<br/>Preserve X-Connect-*]
    M --> N[Stream to forward target]
    N --> O[Sanitize sensitive<br/>response headers]
    O --> P[Return to Connect]
    G --> Q[Forward to vendor URL<br/>with creds]
    Q --> R[ModifyResponse]
    R --> P
Loading

The router runs before injectCredentials and after the allow-list. Allow-list semantics are unchanged: X-Connect-Target-URL is validated for every request, including forwarded ones, because the customer's system reads that header to know which vendor to call.

Migration matcher in action

sequenceDiagram
    autonumber
    actor Reseller as Reseller A<br/>(legacy)
    actor ResellerM as Reseller B<br/>(migrated)
    participant Connect as Connect Platform
    participant Chap as Chaperone
    participant Mux as contrib.Mux
    participant CompB as Company B System
    participant Vendor as Vendor API

    Reseller->>Connect: Action
    Connect->>Chap: mTLS request<br/>ResellerId=legacy-99
    Chap->>Mux: RouteRequest
    Mux-->>Chap: nil (fall-through)
    Chap->>Chap: GetCredentials → CompanyA token
    Chap->>Vendor: Authorization: Bearer A-token
    Vendor-->>Chap: 200
    Chap-->>Connect: 200

    ResellerM->>Connect: Action
    Connect->>Chap: mTLS request<br/>ResellerId=migrated-001
    Chap->>Mux: RouteRequest
    Mux-->>Chap: RouteAction{ForwardTo: "company-b"}
    Chap->>CompB: Forward with X-Connect-*<br/>Authorization: Bearer cb-token
    CompB->>CompB: Inject CompanyB creds<br/>Apply filters
    CompB->>Vendor: Call vendor
    Vendor-->>CompB: response
    CompB->>CompB: Filter response
    CompB-->>Chap: filtered response
    Chap-->>Connect: filtered response
Loading

Same Chaperone instance, same plugin, two outcomes — selected per request by inspecting tx.Data["ResellerId"].

What's in this PR

SDK additions (sdk/)

classDiagram
    class Plugin {
        <<interface>>
        +CredentialProvider
        +CertificateSigner
        +ResponseModifier
    }
    class RequestRouter {
        <<optional interface>>
        +RouteRequest(ctx, tx, req) RouteAction, error
    }
    class RouteAction {
        +ForwardTo string
    }
    class Mux {
        +Handle(route, provider)
        +HandleForward(route, target)
        +RouteRequest(ctx, tx, req) RouteAction, error
        +GetCredentials(ctx, tx, req) Credential, error
        +ForwardReferences() []string
    }
    Plugin <|.. Mux
    RequestRouter <|.. Mux
    RequestRouter ..> RouteAction
Loading
  • sdk.RequestRouter — optional interface; nil-check at startup.
  • sdk.RouteAction{ForwardTo string} — empty / nil = fall-through.
  • compliance.VerifyRouter — contract test for plugins that implement the interface.

Proxy core (internal/proxy/)

  • New ForwardProxy type — one per configured target, built at startup, reused per request.
  • Strips inbound Authorization, adds bearer if configured, preserves X-Connect-* and Connect-Request-ID.
  • TLS 1.3 minimum, InsecureSkipVerify: false. Transport now clones http.DefaultTransport so HTTP/2, idle-conn pooling, dialer defaults, and stdlib timeouts apply — matching the vendor path.
  • Sensitive response headers stripped before reaching Connect (defense in depth).
  • handleProxy gains one branch: type-assert plugin against RequestRouter, call RouteRequest, dispatch to forward proxy on a non-empty ForwardTo.
  • Unknown forward-target name → 500 + JSON {"error":"internal configuration error"}.
  • Startup cross-validation: every forward reference in the mux must resolve to a defined forward_target; unused targets log a warning.

Configuration (internal/config/)

forward_targets:
  company-b:
    url: "https://company-b.internal/ingress"
    timeout: 30s
    auth:
      type: bearer        # bearer | none
      token: "${COMPANY_B_TOKEN}"   # env-var interpolation
  • Bearer requires a non-empty token (post-interpolation).
  • HTTPS required in production builds; HTTP allowed only with build-dev.
  • Unknown auth types rejected at startup.

Contrib mux (plugins/contrib/)

  • Route.Data — new map[string]string matcher against TransactionContext.Data. Missing keys, wrong-type values, and empty strings all yield non-match. Counts toward route specificity.
  • Sealed Action interfaceCredentialAction (existing flow) and ForwardAction (new). routeEntry.action replaces the old routeEntry.provider.
  • Mux.HandleForward(route, target) — registers a forward route. Panics on empty target name. Mutually exclusive with Handle per route.
  • Mux.RouteRequest — satisfies sdk.RequestRouter. Returns non-nil RouteAction only for ForwardAction matches.
  • Mux.ForwardReferences() — lists every forward-target name referenced by routes, used by the proxy for startup cross-validation.
  • LoadMuxFromConfig — builds a Mux from MuxConfig (YAML-shaped). Validates mutual exclusion of forward: vs credentials: per route; rejects Fallback.Forward (fallback must be a credentials provider — explicit, not silent).
mux:
  routes:
    - match:
        vendor_id: "microsoft-*"
        data:
          ResellerId: "migrated-*"
      forward: company-b
    - match:
        vendor_id: "microsoft-*"
      credentials:
        type: microsoft-sam
  fallback:
    credentials:
      type: oauth2-client-credentials

Observability (internal/telemetry/)

Three new Prometheus metrics:

Metric Labels What it measures
chaperone_route_decisions_total action, target Per-request routing decisions. action ∈ {forward, credentials}. Empty target for credentials path.
chaperone_forward_target_duration_seconds target End-to-end forward latency. Observed for every forwarded request (success and error).
chaperone_forward_target_errors_total target, kind Infra errors only (not 5xx target responses). kind ∈ {connection, timeout, tls, other} via ordered error classification.

Plus a structured log line per request:

INFO request routed trace_id=... vendor_id=... action=forward target=company-b
INFO request routed trace_id=... vendor_id=... action=credentials

Documentation (docs/)

  • docs/reference/sdk.mdRequestRouter, RouteAction, VerifyRouter sections.
  • docs/reference/configuration.mdforward_targets section.
  • docs/reference/contrib-plugins.mdHandleForward, Route.Data, MuxConfig, sentinel errors.
  • docs/guides/plugin-development.md — forwarding-requests subsection.

Test infrastructure

  • New syncBuffer + captureLogs(t) helper in internal/proxy/ to thread-safely capture slog output. Migrated every prior bytes.Buffer-backed log test in the package. Pure test-only change; uncovered by -race once the feature work added concurrent log assertions.

Test plan

  • Unit tests cover the full matrix for router/mux changes (route matching, specificity, data matcher, action variants, RouteRequest, LoadMuxFromConfig, ForwardReferences).
  • End-to-end forward path: bearer auth, header propagation, inbound Authorization stripping, response header sanitization, target unreachable → 502 JSON, status/body propagation, no credential injection on forward path.
  • End-to-end migration scenario: same server handles a migrated reseller (forwarded) and a legacy reseller (credentials injected) and increments metrics correctly.
  • Connect-Request-ID explicitly asserted to propagate to forward targets.
  • make test, make test-race, make lint, make fmt, make vet, make license-check, make build all green.
  • make gosec — passes for everything on this branch.

Backward compatibility

  • Existing plugins that don't implement RequestRouter retain today's behavior with zero changes.
  • Existing configs without forward_targets are valid.
  • Mux.Handle(route, provider) signature unchanged; refactor to action variants is internal.
  • SDK is additive only — version bump to sdk/v1.<next>.0 (additive, source-compatible).

Security notes

  • Bearer tokens loaded from env vars at startup; redacted in every log handler; covered by TestForwardProxy_BearerToken_NotInLogOutput.
  • Inbound Authorization from Connect is stripped before forwarding so we don't leak Connect's auth posture to the customer system.
  • Sensitive response headers (Authorization, Set-Cookie, etc.) are stripped from forwarded responses as defense in depth — even though the customer system is trusted.
  • TLS 1.3 minimum to all forward targets, certificate verification mandatory.
  • Allow-list still validates X-Connect-Target-URL for forwarded requests (the customer's system reads it).

Out of scope / deferred

Item Why
mTLS to forward target Bearer covers the asking customer; mTLS is an additive auth.type: mtls follow-up.
Hot reload of mux routes v1 requires restart to flip migration state. File-watcher fast-follow if asked.
External state registry for migrations Config-only for v1. The state surface is small.
ModifyResponse on forward routes Customer's response is canonical; we don't double-shape it.
Weighted / multi-target forward One target per forward route.
Custom retry policy to forward target httputil.ReverseProxy defaults.

arnaugiralt and others added 21 commits May 18, 2026 15:04
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds a httputil.ReverseProxy per configured forward target with
header rewriting (strip inbound Authorization, inject bearer when
configured), TLS 1.3 minimum, and defense-in-depth sanitization of
sensitive response headers.

Consolidates the sensitive-headers default list into internal/security
so both vendor and forward paths share a single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a forwardProxies registry on the Server, populated from
proxy.Config.ForwardTargets at construction. Wires the YAML-parsed
forward_targets through chaperone.go into the runtime proxy config
so production deployments actually see them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Type-asserts the configured plugin against sdk.RequestRouter in
NewServer and stores the reference on the Server. Per-request cost
is one nil check; plugins that do not implement the interface are
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-request routing branch in handleProxy. When the plugin
implements sdk.RequestRouter and returns a RouteAction with a
ForwardTo target, the matching forward proxy serves the request and
the credential-injection flow is skipped. Unknown target names yield
500 with a generic error.

Extracts parseAndValidateTarget and routeAndMaybeForward helpers to
keep handleProxy under the funlen ceiling. Logs the routing decision
(action=forward|credentials) for both paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three Prometheus metrics:
- chaperone_route_decisions_total{action, target}
- chaperone_forward_target_duration_seconds{target}
- chaperone_forward_target_errors_total{target, kind}

Routing decisions are counted at both forward and credential paths.
Forward target duration is observed for all requests via deferred
observation in ServeHTTP. Errors are classified into timeout, tls,
connection, and other via ordered error inspection; 5xx target
responses are not counted as errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Route.Data: map[string]string for matching against Data entries
in TransactionContext (e.g., ResellerId, TenantId). Each entry must
match via GlobMatch; missing keys, wrong-type values, and empty
strings all yield non-match. Data entries contribute to Specificity
so more-specific routes win.

Updates routesMayOverlap to treat shared Data keys as shared
dimensions (disjoint literals prove non-overlap) and routeString
to render Data entries in deterministic key order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a sealed Action interface with two implementations:
CredentialAction (existing flow) and ForwardAction (new). Refactors
routeEntry to hold an Action; preserves Handle(route, provider) as a
back-compat wrapper and adds HandleForward(route, target).

GetCredentials uses defensive sentinels: ErrUnexpectedForwardAction
if the matched route is a ForwardAction (RouteRequest should have
short-circuited), ErrNilCredentialProvider for guards against nil
providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RouteRequest returns a non-nil RouteAction with ForwardTo only when
the matched route is a ForwardAction. CredentialAction matches and
no-match cases fall through (return nil), preserving the existing
GetCredentials path. Reuses Mux.match for routing logic — DRY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entials

Adds LoadMuxFromConfig that builds a Mux from a MuxConfig and a
provider lookup table. Each route declares exactly one of forward or
credentials; mutual exclusion is enforced at load time with the
offending location named in the error.

Fallback supports credentials only — fallback forwarding is rejected
because a silent fallback-forward would catch every unmatched
request without credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mux exposes ForwardReferences() listing every forward_target name
its routes reference. NewServer type-asserts the plugin against
that optional interface; unknown references fail fast with the
offending name in the error. forward_targets entries that are
never referenced produce a warning log line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drives a real proxy.Server end-to-end via an inline RequestRouter
stub. Verifies header propagation, bearer-token injection, inbound
Authorization stripping, response body/status/headers propagation,
sensitive header sanitization, 502 on unreachable target, and that
GetCredentials is never invoked on the forward path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…routes

Single Chaperone server across three requests with one plugin that
implements both sdk.Plugin and sdk.RequestRouter. Requests with
ResellerId matching migrated-* are forwarded to the Company B fake;
legacy resellers fall through to credential injection against a
vendor fake. Verifies hit counts on both targets, header propagation
versus stripping, and the route_decisions_total metric counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- SDK reference: documents the optional RequestRouter interface,
  RouteAction semantics, and the VerifyRouter compliance helper.
- Configuration reference: documents the forward_targets section
  with auth modes, validation rules, and an example.
- Contrib plugins reference: documents Mux.HandleForward, the
  Route.Data matcher, MuxConfig / LoadMuxFromConfig, and the new
  sentinel errors.
- Plugin development guide: brief subsection on forwarding requests
  with pointers to the SDK and contrib references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions

Tests that swapped slog.Default() with a bytes.Buffer-backed handler
raced under -race when production goroutines logged concurrently
with assertion-time buf.String() reads. Adds a syncBuffer +
captureLogs helper that wraps bytes.Buffer in a sync.Mutex, and
migrates every internal/proxy test that captured logs onto the new
helper. Test-only change; no production code modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bare http.Transport{} previously used for forward targets silently
dropped HTTP/2 (ForceAttemptHTTP2), the dialer defaults, idle-conn
pooling (MaxIdleConns, IdleConnTimeout), TLSHandshakeTimeout, and
ExpectContinueTimeout — all of which the vendor path inherits via
httputil.NewSingleHostReverseProxy's reliance on DefaultTransport.

Clone DefaultTransport and override only ResponseHeaderTimeout and
TLSClientConfig (TLS 1.3 min, verify on). Adds a unit test that asserts
the inherited fields are non-zero and the overrides are in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty target names were previously accepted at registration and only
caught at cross-validation time (or, for programmatic registrations
without cfg.ForwardTargets, fell through to RouteAction{ForwardTo: ""}
which the proxy core treats as fall-through — a silent misroute).

Panic at registration with a clear message; config-driven users still
get the cleaner LoadMuxFromConfig validation error before reaching
HandleForward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rget

The design states that Connect-Request-ID reaches the forward target
for trace continuity. Until now this worked only because no code
path strips it — proof-by-non-action. Add an explicit assertion to
the propagation subtest so a future director refactor cannot silently
break trace continuity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gosec's SSRF taint analysis flags fp.proxy.ServeHTTP(w, r) because
the inbound request r is user-controlled, but the destination URL
is fixed at startup from forward_targets config (set on fp.target
and applied by the director). The inbound request cannot influence
where the proxy dials.

Mirrors the same #nosec G704 annotation used by the vendor reverse
proxy in internal/proxy/server.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@arnaugiralt arnaugiralt requested a review from qarlosh May 18, 2026 20:12
@arnaugiralt arnaugiralt marked this pull request as ready for review May 19, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant