Skip to content

Commit 4887d31

Browse files
committed
refactor: remove extractOverride from plugins/contrib resolver, add DataString to SDK
also track go.work for CI cross-module builds
1 parent b427460 commit 4887d31

13 files changed

Lines changed: 217 additions & 102 deletions

File tree

.gitignore

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@
1818
*.out
1919
coverage.html
2020

21-
# Go workspace file
22-
go.work
23-
go.work.sum
24-
2521
# Dependency directories
2622
vendor/
2723

docs/reference/contrib-plugins.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,18 @@ Maps a [`TransactionContext`][tx] to a credential key (e.g., a tenant ID, sessio
205205

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

208-
### `ResolveFromContext`
208+
### `ResolveCredentialKey`
209209

210210
```go
211-
func ResolveFromContext(
211+
func ResolveCredentialKey(
212212
ctx context.Context,
213213
tx sdk.TransactionContext,
214214
dataField string,
215215
resolver KeyResolver,
216216
) (string, error)
217217
```
218218

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

221221
1. If `tx.Data[dataField]` is present and is a valid non-empty string, return it (explicit connector override).
222222
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.
@@ -622,8 +622,8 @@ Resolves `TenantID` and `Resource` from the transaction context and returns a ca
622622

623623
| Key | Type | Resolution | Description |
624624
|-----|------|------------|-------------|
625-
| `"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. |
626-
| `"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. |
625+
| `"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. |
626+
| `"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. |
627627

628628
**Token endpoint URL construction:**
629629

docs/reference/sdk.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,29 @@ type TransactionContext struct {
176176
| `SubscriptionID` | `string` | Subscription identifier. |
177177
| `TargetURL` | `string` | Destination URL for this request. Already validated against the allow-list by the Core. |
178178

179+
#### Helper Methods
180+
181+
##### `DataString`
182+
183+
```go
184+
func (tx TransactionContext) DataString(field string) (value string, ok bool, err error)
185+
```
186+
187+
Returns the `tx.Data[field]` string value when present and valid.
188+
189+
**Return values:**
190+
- `value`: the string when present and valid
191+
- `ok`: `true` when the field is present, `false` when absent
192+
- `err`: `ErrInvalidContextData` when present but wrong type or empty
193+
194+
**Behavior:**
195+
196+
1. If `tx.Data[field]` is present and is a valid non-empty string, returns `(value, true, nil)`.
197+
2. If present but has the wrong type or is an empty string, returns `("", true, ErrInvalidContextData)`.
198+
3. If absent, returns `("", false, nil)`.
199+
200+
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)).
201+
179202
#### Header Mapping
180203

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

259282
---
260283

261-
## Public API
284+
## Errors
285+
286+
### `ErrInvalidContextData`
287+
288+
```go
289+
var ErrInvalidContextData = errors.New("invalid context data type")
290+
```
291+
292+
Indicates a transaction context field is present but fails validation (wrong type or empty string).
293+
Used by [`DataString`](#datastring) and other context validation functions.
294+
295+
Check with [`errors.Is`](https://pkg.go.dev/errors#Is):
296+
297+
```go
298+
value, ok, err := tx.DataString("TenantID")
299+
if errors.Is(err, sdk.ErrInvalidContextData) {
300+
// Field present but invalid (wrong type or empty)
301+
}
302+
```
303+
304+
---
262305

263306
The Core module (`github.com/cloudblue/chaperone`) provides the entry
264307
points for running the proxy.

docs/tutorials/microsoft-sam-mux.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ The proxy handles the full auth lifecycle:
211211
## Going further
212212

213213
- **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.
214+
`TenantID` supports resolver fallback; `Resource` remains a required explicit value in `X-Connect-Context-Data` for each request.
214215

215216
- **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.
216217
- **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.

go.work

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
go 1.26.1
2+
3+
use (
4+
.
5+
./plugins/contrib
6+
./sdk
7+
)

plugins/contrib/errors.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,6 @@ var ErrNoRouteMatch = errors.New("no route matched")
2323
// the Connect platform sent a request without the expected context headers.
2424
var ErrMissingContextData = errors.New("missing required context data")
2525

26-
// ErrInvalidContextData indicates a required key is present in
27-
// TransactionContext.Data but has the wrong type (e.g., TenantID is a
28-
// number instead of a string). Since Data is map[string]any from JSON
29-
// unmarshaling, type assertions can fail. This is a platform/caller issue.
30-
var ErrInvalidContextData = errors.New("invalid context data type")
31-
3226
// ErrTenantNotFound indicates the requested tenant is not in the static
3327
// config and no resolver callback is set, or the resolver returned not found.
3428
// This is a proxy configuration issue.

plugins/contrib/microsoft/token.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,20 +201,24 @@ func (s *RefreshTokenSource) GetCredentials(
201201
tx sdk.TransactionContext,
202202
_ *http.Request,
203203
) (*sdk.Credential, error) {
204-
tenantID, err := contrib.ResolveFromContext(ctx, tx, "TenantID", s.keyResolver)
204+
tenantID, err := contrib.ResolveCredentialKey(ctx, tx, "TenantID", s.keyResolver)
205205
if err != nil {
206206
return nil, err
207207
}
208208

209209
if !validTenantID.MatchString(tenantID) {
210210
return nil, fmt.Errorf("TenantID contains invalid characters: %w",
211-
contrib.ErrInvalidContextData)
211+
sdk.ErrInvalidContextData)
212212
}
213213

214-
resource, err := contrib.ResolveFromContext(ctx, tx, "Resource", nil)
214+
resource, ok, err := tx.DataString("Resource")
215215
if err != nil {
216216
return nil, err
217217
}
218+
if !ok {
219+
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)
220+
contrib.ErrMissingContextData)
221+
}
218222

