Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4debb41
feat(sdk): add optional RequestRouter capability
arnaugiralt May 18, 2026
00f085e
feat(sdk/compliance): add VerifyRouter helper
arnaugiralt May 18, 2026
123bcea
feat(config): add forward_targets section
arnaugiralt May 18, 2026
36da8d8
feat(proxy): add ForwardProxy for named forward_targets
arnaugiralt May 18, 2026
dbb298c
feat(proxy): build forward proxies at server startup
arnaugiralt May 18, 2026
81ca21e
feat(proxy): detect RequestRouter capability at startup
arnaugiralt May 18, 2026
b3db5a3
feat(proxy): forward to RequestRouter target before credential injection
arnaugiralt May 18, 2026
fac1562
feat(observability): metrics for routing decisions and forward targets
arnaugiralt May 18, 2026
d89a5a3
feat(contrib): match routes against TransactionContext.Data fields
arnaugiralt May 18, 2026
8f85a95
feat(contrib): introduce Action types and HandleForward on Mux
arnaugiralt May 18, 2026
e7c3f9a
feat(contrib): Mux implements sdk.RequestRouter for forward routes
arnaugiralt May 18, 2026
eb9a5a8
feat(contrib): config-driven Mux with mutually exclusive forward/cred…
arnaugiralt May 18, 2026
719645d
feat(proxy): validate forward_target references at startup
arnaugiralt May 18, 2026
85e1598
test(integration): end-to-end forwarding with bearer auth
arnaugiralt May 18, 2026
76eb331
test(integration): migration scenario with mixed forward/credentials …
arnaugiralt May 18, 2026
c2ac949
docs: forward_targets, RequestRouter, and Mux forward routes
arnaugiralt May 18, 2026
98cddc6
test(proxy): thread-safe slog capture helper for race-safe log assert…
arnaugiralt May 18, 2026
d4a6677
fix(proxy): clone DefaultTransport for forward path to match vendor path
arnaugiralt May 18, 2026
2a4fd53
fix(contrib): panic on empty target name in Mux.HandleForward
arnaugiralt May 18, 2026
c1a524d
test(integration): assert Connect-Request-ID propagates to forward ta…
arnaugiralt May 18, 2026
3cc5830
fix(proxy): silence gosec G704 false positive on ForwardProxy.ServeHTTP
arnaugiralt May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chaperone.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ func newProxyServer(plugin sdk.Plugin, rc *runConfig, cfg *config.Config, tracin
IdleTimeout: *cfg.Upstream.Timeouts.Idle,
TracingEnabled: tracingEnabled,
LogTargetAddrMode: cfg.Observability.LogTargetAddr,
ForwardTargets: cfg.ForwardTargets,
})
if err != nil {
return nil, fmt.Errorf("creating proxy server: %w", err)
Expand Down
70 changes: 70 additions & 0 deletions chaperone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"testing"
"time"

"github.com/cloudblue/chaperone/internal/config"
"github.com/cloudblue/chaperone/internal/telemetry"
"github.com/cloudblue/chaperone/sdk"
)
Expand Down Expand Up @@ -590,3 +591,72 @@ func TestRun_ProxyPortInUse_CleansUpAdminServer(t *testing.T) {
adminListener.Close()
}
}

func TestNewProxyServer_PropagatesForwardTargetsFromConfig(t *testing.T) {
// Verify that ForwardTargets from config.Config are properly wired into
// proxy.Config during newProxyServer construction. This test validates that
// YAML forward_targets are propagated to the runtime proxy configuration.
if testing.Short() {
t.Skip("skipping test in short mode")
}

serverAddr := freeAddr(t)
adminAddr := freeAddr(t)
configPath := t.TempDir() + "/config-with-forward.yaml"

// Write config with one forward target
cfgContent := fmt.Sprintf(`server:
addr: "%s"
admin_addr: "%s"
tls:
enabled: false
upstream:
header_prefix: "X-Connect"
allow_list:
"example.com":
- "/api/**"
forward_targets:
my-target:
url: "https://my-target.example/api"
auth:
type: "bearer"
token: "secret-token"
`, serverAddr, adminAddr)

if err := os.WriteFile(configPath, []byte(cfgContent), 0o600); err != nil {
t.Fatalf("failed to write test config: %v", err)
}

// Load config using the same path as Run() would
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}

