Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@
*.out
coverage.html

# Go workspace file
go.work
go.work.sum

# Dependency directories
vendor/

Expand Down
10 changes: 5 additions & 5 deletions docs/reference/contrib-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,18 @@ Maps a [`TransactionContext`][tx] to a credential key (e.g., a tenant ID, sessio

A successful return must be a non-empty string. Return [`ErrMissingContextData`](#sentinel-errors) if the transaction lacks enough information to resolve a key.

### `ResolveFromContext`
### `ResolveCredentialKey`

```go
func ResolveFromContext(
func ResolveCredentialKey(
ctx context.Context,
tx sdk.TransactionContext,
dataField string,
resolver KeyResolver,
) (string, error)
```

Shared helper that providers call instead of reading `tx.Data` directly. It implements a strict fallback chain:
Shared helper for credential-selection keys (for example, Microsoft `TenantID`). It implements a strict fallback chain:

1. If `tx.Data[dataField]` is present and is a valid non-empty string, return it (explicit connector override).
2. If `tx.Data[dataField]` is present but has the wrong type or is empty, return [`ErrInvalidContextData`](#sentinel-errors). Malformed overrides never fall through to the resolver.
Expand Down Expand Up @@ -622,8 +622,8 @@ Resolves `TenantID` and `Resource` from the transaction context and returns a ca

| Key | Type | Resolution | Description |
|-----|------|------------|-------------|
| `"TenantID"` | `string` | `tx.Data` override → [`KeyResolver`](#keyresolver) → error | Azure AD tenant (e.g., `"contoso.onmicrosoft.com"`). If present in `tx.Data`, that value is used (connector override). If absent, the configured `KeyResolver` is called. If no resolver is configured, returns [`ErrMissingContextData`](#sentinel-errors). Malformed overrides (wrong type, empty) return [`ErrInvalidContextData`](#sentinel-errors) and never fall through to the resolver. The resolved value must match `^[a-zA-Z0-9][a-zA-Z0-9.\-]*$` regardless of source. |
| `"Resource"` | `string` | `tx.Data` only | Target resource (e.g., `"https://graph.microsoft.com"`). Always required in `tx.Data` — no resolver fallback. This is a per-request concern (which API the connector is calling). Returns [`ErrMissingContextData`](#sentinel-errors) if absent, [`ErrInvalidContextData`](#sentinel-errors) if not a string or empty. |
| `"TenantID"` | `string` | `tx.Data` override → [`KeyResolver`](#keyresolver) → error | Azure AD tenant (e.g., `"contoso.onmicrosoft.com"`). Implemented via `ResolveCredentialKey`. If present in `tx.Data`, that value is used (connector override). If absent, the configured `KeyResolver` is called. If no resolver is configured, returns [`ErrMissingContextData`](#sentinel-errors). Malformed overrides (wrong type, empty) return [`ErrInvalidContextData`](#sentinel-errors) and never fall through to the resolver. The resolved value must match `^[a-zA-Z0-9][a-zA-Z0-9.\-]*$` regardless of source. |
| `"Resource"` | `string` | `tx.Data` only | Target resource (e.g., `"https://graph.microsoft.com"`). Implemented via `DataString` plus explicit missing-field handling in the provider. Always required in `tx.Data` — no resolver fallback. This is a per-request concern (which API the connector is calling). Returns [`ErrMissingContextData`](#sentinel-errors) if absent, [`ErrInvalidContextData`](#sentinel-errors) if not a string or empty. |

**Token endpoint URL construction:**

Expand Down
45 changes: 44 additions & 1 deletion docs/reference/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ type TransactionContext struct {
| `SubscriptionID` | `string` | Subscription identifier. |
| `TargetURL` | `string` | Destination URL for this request. Already validated against the allow-list by the Core. |

#### Helper Methods

##### `DataString`

```go
func (tx TransactionContext) DataString(field string) (value string, ok bool, err error)
```

Returns the `tx.Data[field]` string value when present and valid.

**Return values:**
- `value`: the string when present and valid
- `ok`: `true` when the field is present, `false` when absent
- `err`: `ErrInvalidContextData` when present but wrong type or empty

**Behavior:**

1. If `tx.Data[field]` is present and is a valid non-empty string, returns `(value, true, nil)`.
2. If present but has the wrong type or is an empty string, returns `("", true, ErrInvalidContextData)`.
3. If absent, returns `("", false, nil)`.

Use this helper to validate optional/required fields while keeping "missing field" policy at the call site (see [DataString usage example](../tutorials/microsoft-sam-mux.md#handling-context-data)).

#### Header Mapping

These fields are extracted from headers using the configured prefix
Expand Down Expand Up @@ -258,7 +281,27 @@ type ResponseAction struct {

---

## Public API
## Errors

### `ErrInvalidContextData`

```go
var ErrInvalidContextData = errors.New("invalid context data type")
```

Indicates a transaction context field is present but fails validation (wrong type or empty string).
Used by [`DataString`](#datastring) and other context validation functions.

Check with [`errors.Is`](https://pkg.go.dev/errors#Is):

```go
value, ok, err := tx.DataString("TenantID")
if errors.Is(err, sdk.ErrInvalidContextData) {
// Field present but invalid (wrong type or empty)
}
```

---

The Core module (`github.com/cloudblue/chaperone`) provides the entry
points for running the proxy.
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/microsoft-sam-mux.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ The proxy handles the full auth lifecycle:
## Going further

- **Resolve TenantID automatically** — Instead of requiring `TenantID` in every request's context data, use a [`KeyResolver`](../reference/contrib-plugins.md#keyresolver) to map transaction fields (marketplace, vendor) to the correct tenant. The built-in [`StaticMapping`](../reference/contrib-plugins.md#staticmapping) provides a declarative rule table — see the [reference](../reference/contrib-plugins.md#staticmapping) for configuration details.
`TenantID` supports resolver fallback; `Resource` remains a required explicit value in `X-Connect-Context-Data` for each request.

- **Add more tenants** — Run `chaperone-onboard microsoft` for each tenant and place the token file in the `tokens/` directory. One onboarding per tenant (MRRT). The `RefreshTokenSource` manages an LRU pool of per-tenant entries automatically.
- **Multiple app registrations** — If different groups of tenants require separate Azure AD app registrations (e.g., one per region or partner program), create a `RefreshTokenSource` per app and route them through the Mux. Each source gets its own `KeyResolver` for tenant resolution. All sources can share a single `FileStore` because tokens are keyed by tenant, not by app registration. See the [multiple app registrations example](../reference/contrib-plugins.md#multiple-microsoft-app-registrations) in the contrib reference for complete code.
Expand Down
7 changes: 7 additions & 0 deletions go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
go 1.26.1

use (
.
./plugins/contrib
./sdk
)
6 changes: 0 additions & 6 deletions plugins/contrib/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ var ErrNoRouteMatch = errors.New("no route matched")
// the Connect platform sent a request without the expected context headers.
var ErrMissingContextData = errors.New("missing required context data")

// ErrInvalidContextData indicates a required key is present in
// TransactionContext.Data but has the wrong type (e.g., TenantID is a
// number instead of a string). Since Data is map[string]any from JSON
// unmarshaling, type assertions can fail. This is a platform/caller issue.
var ErrInvalidContextData = errors.New("invalid context data type")

// ErrTenantNotFound indicates the requested tenant is not in the static
// config and no resolver callback is set, or the resolver returned not found.
// This is a proxy configuration issue.
Expand Down
10 changes: 7 additions & 3 deletions plugins/contrib/microsoft/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,20 +201,24 @@ func (s *RefreshTokenSource) GetCredentials(
tx sdk.TransactionContext,
_ *http.Request,
) (*sdk.Credential, error) {
tenantID, err := contrib.ResolveFromContext(ctx, tx, "TenantID", s.keyResolver)
tenantID, err := contrib.ResolveCredentialKey(ctx, tx, "TenantID", s.keyResolver)
if err != nil {
return nil, err
}

if !validTenantID.MatchString(tenantID) {
return nil, fmt.Errorf("TenantID contains invalid characters: %w",
contrib.ErrInvalidContextData)
sdk.ErrInvalidContextData)
}

resource, err := contrib.ResolveFromContext(ctx, tx, "Resource", nil)
resource, ok, err := tx.DataString("Resource")
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("Resource not present in transaction context: %w", //nolint:staticcheck // Resource is an SDK context key identifier, so ignore ST1005 (capitalized error string)
contrib.ErrMissingContextData)
}

entry := s.getOrCreate(ctx, tenantID)

Expand Down
18 changes: 9 additions & 9 deletions plugins/contrib/microsoft/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,8 @@ func TestGetCredentials_EmptyTenantID_ReturnsErrInvalidContextData(t *testing.T)
t.Fatal("expected error")
}

if !errors.Is(err, contrib.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
if !errors.Is(err, sdk.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err)
}

if !strings.Contains(err.Error(), "TenantID") {
Expand Down Expand Up @@ -271,8 +271,8 @@ func TestGetCredentials_EmptyResource_ReturnsErrInvalidContextData(t *testing.T)
t.Fatal("expected error")
}

if !errors.Is(err, contrib.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
if !errors.Is(err, sdk.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err)
}

if !strings.Contains(err.Error(), "Resource") {
Expand Down Expand Up @@ -302,8 +302,8 @@ func TestGetCredentials_TenantIDWrongType_ReturnsErrInvalidContextData(t *testin
t.Fatal("expected error")
}

if !errors.Is(err, contrib.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
if !errors.Is(err, sdk.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err)
}

if !strings.Contains(err.Error(), "float64") {
Expand Down Expand Up @@ -349,7 +349,7 @@ func TestGetCredentials_MaliciousTenantID_ReturnsErrInvalidContextData(t *testin
t.Fatalf("expected error for tenantID %q", tt.tenantID)
}

if !errors.Is(err, contrib.ErrInvalidContextData) {
if !errors.Is(err, sdk.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
}
})
Expand Down Expand Up @@ -1359,7 +1359,7 @@ func TestGetCredentials_KeyResolver_MalformedTxDataErrorsEvenWithResolver(t *tes
t.Fatal("expected error")
}

if !errors.Is(err, contrib.ErrInvalidContextData) {
if !errors.Is(err, sdk.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
}
})
Expand Down Expand Up @@ -1442,7 +1442,7 @@ func TestGetCredentials_KeyResolver_ValidTenantIDCheck_RejectsBadResolverValue(t
t.Fatal("expected error for path traversal tenant from resolver")
}

if !errors.Is(err, contrib.ErrInvalidContextData) {
if !errors.Is(err, sdk.ErrInvalidContextData) {
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
}
}
29 changes: 7 additions & 22 deletions plugins/contrib/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,26 @@ type KeyResolver interface {
ResolveKey(ctx context.Context, tx sdk.TransactionContext) (string, error)
}

// ResolveFromContext extracts a key from tx.Data if present, otherwise
// ResolveCredentialKey extracts a key from tx.Data if present, otherwise
// delegates to the resolver. Returns ErrMissingContextData if neither
// source provides a value.
//
// Malformed overrides (present but wrong type or empty string) always
// return ErrInvalidContextData — they never fall through to the resolver.
// This preserves strictness: a connector bug that sends a bad value is
// surfaced immediately, not silently masked by the resolver.
func ResolveFromContext(
func ResolveCredentialKey(
ctx context.Context,
tx sdk.TransactionContext,
dataField string,
resolver KeyResolver,
) (string, error) {
raw, ok := tx.Data[dataField]
key, ok, err := tx.DataString(dataField)
if err != nil {
return "", err
}
if ok {
return extractOverride(raw, dataField)
return key, nil
}

if resolver != nil {
Expand All @@ -57,21 +60,3 @@ func ResolveFromContext(
return "", fmt.Errorf("%s not present in transaction context: %w",
dataField, ErrMissingContextData)
}

// extractOverride validates an explicit override from tx.Data. It returns
// ErrInvalidContextData for wrong type or empty string — these never fall
// through to the resolver.
func extractOverride(raw any, field string) (string, error) {
s, ok := raw.(string)
if !ok {
return "", fmt.Errorf("%s must be a string, got %T: %w",
field, raw, ErrInvalidContextData)
}

if s == "" {
return "", fmt.Errorf("%s is empty in transaction context: %w",
field, ErrInvalidContextData)
}

return s, nil
}
Loading
Loading