219223
entry := s.getOrCreate(ctx, tenantID)
220224

plugins/contrib/microsoft/token_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,8 @@ func TestGetCredentials_EmptyTenantID_ReturnsErrInvalidContextData(t *testing.T)
236236
t.Fatal("expected error")
237237
}
238238

239-
if !errors.Is(err, contrib.ErrInvalidContextData) {
240-
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
239+
if !errors.Is(err, sdk.ErrInvalidContextData) {
240+
t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err)
241241
}
242242

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

274-
if !errors.Is(err, contrib.ErrInvalidContextData) {
275-
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
274+
if !errors.Is(err, sdk.ErrInvalidContextData) {
275+
t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err)
276276
}
277277

278278
if !strings.Contains(err.Error(), "Resource") {
@@ -302,8 +302,8 @@ func TestGetCredentials_TenantIDWrongType_ReturnsErrInvalidContextData(t *testin
302302
t.Fatal("expected error")
303303
}
304304

305-
if !errors.Is(err, contrib.ErrInvalidContextData) {
306-
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
305+
if !errors.Is(err, sdk.ErrInvalidContextData) {
306+
t.Errorf("error = %v, want errors.Is(sdk.ErrInvalidContextData)", err)
307307
}
308308

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

352-
if !errors.Is(err, contrib.ErrInvalidContextData) {
352+
if !errors.Is(err, sdk.ErrInvalidContextData) {
353353
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
354354
}
355355
})
@@ -1359,7 +1359,7 @@ func TestGetCredentials_KeyResolver_MalformedTxDataErrorsEvenWithResolver(t *tes
13591359
t.Fatal("expected error")
13601360
}
13611361

1362-
if !errors.Is(err, contrib.ErrInvalidContextData) {
1362+
if !errors.Is(err, sdk.ErrInvalidContextData) {
13631363
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
13641364
}
13651365
})
@@ -1442,7 +1442,7 @@ func TestGetCredentials_KeyResolver_ValidTenantIDCheck_RejectsBadResolverValue(t
14421442
t.Fatal("expected error for path traversal tenant from resolver")
14431443
}
14441444

1445-
if !errors.Is(err, contrib.ErrInvalidContextData) {
1445+
if !errors.Is(err, sdk.ErrInvalidContextData) {
14461446
t.Errorf("error = %v, want errors.Is(ErrInvalidContextData)", err)
14471447
}
14481448
}

plugins/contrib/resolver.go

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,23 +23,26 @@ type KeyResolver interface {
2323
ResolveKey(ctx context.Context, tx sdk.TransactionContext) (string, error)
2424
}
2525

26-
// ResolveFromContext extracts a key from tx.Data if present, otherwise
26+
// ResolveCredentialKey extracts a key from tx.Data if present, otherwise
2727
// delegates to the resolver. Returns ErrMissingContextData if neither
2828
// source provides a value.
2929
//
3030
// Malformed overrides (present but wrong type or empty string) always
3131
// return ErrInvalidContextData — they never fall through to the resolver.
3232
// This preserves strictness: a connector bug that sends a bad value is
3333
// surfaced immediately, not silently masked by the resolver.
34-
func ResolveFromContext(
34+
func ResolveCredentialKey(
3535
ctx context.Context,
3636
tx sdk.TransactionContext,
3737
dataField string,
3838
resolver KeyResolver,
3939
) (string, error) {
40-
raw, ok := tx.Data[dataField]
40+
key, ok, err := tx.DataString(dataField)
41+
if err != nil {
42+
return "", err
43+
}
4144
if ok {
42-
return extractOverride(raw, dataField)
45+
return key, nil
4346
}
4447

4548
if resolver != nil {
@@ -57,21 +60,3 @@ func ResolveFromContext(
5760
return "", fmt.Errorf("%s not present in transaction context: %w",
5861
dataField, ErrMissingContextData)
5962
}
60-
61-
// extractOverride validates an explicit override from tx.Data. It returns
62-
// ErrInvalidContextData for wrong type or empty string — these never fall
63-
// through to the resolver.
64-
func extractOverride(raw any, field string) (string, error) {
65-
s, ok := raw.(string)
66-
if !ok {
67-
return "", fmt.Errorf("%s must be a string, got %T: %w",
68-
field, raw, ErrInvalidContextData)
69-
}
70-
71-
if s == "" {
72-
return "", fmt.Errorf("%s is empty in transaction context: %w",
73-
field, ErrInvalidContextData)
74-
}
75-
76-
return s, nil
77-
}

0 commit comments

Comments
 (0)