// Verify the config loaded the forward target
if len(cfg.ForwardTargets) != 1 {
t.Fatalf("expected 1 forward target in loaded config, got %d", len(cfg.ForwardTargets))
}

// Call newProxyServer which should propagate ForwardTargets
runCfg := &runConfig{version: "test"}
proxySrv, err := newProxyServer(nil, runCfg, cfg, false)
if err != nil {
t.Fatalf("newProxyServer failed: %v", err)
}

// Verify the forward target was propagated by checking the server's config
srvCfg := proxySrv.Config()
if len(srvCfg.ForwardTargets) != 1 {
t.Errorf("proxy.Config.ForwardTargets length = %d, want 1", len(srvCfg.ForwardTargets))
}

target, exists := srvCfg.ForwardTargets["my-target"]
if !exists {
t.Fatal("expected forward target 'my-target' in proxy.Config.ForwardTargets")
}

if target.URL != "https://my-target.example/api" {
t.Errorf("forward target URL = %q, want %q", target.URL, "https://my-target.example/api")
}
}
15 changes: 15 additions & 0 deletions docs/guides/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,21 @@ func (p *MyPlugin) GetCredentials(ctx context.Context, tx sdk.TransactionContext
> timeout and is cancelled if the client disconnects. This prevents your
> plugin from leaking goroutines or holding connections to slow backends.

### Forwarding requests (optional)

For some requests, the right answer is not to inject credentials at all but to forward the request as-is to another service — for example, a customer-side router that handles credential injection, response filtering, and policy enforcement on its own. Implement the optional [`sdk.RequestRouter`](../reference/sdk.md#requestrouter-optional) interface on your plugin to opt into this behavior. Returning a non-nil [`*sdk.RouteAction`](../reference/sdk.md#routeaction) with a `ForwardTo` that names a configured [`forward_target`](../reference/configuration.md#forward-targets) tells Chaperone to skip credential injection and `ModifyResponse` for that request; returning `nil` falls through to the normal credential-injection flow.

```go
func (p *MyPlugin) RouteRequest(ctx context.Context, tx sdk.TransactionContext, req *http.Request) (*sdk.RouteAction, error) {
if v, ok, _ := tx.DataString("ResellerId"); ok && strings.HasPrefix(v, "migrated-") {
return &sdk.RouteAction{ForwardTo: "customer-router"}, nil
}
return nil, nil
}
```

Test routers with [`compliance.VerifyRouter`](../reference/sdk.md#verifyrouter). If you use the contrib [`Mux`](../reference/contrib-plugins.md#mux), prefer [`Mux.HandleForward`](../reference/contrib-plugins.md#handleforward) or the [`forward:`](../reference/contrib-plugins.md#muxconfig) field on a `MuxRouteConfig` — the mux implements `RequestRouter` for you.

---

## Reference Plugin Walkthrough
Expand Down
40 changes: 40 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,46 @@ export OTEL_SDK_DISABLED=true
| `OTEL_TRACES_SAMPLER_ARG` | Sampler argument (e.g., ratio) |
| `OTEL_SDK_DISABLED` | Force-disable SDK (`true` always wins) |

### Forward Targets

Named upstreams that Chaperone can forward requests to instead of calling the vendor directly. Targets are referenced by name from a [`sdk.RouteAction`](sdk.md#routeaction) returned by a [`RequestRouter`](sdk.md#requestrouter-optional). The contrib [`Mux`](contrib-plugins.md#handleforward) implements `RequestRouter` and exposes targets through the `forward:` field on route entries.

When a router selects a forward target, the Core sends the request to that target's `url` with the configured authentication and timeout, and skips credential injection and `ModifyResponse`.

```yaml
forward_targets:
customer-router:
url: "https://router.customer.example/v1/intake"
timeout: 15s
auth:
type: "bearer"
token: "${CUSTOMER_ROUTER_TOKEN}"
internal-relay:
url: "https://relay.internal.example/"
timeout: 10s
auth:
type: "none"
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `url` | string | — (required) | Absolute base URL of the forward target. Must be `https://` in production builds; `http://` is permitted only in dev builds. |
| `timeout` | duration | `0` (use upstream defaults) | Per-request timeout when calling the forward target. |
| `auth.type` | string | — (required) | `bearer` or `none`. Unknown values are rejected at startup. |
| `auth.token` | string | — | Bearer token used when `auth.type: bearer`. Required and must be non-empty for bearer auth. Supports `${VAR}` and `$VAR` environment variable interpolation. |

#### Validation rules

Forward targets are validated at startup. The proxy fails fast with a descriptive error when any of these rules is violated:

- `url` must be present, parseable, and have a non-empty scheme and host.
- The scheme must be `https` in production builds. In dev builds, `http` is also accepted.
- `auth.type` must be set; the empty string is rejected.
- `auth.type` must be `bearer` or `none`; any other value is rejected.
- When `auth.type: bearer`, `auth.token` must be non-empty after environment variable interpolation.

Routers that reference a `forward_target` name not defined here are also rejected at startup — see [`MuxConfig`](contrib-plugins.md#muxconfig) for how the contrib mux participates in this check.

## Allow-List Syntax

The allow-list enforces a **default-deny** policy. Only requests matching
Expand Down
149 changes: 146 additions & 3 deletions docs/reference/contrib-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Sub-packages:
[cred]: sdk.md#credential
[cs]: sdk.md#certificatesigner
[rm]: sdk.md#responsemodifier
[rr]: sdk.md#requestrouter-optional
[ra]: sdk.md#routeaction
[ft]: configuration.md#forward-targets

---

Expand All @@ -39,9 +42,9 @@ Sub-packages:
type Mux struct{ /* unexported */ }
```

A request multiplexer that dispatches to the most specific matching [`CredentialProvider`][cp] based on transaction context fields. `Mux` implements [`Plugin`][plugin] and can be passed directly to `chaperone.Run()`.
A request multiplexer that dispatches to the most specific matching [`CredentialProvider`][cp] based on transaction context fields. `Mux` implements [`Plugin`][plugin] and the optional [`RequestRouter`][rr], and can be passed directly to `chaperone.Run()`.

Safe for concurrent use after construction. `Handle` and `Default` are not safe for concurrent calls — register all routes before serving traffic.
Safe for concurrent use after construction. `Handle`, `HandleForward`, and `Default` are not safe for concurrent calls — register all routes before serving traffic.

### `NewMux`

Expand Down Expand Up @@ -73,6 +76,20 @@ func (m *Mux) Handle(route Route, provider sdk.CredentialProvider)

Registers a route that dispatches matching requests to `provider`. Routes are evaluated by [specificity](#specificity) at dispatch time. Registration order breaks ties.

Mutually exclusive with [`HandleForward`](#handleforward) for the same route: every route in the mux dispatches to either a credential provider or a forward target, never both.

### `HandleForward`

```go
func (m *Mux) HandleForward(route Route, target string)
```

Registers a route that, when matched, forwards the request to the named [`forward_target`][ft] via the Core's forwarding path. Credential injection and [`ResponseModifier`][rm] are skipped for forwarded requests.

`target` is the key of an entry in the proxy's `forward_targets` configuration. The Mux treats the name as opaque — cross-validation that every referenced target exists in the configuration happens at startup. Mutually exclusive with [`Handle`](#handle) for the same route.

The Mux implements [`RequestRouter`][rr]: when a forward route matches, `RouteRequest` returns a [`*RouteAction`][ra] with `ForwardTo` set to `target`. When a [`Handle`](#handle) route matches (or nothing matches), `RouteRequest` returns `nil` and dispatch falls through to `GetCredentials`.

### `Default`

```go
Expand Down Expand Up @@ -129,6 +146,105 @@ Delegates to the configured modifier. Returns a nil action and nil error if no m

---

## MuxConfig

YAML-friendly description of a request multiplexer. A `MuxConfig` can be unmarshalled directly from a YAML document — typically as a sub-section of the distributor's own configuration file — and passed to [`LoadMuxFromConfig`](#loadmuxfromconfig) to build a usable [`*Mux`](#mux).

```go
type MuxConfig struct {
Routes []MuxRouteConfig `yaml:"routes"`
Fallback *MuxFallbackConfig `yaml:"fallback,omitempty"`
}

type MuxRouteConfig struct {
Match MatchConfig `yaml:"match"`
Forward string `yaml:"forward,omitempty"`
Credentials *CredentialsConfig `yaml:"credentials,omitempty"`
}

type MatchConfig struct {
VendorID string `yaml:"vendor_id,omitempty"`
MarketplaceID string `yaml:"marketplace_id,omitempty"`
ProductID string `yaml:"product_id,omitempty"`
EnvironmentID string `yaml:"environment_id,omitempty"`
TargetURL string `yaml:"target_url,omitempty"`
Data map[string]string `yaml:"data,omitempty"`
}

type CredentialsConfig struct {
Type string `yaml:"type"`
}

type MuxFallbackConfig struct {
Credentials *CredentialsConfig `yaml:"credentials,omitempty"`
Forward string `yaml:"forward,omitempty"` // rejected; see below
}
```

Each route must set **exactly one** of `forward` or `credentials`:

- `forward` names a [`forward_target`][ft]. The matched request is sent there as-is by the Core, bypassing credential injection and `ModifyResponse`.
- `credentials.type` is a discriminator looked up in the providers map passed to [`LoadMuxFromConfig`](#loadmuxfromconfig). The distributor constructs the providers (OAuth, Microsoft SAM, etc.) and registers them in the map.

`match.data` mirrors the [`Route.Data` matcher](#data-matcher) and follows the same semantics: missing keys, wrong-type values, and empty strings yield non-match.

The optional `fallback` runs when no route matches. Only `fallback.credentials` is supported in v1 — a `fallback.forward` is rejected at load time. A silent fallback-forward would route every unmatched request, including misconfigured or unexpected traffic, to a customer-side service without credential injection; forward routes must be explicit per-match.

### `LoadMuxFromConfig`

```go
func LoadMuxFromConfig(cfg MuxConfig, providers map[string]sdk.CredentialProvider) (*Mux, error)
```

Builds a [`*Mux`](#mux) from a `MuxConfig` and a lookup of pre-built providers. Forward routes are registered via [`HandleForward`](#handleforward); credential routes via [`Handle`](#handle); the fallback (if any) via [`Default`](#default).

Validation rules — the first violation returns an error that names the offending route by index (`routes[2]`) or `fallback`:

- Every route must set exactly one of `forward` or `credentials`.
- A route's `credentials.type` must be non-empty and present in `providers`.
- The fallback, if present, must set `credentials` (not `forward`), and `credentials.type` must be non-empty and present in `providers`.

#### Example

```yaml
mux:
routes:
# Migrated tenants → customer-side handler (no credential injection).
- match:
vendor_id: "microsoft-*"
data:
ResellerId: "migrated-*"
forward: customer-router

# Everyone else on Microsoft → SAM provider.
- match:
vendor_id: "microsoft-*"
credentials:
type: microsoft-sam

# Acme → OAuth client credentials.
- match:
vendor_id: "acme"
credentials:
type: acme-oauth

fallback:
credentials:
type: microsoft-sam
```

```go
providers := map[string]sdk.CredentialProvider{
"microsoft-sam": msSource,
"acme-oauth": acmeOAuth,
}
mux, err := contrib.LoadMuxFromConfig(cfg.Mux, providers)
```

Pair this with the [`forward_targets`][ft] section in the proxy configuration so that every `forward:` name resolves to a real target.

---

## Route

```go
Expand All @@ -138,6 +254,7 @@ type Route struct {
ProductID string
TargetURL string
EnvironmentID string
Data map[string]string
}
```

Expand All @@ -150,6 +267,29 @@ Matching criteria for dispatching requests. Each non-empty field must match the
| `ProductID` | `tx.ProductID` | `"MICROSOFT_*"` |
| `TargetURL` | `tx.TargetURL` (scheme stripped) | `"*.graph.microsoft.com/**"` |
| `EnvironmentID` | `tx.EnvironmentID` | `"prod-*"` |
| `Data` | `tx.Data[key]` per entry | `{"ResellerId": "migrated-*"}` |

#### Data matcher

`Data` is a map of `tx.Data` keys to glob patterns. The route matches only if **every** entry's pattern matches the corresponding `tx.DataString(key)` value. Each entry contributes 1 to [`Specificity()`](#specificity).

Non-match cases (the route is skipped):

- The key is absent from `tx.Data`.
- The value is present but has the wrong type (not a string).
- The value is present but is an empty string.

Invalid or missing data must never silently dispatch to a provider, so these cases all yield a non-match rather than a partial match. The same semantics apply when the matcher is configured via [`MuxConfig`](#muxconfig) (the YAML `match.data` field).

```go
mux.Handle(
contrib.Route{
VendorID: "microsoft-*",
Data: map[string]string{"ResellerId": "migrated-*"},
},
legacyProvider,
)
```

### `Matches`

Expand All @@ -165,14 +305,15 @@ Reports whether every non-empty field in the route matches the corresponding `tx
func (r Route) Specificity() int
```

Returns the number of non-empty fields (0–5). The mux prefers routes with higher specificity when multiple routes match.
Returns the number of non-empty fields, where each `Data` entry counts as one. The mux prefers routes with higher specificity when multiple routes match.

| Route | Specificity |
|-------|-------------|
| `Route{}` | 0 |
| `Route{VendorID: "acme"}` | 1 |
| `Route{MarketplaceID: "MP-*", ProductID: "MICROSOFT_SAAS"}` | 2 |
| `Route{EnvironmentID: "prod", VendorID: "acme", TargetURL: "api.acme.com/**"}` | 3 |
| `Route{VendorID: "acme", Data: map[string]string{"ResellerId": "migrated-*"}}` | 2 |

### Glob patterns

Expand Down Expand Up @@ -860,6 +1001,8 @@ import "github.com/cloudblue/chaperone/plugins/contrib"
| `ErrTokenExpiredOnArrival` | `"token expired on arrival"` | Token `expires_in` is less than or equal to the expiry margin. Token too short-lived to cache. | No |
| `ErrSigningNotConfigured` | `"certificate signing not configured"` | `SignCSR` called on [`AsPlugin`](#asplugin) or [`Mux`](#mux) with no signer configured. | No |
| `ErrTokenEndpointUnavailable` | `"token endpoint unavailable"` | Network error, HTTP 5xx, or HTTP 429 from the token endpoint. | Yes |
| `ErrUnexpectedForwardAction` | `"matched route is a forward action; GetCredentials should not have been called"` | The Core called `GetCredentials` for a route registered via [`HandleForward`](#handleforward). Indicates an integration bug — forwarding should have been handled by `RouteRequest`. | No |
| `ErrNilCredentialProvider` | `"credential action has nil provider"` | A credential route was registered with a nil provider. Programming error caught at dispatch time. | No |

---

Expand Down
Loading