diff --git a/docs/server/docs.go b/docs/server/docs.go index d0bdc43360..bdd4718b94 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -549,6 +549,9 @@ const docTemplate = `{ "type": "array", "uniqueItems": false }, + "upstream_filter": { + "$ref": "#/components/schemas/upstreamfilter.GroupBasedFilterConfig" + }, "upstreams": { "description": "Upstreams configures connections to upstream Identity Providers for\ninteractive authorization. It may be empty only when DelegateClients or a\nTrustedIssuer with JWTBearerGrant enables token-only operation.\nMultiple upstreams are supported for sequential authorization chains.", "items": { @@ -5774,6 +5777,53 @@ const docTemplate = `{ }, "type": "object" }, + "upstreamfilter.GroupBasedFilterConfig": { + "description": "UpstreamFilter configures an optional group-based filter that narrows the\nmulti-upstream authorization chain per-request based on the first\nupstream's resolved identity. When nil, every request walks the full\nupstream chain in configured order (preserving behavior from before the\nfilter hook existed).\n\nThe filter is applied exactly once per authorization, on the first leg's\ncallback. The mandatory first upstream (Upstreams[0]) is never filtered\nout. See pkg/authserver/upstreamfilter for the configuration schema and\npkg/authserver/server/handlers for the runtime contract.", + "properties": { + "claim": { + "description": "Claim is the exact, top-level JWT/OIDC claim name to read group\nmembership from. Defaults to \"groups\" when empty. The claim value MUST be\na JSON array of strings; other shapes (missing, non-array, mixed types)\nfail the authorization. Nested claims (e.g. Keycloak's realm_access.roles)\nmust be flattened by the identity provider — nested paths are not\nsupported in this implementation.\n\nThe value is read from upstream.Identity.Claims as the first upstream\nresolves it: an OIDC upstream fills that map from the validated ID\ntoken, a pure OAuth2 upstream from the userinfo response. The\nidentityFromToken and synthesized-identity paths leave it empty, and\ngroups carried only in an access token never reach it. A missing claim\nfails the authorization — DefaultUpstreams covers a valid-but-empty\nclaim and unmatched groups, not an absent one.", + "type": "string" + }, + "defaultUpstreams": { + "description": "DefaultUpstreams is used when the claim is a valid empty array or when no\nrule matches. When empty or omitted, no optional upstreams are added —\nthe mandatory first upstream remains but the authorization walks no\nfurther.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "rules": { + "description": "Rules assigns groups to upstream provider subsets. A principal matches a\nrule if any of the principal's group values appears in the rule's Groups\nlist (any-of / OR semantics). Multiple matching rules are combined by\nunion: the effective upstream chain contains every UpstreamProviders\nvalue from every matching rule, deduplicated.", + "items": { + "$ref": "#/components/schemas/upstreamfilter.GroupRule" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "upstreamfilter.GroupRule": { + "properties": { + "groups": { + "description": "Groups is the set of principal group names this rule matches. Matching\nis case-sensitive and exact. Empty and duplicate values are rejected at\nconfiguration validation time.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "upstreamProviders": { + "description": "UpstreamProviders lists the non-first upstream provider names to include\nin the chain when this rule matches. All names must reference a\nconfigured non-first upstream. Empty and duplicate values, references to\nthe mandatory first upstream, and references to undeclared upstreams are\nrejected at configuration validation time.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "v0.ServerJSON": { "properties": { "$schema": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index ac491a79f7..ff36ab077b 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -542,6 +542,9 @@ "type": "array", "uniqueItems": false }, + "upstream_filter": { + "$ref": "#/components/schemas/upstreamfilter.GroupBasedFilterConfig" + }, "upstreams": { "description": "Upstreams configures connections to upstream Identity Providers for\ninteractive authorization. It may be empty only when DelegateClients or a\nTrustedIssuer with JWTBearerGrant enables token-only operation.\nMultiple upstreams are supported for sequential authorization chains.", "items": { @@ -5767,6 +5770,53 @@ }, "type": "object" }, + "upstreamfilter.GroupBasedFilterConfig": { + "description": "UpstreamFilter configures an optional group-based filter that narrows the\nmulti-upstream authorization chain per-request based on the first\nupstream's resolved identity. When nil, every request walks the full\nupstream chain in configured order (preserving behavior from before the\nfilter hook existed).\n\nThe filter is applied exactly once per authorization, on the first leg's\ncallback. The mandatory first upstream (Upstreams[0]) is never filtered\nout. See pkg/authserver/upstreamfilter for the configuration schema and\npkg/authserver/server/handlers for the runtime contract.", + "properties": { + "claim": { + "description": "Claim is the exact, top-level JWT/OIDC claim name to read group\nmembership from. Defaults to \"groups\" when empty. The claim value MUST be\na JSON array of strings; other shapes (missing, non-array, mixed types)\nfail the authorization. Nested claims (e.g. Keycloak's realm_access.roles)\nmust be flattened by the identity provider — nested paths are not\nsupported in this implementation.\n\nThe value is read from upstream.Identity.Claims as the first upstream\nresolves it: an OIDC upstream fills that map from the validated ID\ntoken, a pure OAuth2 upstream from the userinfo response. The\nidentityFromToken and synthesized-identity paths leave it empty, and\ngroups carried only in an access token never reach it. A missing claim\nfails the authorization — DefaultUpstreams covers a valid-but-empty\nclaim and unmatched groups, not an absent one.", + "type": "string" + }, + "defaultUpstreams": { + "description": "DefaultUpstreams is used when the claim is a valid empty array or when no\nrule matches. When empty or omitted, no optional upstreams are added —\nthe mandatory first upstream remains but the authorization walks no\nfurther.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "rules": { + "description": "Rules assigns groups to upstream provider subsets. A principal matches a\nrule if any of the principal's group values appears in the rule's Groups\nlist (any-of / OR semantics). Multiple matching rules are combined by\nunion: the effective upstream chain contains every UpstreamProviders\nvalue from every matching rule, deduplicated.", + "items": { + "$ref": "#/components/schemas/upstreamfilter.GroupRule" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "upstreamfilter.GroupRule": { + "properties": { + "groups": { + "description": "Groups is the set of principal group names this rule matches. Matching\nis case-sensitive and exact. Empty and duplicate values are rejected at\nconfiguration validation time.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "upstreamProviders": { + "description": "UpstreamProviders lists the non-first upstream provider names to include\nin the chain when this rule matches. All names must reference a\nconfigured non-first upstream. Empty and duplicate values, references to\nthe mandatory first upstream, and references to undeclared upstreams are\nrejected at configuration validation time.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "v0.ServerJSON": { "properties": { "$schema": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 0c4e5dd064..3f28ec76b6 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -744,6 +744,8 @@ components: $ref: '#/components/schemas/tokenexchange.TrustedIssuer' type: array uniqueItems: false + upstream_filter: + $ref: '#/components/schemas/upstreamfilter.GroupBasedFilterConfig' upstreams: description: |- Upstreams configures connections to upstream Identity Providers for @@ -5499,6 +5501,81 @@ components: shared: $ref: '#/components/schemas/types.RateLimitBucket' type: object + upstreamfilter.GroupBasedFilterConfig: + description: |- + UpstreamFilter configures an optional group-based filter that narrows the + multi-upstream authorization chain per-request based on the first + upstream's resolved identity. When nil, every request walks the full + upstream chain in configured order (preserving behavior from before the + filter hook existed). + + The filter is applied exactly once per authorization, on the first leg's + callback. The mandatory first upstream (Upstreams[0]) is never filtered + out. See pkg/authserver/upstreamfilter for the configuration schema and + pkg/authserver/server/handlers for the runtime contract. + properties: + claim: + description: |- + Claim is the exact, top-level JWT/OIDC claim name to read group + membership from. Defaults to "groups" when empty. The claim value MUST be + a JSON array of strings; other shapes (missing, non-array, mixed types) + fail the authorization. Nested claims (e.g. Keycloak's realm_access.roles) + must be flattened by the identity provider — nested paths are not + supported in this implementation. + + The value is read from upstream.Identity.Claims as the first upstream + resolves it: an OIDC upstream fills that map from the validated ID + token, a pure OAuth2 upstream from the userinfo response. The + identityFromToken and synthesized-identity paths leave it empty, and + groups carried only in an access token never reach it. A missing claim + fails the authorization — DefaultUpstreams covers a valid-but-empty + claim and unmatched groups, not an absent one. + type: string + defaultUpstreams: + description: |- + DefaultUpstreams is used when the claim is a valid empty array or when no + rule matches. When empty or omitted, no optional upstreams are added — + the mandatory first upstream remains but the authorization walks no + further. + items: + type: string + type: array + uniqueItems: false + rules: + description: |- + Rules assigns groups to upstream provider subsets. A principal matches a + rule if any of the principal's group values appears in the rule's Groups + list (any-of / OR semantics). Multiple matching rules are combined by + union: the effective upstream chain contains every UpstreamProviders + value from every matching rule, deduplicated. + items: + $ref: '#/components/schemas/upstreamfilter.GroupRule' + type: array + uniqueItems: false + type: object + upstreamfilter.GroupRule: + properties: + groups: + description: |- + Groups is the set of principal group names this rule matches. Matching + is case-sensitive and exact. Empty and duplicate values are rejected at + configuration validation time. + items: + type: string + type: array + uniqueItems: false + upstreamProviders: + description: |- + UpstreamProviders lists the non-first upstream provider names to include + in the chain when this rule matches. All names must reference a + configured non-first upstream. Empty and duplicate values, references to + the mandatory first upstream, and references to undeclared upstreams are + rejected at configuration validation time. + items: + type: string + type: array + uniqueItems: false + type: object v0.ServerJSON: properties: $schema: diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index c7116e60d1..fa204b1a46 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -23,6 +23,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" + "github.com/stacklok/toolhive/pkg/authserver/upstreamfilter" "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/oauthproto" ) @@ -79,6 +80,18 @@ type RunConfig struct { // Multiple upstreams are supported for sequential authorization chains. Upstreams []UpstreamRunConfig `json:"upstreams" yaml:"upstreams"` + // UpstreamFilter configures an optional group-based filter that narrows the + // multi-upstream authorization chain per-request based on the first + // upstream's resolved identity. When nil, every request walks the full + // upstream chain in configured order (preserving behavior from before the + // filter hook existed). + // + // The filter is applied exactly once per authorization, on the first leg's + // callback. The mandatory first upstream (Upstreams[0]) is never filtered + // out. See pkg/authserver/upstreamfilter for the configuration schema and + // pkg/authserver/server/handlers for the runtime contract. + UpstreamFilter *upstreamfilter.GroupBasedFilterConfig `json:"upstream_filter,omitempty" yaml:"upstream_filter,omitempty"` + // ScopesSupported lists the OAuth 2.0 scope values advertised in discovery documents. // If empty, defaults to registration.DefaultScopes (["openid", "profile", "email", "offline_access"]). ScopesSupported []string `json:"scopes_supported,omitempty" yaml:"scopes_supported,omitempty"` @@ -332,9 +345,31 @@ func (c *RunConfig) Validate() error { if err := validateSPIFFENotYetEnforced(c.SPIFFETrustDomains); err != nil { return err } + if err := c.validateUpstreamFilter(); err != nil { + return err + } return c.validateBaselineClientScopes() } +// validateUpstreamFilter rejects an UpstreamFilter configuration whose rules or +// defaults reference upstream names that are not configured, that reference the +// mandatory first upstream, or that otherwise violate the filter's static +// invariants. It constructs the filter against the configured upstream names +// and discards the result — successful construction is the validation. +func (c *RunConfig) validateUpstreamFilter() error { + if c.UpstreamFilter == nil { + return nil + } + configured := make([]string, len(c.Upstreams)) + for i := range c.Upstreams { + configured[i] = c.Upstreams[i].Name + } + if _, err := upstreamfilter.NewGroupBasedFilter(*c.UpstreamFilter, configured); err != nil { + return fmt.Errorf("upstream_filter: %w", err) + } + return nil +} + // validateSPIFFENotYetEnforced hard-rejects a non-empty SPIFFE trust // configuration. ValidateSPIFFETrust above confirms the configuration is // well-formed, but well-formed is not the same as enforced: nothing in this diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index 5f68e9cc03..96cf17e8d6 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -5,6 +5,7 @@ package authserver import ( "bytes" + "encoding/json" "log/slog" "strings" "testing" @@ -12,12 +13,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver/upstream" + "github.com/stacklok/toolhive/pkg/authserver/upstreamfilter" "github.com/stacklok/toolhive/pkg/oauthproto" ) @@ -730,6 +733,216 @@ func TestRunConfigValidate(t *testing.T) { } } +// TestRunConfigValidate_UpstreamFilter covers RunConfig.Validate()'s +// enforcement of UpstreamFilter static invariants against the configured +// upstream list. Thorough matching/union/claim behavior is covered in +// pkg/authserver/upstreamfilter — these cases only verify that invalid filter +// configurations are rejected at startup rather than deferred to a runtime +// surprise. +func TestRunConfigValidate_UpstreamFilter(t *testing.T) { + t.Parallel() + + okta := UpstreamRunConfig{Name: "okta"} + jira := UpstreamRunConfig{Name: "jira"} + slack := UpstreamRunConfig{Name: "slack"} + + validRule := upstreamfilter.GroupRule{ + Groups: []string{"engineering"}, + UpstreamProviders: []string{"jira"}, + } + + tests := []struct { + name string + cfg RunConfig + wantErr bool + errMsg string + }{ + { + name: "nil UpstreamFilter passes", + cfg: RunConfig{ + Upstreams: []UpstreamRunConfig{okta, jira, slack}, + }, + }, + { + name: "valid UpstreamFilter passes", + cfg: RunConfig{ + Upstreams: []UpstreamRunConfig{okta, jira, slack}, + UpstreamFilter: &upstreamfilter.GroupBasedFilterConfig{ + Rules: []upstreamfilter.GroupRule{validRule}, + DefaultUpstreams: []string{"slack"}, + }, + }, + }, + { + name: "fewer than two upstreams is rejected", + cfg: RunConfig{ + Upstreams: []UpstreamRunConfig{okta}, + UpstreamFilter: &upstreamfilter.GroupBasedFilterConfig{ + Rules: []upstreamfilter.GroupRule{validRule}, + }, + }, + wantErr: true, + errMsg: "at least two configured upstreams", + }, + { + name: "unknown upstream reference is rejected", + cfg: RunConfig{ + Upstreams: []UpstreamRunConfig{okta, jira, slack}, + UpstreamFilter: &upstreamfilter.GroupBasedFilterConfig{ + Rules: []upstreamfilter.GroupRule{{ + Groups: []string{"engineering"}, + UpstreamProviders: []string{"salesforce"}, + }}, + }, + }, + wantErr: true, + errMsg: `"salesforce" is not a configured upstream`, + }, + { + name: "reference to mandatory first upstream is rejected", + cfg: RunConfig{ + Upstreams: []UpstreamRunConfig{okta, jira, slack}, + UpstreamFilter: &upstreamfilter.GroupBasedFilterConfig{ + Rules: []upstreamfilter.GroupRule{{ + Groups: []string{"engineering"}, + UpstreamProviders: []string{"okta"}, + }}, + }, + }, + wantErr: true, + errMsg: `must not reference the mandatory first upstream "okta"`, + }, + { + name: "no rules is rejected", + cfg: RunConfig{ + Upstreams: []UpstreamRunConfig{okta, jira, slack}, + UpstreamFilter: &upstreamfilter.GroupBasedFilterConfig{ + Rules: nil, + }, + }, + wantErr: true, + errMsg: "at least one rule", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.cfg.Validate() + assertError(t, err, tt.wantErr, tt.errMsg) + }) + } +} + +// TestRunConfig_UpstreamFilterRoundTrip verifies that UpstreamFilter survives +// JSON encode/decode without data loss — this is the path by which the +// operator ConfigMap delivers the field to the vMCP pod. +func TestRunConfig_UpstreamFilterRoundTrip(t *testing.T) { + t.Parallel() + + original := RunConfig{ + SchemaVersion: CurrentSchemaVersion, + Issuer: "https://example.com", + Upstreams: []UpstreamRunConfig{ + {Name: "okta"}, + {Name: "jira"}, + {Name: "slack"}, + }, + UpstreamFilter: &upstreamfilter.GroupBasedFilterConfig{ + Claim: "groups", + Rules: []upstreamfilter.GroupRule{ + {Groups: []string{"engineering"}, UpstreamProviders: []string{"jira", "slack"}}, + {Groups: []string{"sales"}, UpstreamProviders: []string{"slack"}}, + }, + DefaultUpstreams: []string{"jira"}, + }, + } + + encoded, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded RunConfig + require.NoError(t, json.Unmarshal(encoded, &decoded)) + + assert.Equal(t, original.UpstreamFilter, decoded.UpstreamFilter) + // Also confirm omitempty behavior: a nil filter must not appear in the + // encoded payload at all. + noFilter := RunConfig{SchemaVersion: CurrentSchemaVersion, Issuer: "https://example.com"} + noFilterJSON, err := json.Marshal(&noFilter) + require.NoError(t, err) + assert.NotContains(t, string(noFilterJSON), "upstream_filter") +} + +// TestRunConfig_UpstreamFilterYAMLRoundTrip verifies YAML decoding and +// re-encoding of upstream filter configuration using the vMCP loader's +// YAML package. A literal document pins the expected configuration keys. +func TestRunConfig_UpstreamFilterYAMLRoundTrip(t *testing.T) { + t.Parallel() + + t.Run("configured_filter", func(t *testing.T) { + t.Parallel() + + const doc = ` +schema_version: v0.1.0 +issuer: https://example.com +upstreams: + - name: okta + - name: jira + - name: slack +upstream_filter: + claim: custom_groups + rules: + - groups: + - engineering + upstreamProviders: + - jira + - slack + - groups: + - sales + upstreamProviders: + - slack + defaultUpstreams: + - jira +` + + want := &upstreamfilter.GroupBasedFilterConfig{ + Claim: "custom_groups", + Rules: []upstreamfilter.GroupRule{ + {Groups: []string{"engineering"}, UpstreamProviders: []string{"jira", "slack"}}, + {Groups: []string{"sales"}, UpstreamProviders: []string{"slack"}}, + }, + DefaultUpstreams: []string{"jira"}, + } + + var decoded RunConfig + require.NoError(t, yaml.Unmarshal([]byte(doc), &decoded)) + require.NotNil(t, decoded.UpstreamFilter) + assert.Equal(t, want, decoded.UpstreamFilter) + + // Re-encoding must produce a document the same decoder reads back + // unchanged: the operator rewrites the ConfigMap on every reconcile. + encoded, err := yaml.Marshal(&decoded) + require.NoError(t, err) + var reDecoded RunConfig + require.NoError(t, yaml.Unmarshal(encoded, &reDecoded)) + assert.Equal(t, decoded.UpstreamFilter, reDecoded.UpstreamFilter) + }) + + t.Run("nil_filter", func(t *testing.T) { + t.Parallel() + + // An unset filter must be omitted from YAML and decode back to nil. + noFilter := RunConfig{SchemaVersion: CurrentSchemaVersion, Issuer: "https://example.com"} + encoded, err := yaml.Marshal(&noFilter) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "upstream_filter") + + var decoded RunConfig + require.NoError(t, yaml.Unmarshal(encoded, &decoded)) + assert.Nil(t, decoded.UpstreamFilter) + }) +} + func TestDelegateClientRunConfigValidate(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index f1f875c8d0..7f847537eb 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -25,6 +25,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" + "github.com/stacklok/toolhive/pkg/authserver/upstreamfilter" "github.com/stacklok/toolhive/pkg/bodylimit" "github.com/stacklok/toolhive/pkg/oauthproto" "github.com/stacklok/toolhive/pkg/redisconfig" @@ -190,6 +191,44 @@ func hasSPIFFEClientAuth(cfg *authserver.RunConfig) bool { return cfg.InboundGrants != nil && len(cfg.InboundGrants.SPIFFEClientAuth) > 0 } +// buildUpstreamFilter constructs a handlers.UpstreamFilter from the RunConfig, +// if one is configured. It returns an untyped nil (not a typed-nil interface +// value) when cfg.UpstreamFilter is unset so authserver.Config.UpstreamFilter +// remains truly nil and the handler walks all configured upstreams — +// preserving behavior from before the filter hook existed. +// +// RunConfig.Validate has already applied the same static-invariant checks the +// filter constructor runs here (unknown provider references, mandatory-first +// references, fewer-than-two upstreams, etc.), so a Validate-passing cfg will +// not fail here in practice. The second construction is cheap and acts as a +// belt-and-suspenders guard for consumers that bypassed Validate. +func buildUpstreamFilter(cfg *authserver.RunConfig) (handlers.UpstreamFilter, error) { + if cfg.UpstreamFilter == nil { + return nil, nil + } + configured := make([]string, len(cfg.Upstreams)) + for i := range cfg.Upstreams { + configured[i] = cfg.Upstreams[i].Name + } + f, err := upstreamfilter.NewGroupBasedFilter(*cfg.UpstreamFilter, configured) + if err != nil { + return nil, err + } + return f, nil +} + +// newEmbeddedAuthServerWithStorage assembles the auth server from a RunConfig +// in a strict ordered sequence: validate inputs, derive crypto bits, resolve +// upstream providers and inbound grants, build the handler Config, construct +// the server. Each step is a required stage with its own error-return and +// cannot be reordered, so the function's cyclomatic complexity sits above the +// package lint ceiling by design rather than from avoidable branching. The +// shape mirrors the "sequential boot" exceptions elsewhere in the repository +// (CLI command handlers, reconcile loops) and is preferred over splitting the +// chain into helpers whose only purpose would be to spread the error checks +// across functions without clarifying the boot order. +// +//nolint:gocyclo // Sequential auth-server bootstrap; see function docstring. func newEmbeddedAuthServerWithStorage( ctx context.Context, cfg *authserver.RunConfig, @@ -303,6 +342,11 @@ func newEmbeddedAuthServerWithStorage( return nil, fmt.Errorf("failed to resolve JWT-bearer grant policies: %w", err) } + upstreamFilter, err := buildUpstreamFilter(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build upstream filter: %w", err) + } + resolvedCfg := authserver.Config{ Issuer: cfg.Issuer, AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL, @@ -336,6 +380,7 @@ func newEmbeddedAuthServerWithStorage( DisableTokenExchange: !normalized.Capabilities.TokenExchange, SPIFFETrust: spiffeTrust, DeviceFlowEnabled: cfg.DeviceFlowEnabled, + UpstreamFilter: upstreamFilter, } // 8. Create the auth server. authserver.New also asserts the DCR diff --git a/pkg/authserver/runner/embeddedauthserver_upstreamfilter_test.go b/pkg/authserver/runner/embeddedauthserver_upstreamfilter_test.go new file mode 100644 index 0000000000..002281c41f --- /dev/null +++ b/pkg/authserver/runner/embeddedauthserver_upstreamfilter_test.go @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/oauth2-proxy/mockoidc" + "github.com/ory/fosite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/authserver/upstreamfilter" +) + +const ( + filterTestIssuer = "https://auth.example.com" + filterTestClientID = "filter-test-client" + filterTestRedirectURI = "https://client.example.com/callback" + filterTestAudience = "https://mcp.example.com" +) + +// startFilterUpstream runs a mockoidc instance standing in for one upstream +// IdP and queues a user carrying the given groups. mockoidc only copies +// MockUser.Groups into the ID token when the "groups" scope is requested, so +// every upstream config below asks for it. +func startFilterUpstream(t *testing.T, name string, groups []string) *mockoidc.MockOIDC { + t.Helper() + m, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, m.Shutdown()) }) + m.QueueUser(&mockoidc.MockUser{ + Subject: name + "-subject", + Email: name + "@example.com", + PreferredUsername: name + "-user", + Groups: groups, + }) + return m +} + +// newFilterRunConfig builds a RunConfig whose upstreams are the given mockoidc +// instances, in order. The first is the mandatory identity leg. +func newFilterRunConfig( + t *testing.T, + filter *upstreamfilter.GroupBasedFilterConfig, + names []string, + ms []*mockoidc.MockOIDC, +) *authserver.RunConfig { + t.Helper() + secretDir := t.TempDir() + upstreams := make([]authserver.UpstreamRunConfig, 0, len(ms)) + for i, m := range ms { + cfg := m.Config() + upstreams = append(upstreams, authserver.UpstreamRunConfig{ + Name: names[i], + Type: authserver.UpstreamProviderTypeOIDC, + OIDCConfig: &authserver.OIDCUpstreamRunConfig{ + IssuerURL: m.Issuer(), + ClientID: cfg.ClientID, + ClientSecretFile: writeUpstreamSecret(t, secretDir, names[i], cfg.ClientSecret), + RedirectURI: filterTestIssuer + "/oauth/callback", + Scopes: []string{"openid", "groups"}, + }, + }) + } + return &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: filterTestIssuer, + Upstreams: upstreams, + UpstreamFilter: filter, + ScopesSupported: []string{"openid", "profile", "offline_access"}, + AllowedAudiences: []string{filterTestAudience}, + InsecureAllowHTTP: true, + } +} + +// writeUpstreamSecret stores an upstream client secret in a file. RunConfig +// takes secrets by reference, and a file keeps these tests parallel-safe where +// an environment variable would not be. +func writeUpstreamSecret(t *testing.T, dir, name, secret string) string { + t.Helper() + path := filepath.Join(dir, name+".secret") + require.NoError(t, os.WriteFile(path, []byte(secret), 0o600)) + return path +} + +// newFilterTestServer builds the auth server from the RunConfig — exercising +// the RunConfig -> buildUpstreamFilter -> resolvedCfg wiring this test exists +// for — and serves it over httptest. +func newFilterTestServer( + t *testing.T, + filter *upstreamfilter.GroupBasedFilterConfig, + names []string, + ms []*mockoidc.MockOIDC, +) *httptest.Server { + t.Helper() + ctx := context.Background() + + stor := storage.NewMemoryStorage() + eas, err := NewEmbeddedAuthServerWithStorage(ctx, newFilterRunConfig(t, filter, names, ms), stor) + require.NoError(t, err) + + require.NoError(t, stor.RegisterClient(ctx, &fosite.DefaultClient{ + ID: filterTestClientID, + RedirectURIs: []string{filterTestRedirectURI}, + ResponseTypes: []string{"code"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + Scopes: registration.DefaultScopes, + Audience: []string{filterTestAudience}, + Public: true, + })) + + srv := httptest.NewServer(eas.Handler()) + t.Cleanup(func() { + srv.Close() + require.NoError(t, eas.Close()) + }) + return srv +} + +// filterNoRedirectClient returns a client that surfaces each redirect instead +// of following it, so the test can inspect which upstream the chain picks. +func filterNoRedirectClient() *http.Client { + return &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// filterGetWithCookies issues a GET carrying cookies verbatim. /oauth/callback +// is bound to the browser that started the flow by a cookie set on the +// redirect to the upstream, so a hand-walked flow must carry it itself. +func filterGetWithCookies( + t *testing.T, client *http.Client, rawURL string, cookies []*http.Cookie, +) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(t, err) + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := client.Do(req) + require.NoError(t, err) + return resp +} + +// filterApproveConsent posts an approval for the consent page the authorize +// endpoint serves before the first upstream redirect. +func filterApproveConsent( + t *testing.T, client *http.Client, serverURL string, resp *http.Response, +) *http.Response { + t.Helper() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected consent page") + page, err := io.ReadAll(resp.Body) + require.NoError(t, err) + cookies := resp.Cookies() + resp.Body.Close() + + match := regexp.MustCompile(`name="handle" value="([^"]+)"`).FindSubmatch(page) + require.Len(t, match, 2, "consent handle missing") + form := url.Values{"handle": {string(match[1])}, "decision": {"approve"}} + req, err := http.NewRequest(http.MethodPost, serverURL+"/oauth/consent", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", filterTestIssuer) + for _, c := range cookies { + req.AddCookie(c) + } + approved, err := client.Do(req) + require.NoError(t, err) + return approved +} + +// walkChain drives the authorization chain leg by leg and reports the host of +// every upstream authorize endpoint it was redirected to, in order, together +// with the response that ended the walk — a 303 back to the client once the +// chain is satisfied, or whatever the callback returned instead. +// +// The per-leg return is the point: the filter runs on the first leg's +// callback, so the hosts collected here are exactly the chain it selected. +func walkChain(t *testing.T, serverURL, challenge, clientState string) ([]string, *http.Response) { + t.Helper() + client := filterNoRedirectClient() + parsedServerURL, err := url.Parse(serverURL) + require.NoError(t, err) + + authorizeURL := serverURL + "/oauth/authorize?" + url.Values{ + "client_id": {filterTestClientID}, + "redirect_uri": {filterTestRedirectURI}, + "state": {clientState}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "response_type": {"code"}, + "scope": {"openid"}, + }.Encode() + + resp, err := client.Get(authorizeURL) + require.NoError(t, err) + resp = filterApproveConsent(t, client, serverURL, resp) + require.Equal(t, http.StatusSeeOther, resp.StatusCode, "expected redirect to first upstream") + next, err := resp.Location() + require.NoError(t, err) + cookies := resp.Cookies() + resp.Body.Close() + + var visited []string + for range maxChainLegs { + visited = append(visited, next.Host) + + upstreamResp, err := client.Get(next.String()) + require.NoError(t, err) + require.Equal(t, http.StatusFound, upstreamResp.StatusCode, "expected upstream to redirect to callback") + callback, err := upstreamResp.Location() + require.NoError(t, err) + upstreamResp.Body.Close() + callback.Scheme = parsedServerURL.Scheme + callback.Host = parsedServerURL.Host + + resp = filterGetWithCookies(t, client, callback.String(), cookies) + if resp.StatusCode != http.StatusFound { + return visited, resp + } + // Another upstream leg: the callback mints a fresh binding cookie. + next, err = resp.Location() + require.NoError(t, err) + cookies = resp.Cookies() + resp.Body.Close() + } + t.Fatalf("chain did not finish within %d legs", maxChainLegs) + return visited, nil +} + +const maxChainLegs = 5 + +// TestIntegration_RunConfigUpstreamFilter_UnsetWalksFullChain is the control +// case: with no filter in the RunConfig every configured upstream is visited, +// which is the behavior that existed before the filter hook. +func TestIntegration_RunConfigUpstreamFilter_UnsetWalksFullChain(t *testing.T) { + t.Parallel() + + okta := startFilterUpstream(t, "okta", []string{"viewer"}) + slack := startFilterUpstream(t, "slack", nil) + salesforce := startFilterUpstream(t, "salesforce", nil) + + names := []string{"okta", "slack", "salesforce"} + ms := []*mockoidc.MockOIDC{okta, slack, salesforce} + srv := newFilterTestServer(t, nil, names, ms) + + visited, final := walkChain(t, srv.URL, "test-challenge", "client-state") + defer final.Body.Close() + + assert.Equal(t, []string{hostOf(t, okta), hostOf(t, slack), hostOf(t, salesforce)}, visited) + assert.Equal(t, http.StatusSeeOther, final.StatusCode) + loc, err := final.Location() + require.NoError(t, err) + assert.Equal(t, filterTestRedirectURI, loc.Scheme+"://"+loc.Host+loc.Path) + assert.NotEmpty(t, loc.Query().Get("code")) + assert.Equal(t, "client-state", loc.Query().Get("state")) +} + +func hostOf(t *testing.T, m *mockoidc.MockOIDC) string { + t.Helper() + u, err := url.Parse(m.AuthorizationEndpoint()) + require.NoError(t, err) + return u.Host +} + +// TestIntegration_RunConfigUpstreamFilter_NarrowsChain proves the filter the +// RunConfig asks for is the one the callback handler actually runs: a viewer +// is redirected through okta then slack, and never to salesforce. Server +// construction still discovers salesforce; what the filter removes is the +// authorization redirect. +func TestIntegration_RunConfigUpstreamFilter_NarrowsChain(t *testing.T) { + t.Parallel() + + okta := startFilterUpstream(t, "okta", []string{"viewer"}) + slack := startFilterUpstream(t, "slack", nil) + salesforce := startFilterUpstream(t, "salesforce", nil) + + names := []string{"okta", "slack", "salesforce"} + ms := []*mockoidc.MockOIDC{okta, slack, salesforce} + filter := &upstreamfilter.GroupBasedFilterConfig{ + Rules: []upstreamfilter.GroupRule{ + {Groups: []string{"viewer"}, UpstreamProviders: []string{"slack"}}, + }, + } + srv := newFilterTestServer(t, filter, names, ms) + + visited, final := walkChain(t, srv.URL, "test-challenge", "client-state") + defer final.Body.Close() + + assert.Equal(t, []string{hostOf(t, okta), hostOf(t, slack)}, visited) + assert.NotContains(t, visited, hostOf(t, salesforce), + "the user must never be redirected to salesforce") + assert.Equal(t, http.StatusSeeOther, final.StatusCode) + loc, err := final.Location() + require.NoError(t, err) + assert.Equal(t, filterTestRedirectURI, loc.Scheme+"://"+loc.Host+loc.Path) + assert.NotEmpty(t, loc.Query().Get("code"), "authorization must complete on the narrowed chain") + assert.Equal(t, "client-state", loc.Query().Get("state")) +} + +// TestIntegration_RunConfigUpstreamFilter_MissingClaimFails covers the +// fail-closed contract through the wiring: an identity without the configured +// claim stops the authorization on the first leg's callback. +func TestIntegration_RunConfigUpstreamFilter_MissingClaimFails(t *testing.T) { + t.Parallel() + + okta := startFilterUpstream(t, "okta", nil) + slack := startFilterUpstream(t, "slack", nil) + + names := []string{"okta", "slack"} + ms := []*mockoidc.MockOIDC{okta, slack} + filter := &upstreamfilter.GroupBasedFilterConfig{ + Rules: []upstreamfilter.GroupRule{ + {Groups: []string{"viewer"}, UpstreamProviders: []string{"slack"}}, + }, + DefaultUpstreams: []string{"slack"}, + } + srv := newFilterTestServer(t, filter, names, ms) + + visited, final := walkChain(t, srv.URL, "test-challenge", "client-state") + defer final.Body.Close() + + assert.Equal(t, []string{hostOf(t, okta)}, visited, "the chain must stop on the first leg") + require.Equal(t, http.StatusSeeOther, final.StatusCode) + loc, err := final.Location() + require.NoError(t, err) + assert.Equal(t, "server_error", loc.Query().Get("error")) + assert.Contains(t, loc.Query().Get("error_description"), "failed to determine authorization chain") + assert.Empty(t, loc.Query().Get("code"), "no authorization code may be issued") + assert.Equal(t, "client-state", loc.Query().Get("state")) +} diff --git a/pkg/authserver/upstreamfilter/config.go b/pkg/authserver/upstreamfilter/config.go new file mode 100644 index 0000000000..068af10b03 --- /dev/null +++ b/pkg/authserver/upstreamfilter/config.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package upstreamfilter provides UpstreamFilter implementations for the +// embedded authorization server. Filters narrow the multi-upstream authorization +// chain based on the first upstream's resolved identity, so users only walk the +// upstream providers they are entitled to. +// +// This package implements the handlers.UpstreamFilter contract defined in +// pkg/authserver/server/handlers. See that interface's documentation for the +// runtime lifecycle: single invocation per authorization on the first leg's +// callback, principal is read-only, fail-closed on nil/absent claims. +package upstreamfilter + +// GroupBasedFilterConfig is the serializable configuration for the group-based +// upstream filter. It is stored in RunConfig and delivered to the vMCP pod via +// the auth-server config ConfigMap. +// +// The nested keys are camelCase where RunConfig's own fields are snake_case. +// They match the v1beta1 CRD spelling (spec.authServerConfig.upstreamProviders), +// which keeps a rule spelled the same in the CR and in the generated YAML. +// Renaming them would also change the published OpenAPI model and Go SDK. +type GroupBasedFilterConfig struct { + // Claim is the exact, top-level JWT/OIDC claim name to read group + // membership from. Defaults to "groups" when empty. The claim value MUST be + // a JSON array of strings; other shapes (missing, non-array, mixed types) + // fail the authorization. Nested claims (e.g. Keycloak's realm_access.roles) + // must be flattened by the identity provider — nested paths are not + // supported in this implementation. + // + // The value is read from upstream.Identity.Claims as the first upstream + // resolves it: an OIDC upstream fills that map from the validated ID + // token, a pure OAuth2 upstream from the userinfo response. The + // identityFromToken and synthesized-identity paths leave it empty, and + // groups carried only in an access token never reach it. A missing claim + // fails the authorization — DefaultUpstreams covers a valid-but-empty + // claim and unmatched groups, not an absent one. + Claim string `json:"claim,omitempty" yaml:"claim,omitempty"` + + // Rules assigns groups to upstream provider subsets. A principal matches a + // rule if any of the principal's group values appears in the rule's Groups + // list (any-of / OR semantics). Multiple matching rules are combined by + // union: the effective upstream chain contains every UpstreamProviders + // value from every matching rule, deduplicated. + Rules []GroupRule `json:"rules" yaml:"rules"` + + // DefaultUpstreams is used when the claim is a valid empty array or when no + // rule matches. When empty or omitted, no optional upstreams are added — + // the mandatory first upstream remains but the authorization walks no + // further. + DefaultUpstreams []string `json:"defaultUpstreams,omitempty" yaml:"defaultUpstreams,omitempty"` +} + +// GroupRule pairs a set of principal group names with the set of upstream +// provider names matching principals are entitled to walk. +type GroupRule struct { + // Groups is the set of principal group names this rule matches. Matching + // is case-sensitive and exact. Empty and duplicate values are rejected at + // configuration validation time. + Groups []string `json:"groups" yaml:"groups"` + + // UpstreamProviders lists the non-first upstream provider names to include + // in the chain when this rule matches. All names must reference a + // configured non-first upstream. Empty and duplicate values, references to + // the mandatory first upstream, and references to undeclared upstreams are + // rejected at configuration validation time. + UpstreamProviders []string `json:"upstreamProviders" yaml:"upstreamProviders"` +} diff --git a/pkg/authserver/upstreamfilter/filter.go b/pkg/authserver/upstreamfilter/filter.go new file mode 100644 index 0000000000..60f55bb3a2 --- /dev/null +++ b/pkg/authserver/upstreamfilter/filter.go @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upstreamfilter + +import ( + "context" + "errors" + "fmt" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/auth/claims" + "github.com/stacklok/toolhive/pkg/authserver/server/handlers" +) + +const defaultClaimName = "groups" + +// GroupBasedFilter implements handlers.UpstreamFilter by matching the +// principal's group claim against configured rules and returning the union of +// the matching rules' upstream provider sets. See package documentation and +// GroupBasedFilterConfig for semantics. +type GroupBasedFilter struct { + claim string + rules []GroupRule + defaultUpstreams []string +} + +// Compile-time assertion that GroupBasedFilter satisfies the UpstreamFilter +// contract. +var _ handlers.UpstreamFilter = (*GroupBasedFilter)(nil) + +// NewGroupBasedFilter constructs and validates a GroupBasedFilter. +// +// configuredUpstreams is the full ordered list of upstream provider names +// declared on the auth server, including the mandatory first upstream at +// index 0. It is required at construction time to validate that every +// referenced provider exists and that no rule or default references the +// mandatory first upstream (which is always included and cannot be filtered). +// +// It returns an error for any of: fewer than two configured upstreams; no +// rules; empty or duplicate group values within a rule; empty or duplicate +// upstream references within a rule; unknown upstream references; references +// to the mandatory first upstream; unknown or first-upstream references in +// DefaultUpstreams. +func NewGroupBasedFilter(cfg GroupBasedFilterConfig, configuredUpstreams []string) (*GroupBasedFilter, error) { + if len(configuredUpstreams) < 2 { + return nil, fmt.Errorf( + "group-based upstream filter requires at least two configured upstreams, got %d", + len(configuredUpstreams), + ) + } + if len(cfg.Rules) == 0 { + return nil, errors.New("group-based upstream filter requires at least one rule") + } + + claim := cfg.Claim + if claim == "" { + claim = defaultClaimName + } + + firstUpstream := configuredUpstreams[0] + validProviders := make(map[string]struct{}, len(configuredUpstreams)-1) + for _, name := range configuredUpstreams[1:] { + validProviders[name] = struct{}{} + } + + for i, rule := range cfg.Rules { + if err := validateRule(rule, i, firstUpstream, validProviders); err != nil { + return nil, err + } + } + if err := validateProviderList("defaultUpstreams", cfg.DefaultUpstreams, firstUpstream, validProviders); err != nil { + return nil, err + } + + return &GroupBasedFilter{ + claim: claim, + rules: cloneRules(cfg.Rules), + defaultUpstreams: append([]string(nil), cfg.DefaultUpstreams...), + }, nil +} + +// FilterUpstreams implements handlers.UpstreamFilter. +// +// It reads principal.Claims[filter.claim], decodes it as a []string via the +// shared claims helper, and returns the union of every matching rule's +// UpstreamProviders. The claim MUST be present and MUST be a JSON string +// array — a missing, nil, non-array, or mixed-type claim value fails the +// authorization per the filter contract's fail-closed guarantee for +// security-relevant filters. +// +// A valid empty array or a valid array with no matching rule falls back to +// the configured DefaultUpstreams. Duplicate names across matching rules and +// defaults are deduplicated in the returned slice; the handler layer preserves +// configured order and drops any name that is not a non-first configured +// upstream. +// +// principal (including its Claims map) is treated as read-only. Claim values +// are not logged. +// +// The configured argument is unused: the constructor validated every rule and +// default reference against the full configured upstream list, so runtime +// reconciliation of the returned set against the non-first upstreams is +// redundant with the handler layer's own drop-unknown behavior. +func (f *GroupBasedFilter) FilterUpstreams( + _ context.Context, + principal auth.PrincipalInfo, + _ []string, +) ([]string, error) { + if principal.Claims == nil { + return nil, errors.New("upstream filter: principal claims are absent") + } + raw, present := principal.Claims[f.claim] + if !present { + return nil, fmt.Errorf("upstream filter: claim %q not present in principal claims", f.claim) + } + groups, err := claims.DecodeStringArray(raw) + if err != nil { + return nil, fmt.Errorf("upstream filter: claim %q: %w", f.claim, err) + } + + if len(groups) == 0 { + return append([]string(nil), f.defaultUpstreams...), nil + } + + principalGroupSet := make(map[string]struct{}, len(groups)) + for _, g := range groups { + principalGroupSet[g] = struct{}{} + } + + effective := make([]string, 0) + seen := make(map[string]struct{}) + matched := false + for _, rule := range f.rules { + if !ruleMatches(rule, principalGroupSet) { + continue + } + matched = true + for _, name := range rule.UpstreamProviders { + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + effective = append(effective, name) + } + } + if !matched { + return append([]string(nil), f.defaultUpstreams...), nil + } + return effective, nil +} + +// ruleMatches returns true if any of the rule's Groups values is in the +// principal's group set (any-of / OR semantics). +func ruleMatches(rule GroupRule, principalGroupSet map[string]struct{}) bool { + for _, g := range rule.Groups { + if _, ok := principalGroupSet[g]; ok { + return true + } + } + return false +} + +func validateRule(rule GroupRule, index int, firstUpstream string, validProviders map[string]struct{}) error { + if len(rule.Groups) == 0 { + return fmt.Errorf("rules[%d]: groups must not be empty", index) + } + if hasEmpty(rule.Groups) { + return fmt.Errorf("rules[%d]: groups must not contain empty values", index) + } + if hasDuplicates(rule.Groups) { + return fmt.Errorf("rules[%d]: groups must not contain duplicate values", index) + } + if len(rule.UpstreamProviders) == 0 { + return fmt.Errorf("rules[%d]: upstreamProviders must not be empty", index) + } + return validateProviderList( + fmt.Sprintf("rules[%d].upstreamProviders", index), + rule.UpstreamProviders, + firstUpstream, + validProviders, + ) +} + +func validateProviderList(field string, names []string, firstUpstream string, validProviders map[string]struct{}) error { + if hasEmpty(names) { + return fmt.Errorf("%s: must not contain empty values", field) + } + if hasDuplicates(names) { + return fmt.Errorf("%s: must not contain duplicate values", field) + } + for _, name := range names { + if name == firstUpstream { + return fmt.Errorf("%s: must not reference the mandatory first upstream %q", field, firstUpstream) + } + if _, ok := validProviders[name]; !ok { + return fmt.Errorf("%s: %q is not a configured upstream", field, name) + } + } + return nil +} + +func hasEmpty(values []string) bool { + for _, v := range values { + if v == "" { + return true + } + } + return false +} + +func hasDuplicates(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, v := range values { + if _, dup := seen[v]; dup { + return true + } + seen[v] = struct{}{} + } + return false +} + +func cloneRules(in []GroupRule) []GroupRule { + out := make([]GroupRule, len(in)) + for i, r := range in { + out[i] = GroupRule{ + Groups: append([]string(nil), r.Groups...), + UpstreamProviders: append([]string(nil), r.UpstreamProviders...), + } + } + return out +} diff --git a/pkg/authserver/upstreamfilter/filter_test.go b/pkg/authserver/upstreamfilter/filter_test.go new file mode 100644 index 0000000000..d0ce7ab206 --- /dev/null +++ b/pkg/authserver/upstreamfilter/filter_test.go @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package upstreamfilter + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" +) + +// configuredUpstreams used across tests. Index 0 is the mandatory first +// upstream (okta), indices 1+ are the non-first upstreams filters may select. +var testConfigured = []string{"okta", "jira", "confluence", "slack", "salesforce"} + +func TestNewGroupBasedFilter_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg GroupBasedFilterConfig + configured []string + wantErrSubs string // non-empty = expect error containing this substring + }{ + { + name: "fewer than two configured upstreams", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}}, + configured: []string{"okta"}, + wantErrSubs: "at least two configured upstreams", + }, + { + name: "no rules", + cfg: GroupBasedFilterConfig{Rules: nil}, + configured: testConfigured, + wantErrSubs: "at least one rule", + }, + { + name: "rule with empty groups", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{}, UpstreamProviders: []string{"jira"}}}}, + configured: testConfigured, + wantErrSubs: "groups must not be empty", + }, + { + name: "rule with empty string in groups", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"engineering", ""}, UpstreamProviders: []string{"jira"}}}}, + configured: testConfigured, + wantErrSubs: "groups must not contain empty values", + }, + { + name: "rule with duplicate groups", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"eng", "eng"}, UpstreamProviders: []string{"jira"}}}}, + configured: testConfigured, + wantErrSubs: "groups must not contain duplicate values", + }, + { + name: "rule with empty upstreamProviders", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{}}}}, + configured: testConfigured, + wantErrSubs: "upstreamProviders must not be empty", + }, + { + name: "rule with empty string in upstreamProviders", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira", ""}}}}, + configured: testConfigured, + wantErrSubs: "must not contain empty values", + }, + { + name: "rule with duplicate upstreamProviders", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira", "jira"}}}}, + configured: testConfigured, + wantErrSubs: "must not contain duplicate values", + }, + { + name: "rule references unknown upstream", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"unknown"}}}}, + configured: testConfigured, + wantErrSubs: `"unknown" is not a configured upstream`, + }, + { + name: "rule references mandatory first upstream", + cfg: GroupBasedFilterConfig{Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"okta"}}}}, + configured: testConfigured, + wantErrSubs: `must not reference the mandatory first upstream "okta"`, + }, + { + name: "defaultUpstreams references unknown upstream", + cfg: GroupBasedFilterConfig{ + Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}, + DefaultUpstreams: []string{"unknown"}, + }, + configured: testConfigured, + wantErrSubs: `"unknown" is not a configured upstream`, + }, + { + name: "defaultUpstreams references first upstream", + cfg: GroupBasedFilterConfig{ + Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}, + DefaultUpstreams: []string{"okta"}, + }, + configured: testConfigured, + wantErrSubs: `must not reference the mandatory first upstream "okta"`, + }, + { + name: "defaultUpstreams has duplicates", + cfg: GroupBasedFilterConfig{ + Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}, + DefaultUpstreams: []string{"jira", "jira"}, + }, + configured: testConfigured, + wantErrSubs: "must not contain duplicate values", + }, + { + name: "valid config with defaults", + cfg: GroupBasedFilterConfig{ + Claim: "groups", + Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}, + DefaultUpstreams: []string{"confluence"}, + }, + configured: testConfigured, + }, + { + name: "valid config without claim (defaults applied)", + cfg: GroupBasedFilterConfig{ + Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}, + }, + configured: testConfigured, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f, err := NewGroupBasedFilter(tc.cfg, tc.configured) + if tc.wantErrSubs != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSubs) + assert.Nil(t, f) + return + } + require.NoError(t, err) + require.NotNil(t, f) + }) + } +} + +func TestNewGroupBasedFilter_DefaultClaimName(t *testing.T) { + t.Parallel() + + // Empty claim string should default to "groups". + f, err := NewGroupBasedFilter(GroupBasedFilterConfig{ + Rules: []GroupRule{{Groups: []string{"g1"}, UpstreamProviders: []string{"jira"}}}, + }, testConfigured) + require.NoError(t, err) + assert.Equal(t, "groups", f.claim) +} + +func TestNewGroupBasedFilter_InputImmutability(t *testing.T) { + t.Parallel() + + // Caller mutations to the config slices MUST NOT affect the constructed + // filter — the constructor clones defensively. + cfg := GroupBasedFilterConfig{ + Rules: []GroupRule{ + {Groups: []string{"eng"}, UpstreamProviders: []string{"jira"}}, + }, + DefaultUpstreams: []string{"confluence"}, + } + f, err := NewGroupBasedFilter(cfg, testConfigured) + require.NoError(t, err) + + // Mutate caller-owned slices. + cfg.Rules[0].Groups[0] = "mutated" + cfg.Rules[0].UpstreamProviders[0] = "mutated" + cfg.DefaultUpstreams[0] = "mutated" + + // Filter's internal state must be unchanged. + assert.Equal(t, "eng", f.rules[0].Groups[0]) + assert.Equal(t, "jira", f.rules[0].UpstreamProviders[0]) + assert.Equal(t, "confluence", f.defaultUpstreams[0]) +} + +func TestFilterUpstreams(t *testing.T) { + t.Parallel() + + // A canonical config used across most behavior tests. + // configured upstreams: [okta, jira, confluence, slack, salesforce] + validCfg := GroupBasedFilterConfig{ + Claim: "groups", + Rules: []GroupRule{ + {Groups: []string{"engineering"}, UpstreamProviders: []string{"jira", "confluence", "slack"}}, + {Groups: []string{"sales"}, UpstreamProviders: []string{"salesforce", "slack"}}, + {Groups: []string{"admins"}, UpstreamProviders: []string{"jira", "confluence", "slack", "salesforce"}}, + }, + DefaultUpstreams: []string{"jira"}, + } + + tests := []struct { + name string + claims map[string]any + want []string + wantErr bool + errSubs string + }{ + { + name: "nil claims map fails closed", + claims: nil, + wantErr: true, + errSubs: "principal claims are absent", + }, + { + name: "claim not present fails closed", + claims: map[string]any{"email": "a@kt.com"}, + wantErr: true, + errSubs: `claim "groups" not present`, + }, + { + name: "claim value is nil fails closed", + claims: map[string]any{"groups": nil}, + wantErr: true, + errSubs: "nil", + }, + { + name: "claim value is wrong type (string) fails closed", + claims: map[string]any{"groups": "engineering"}, + wantErr: true, + errSubs: "want JSON string array", + }, + { + name: "claim value has non-string element fails closed", + claims: map[string]any{"groups": []any{"engineering", 42}}, + wantErr: true, + errSubs: "want string", + }, + { + name: "empty []string claim falls back to defaults", + claims: map[string]any{"groups": []string{}}, + want: []string{"jira"}, + }, + { + name: "empty []any claim falls back to defaults", + claims: map[string]any{"groups": []any{}}, + want: []string{"jira"}, + }, + { + name: "no matching rule falls back to defaults", + claims: map[string]any{"groups": []any{"finance"}}, + want: []string{"jira"}, + }, + { + name: "single rule match returns that rule's upstreams", + claims: map[string]any{"groups": []any{"engineering"}}, + want: []string{"jira", "confluence", "slack"}, + }, + { + name: "multiple rule match returns union (dedup)", + claims: map[string]any{"groups": []any{"engineering", "sales"}}, + want: []string{"jira", "confluence", "slack", "salesforce"}, + }, + { + name: "admin single rule covers all optional upstreams", + claims: map[string]any{"groups": []any{"admins"}}, + want: []string{"jira", "confluence", "slack", "salesforce"}, + }, + { + name: "typed []string claim works equivalently", + claims: map[string]any{"groups": []string{"engineering", "sales"}}, + want: []string{"jira", "confluence", "slack", "salesforce"}, + }, + { + name: "case-sensitive matching: lowercase does not match capitalized rule group", + claims: map[string]any{"groups": []any{"Engineering"}}, + want: []string{"jira"}, // falls back to defaults + }, + { + name: "unrelated extra groups are ignored", + claims: map[string]any{"groups": []any{"engineering", "random-group", "another"}}, + want: []string{"jira", "confluence", "slack"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f, err := NewGroupBasedFilter(validCfg, testConfigured) + require.NoError(t, err) + + got, err := f.FilterUpstreams( + context.Background(), + auth.PrincipalInfo{Claims: tc.claims}, + testConfigured[1:], + ) + if tc.wantErr { + require.Error(t, err) + if tc.errSubs != "" { + assert.Contains(t, err.Error(), tc.errSubs) + } + assert.Nil(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestFilterUpstreams_MultiGroupRuleMatchesAnyGroup pins the any-of (OR) +// semantics of a rule listing several groups: each group selects the rule on +// its own. Every matching rule in TestFilterUpstreams carries a single group, +// so none of them tells OR from AND. DefaultUpstreams is deliberately a +// different set here, so a case that fell through to the fallback could not +// be mistaken for a match. Issue #6675 leaves the preferred semantic open; +// this test is what a move to all-of would have to update. +func TestFilterUpstreams_MultiGroupRuleMatchesAnyGroup(t *testing.T) { + t.Parallel() + + cfg := GroupBasedFilterConfig{ + Rules: []GroupRule{ + {Groups: []string{"engineering", "production"}, UpstreamProviders: []string{"jira", "slack"}}, + }, + DefaultUpstreams: []string{"confluence"}, + } + + tests := []struct { + name string + groups []any + want []string + }{ + {name: "first group alone matches", groups: []any{"engineering"}, want: []string{"jira", "slack"}}, + {name: "second group alone matches", groups: []any{"production"}, want: []string{"jira", "slack"}}, + {name: "both groups match once", groups: []any{"engineering", "production"}, want: []string{"jira", "slack"}}, + {name: "unrelated group falls back to defaults", groups: []any{"sales"}, want: []string{"confluence"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f, err := NewGroupBasedFilter(cfg, testConfigured) + require.NoError(t, err) + + got, err := f.FilterUpstreams( + context.Background(), + auth.PrincipalInfo{Claims: map[string]any{"groups": tc.groups}}, + testConfigured[1:], + ) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFilterUpstreams_CustomClaimName(t *testing.T) { + t.Parallel() + + cfg := GroupBasedFilterConfig{ + Claim: "cognito:groups", + Rules: []GroupRule{ + {Groups: []string{"engineering"}, UpstreamProviders: []string{"jira"}}, + }, + } + f, err := NewGroupBasedFilter(cfg, testConfigured) + require.NoError(t, err) + + got, err := f.FilterUpstreams( + context.Background(), + auth.PrincipalInfo{Claims: map[string]any{"cognito:groups": []any{"engineering"}}}, + testConfigured[1:], + ) + require.NoError(t, err) + assert.Equal(t, []string{"jira"}, got) +} + +func TestFilterUpstreams_EmptyDefaultsReturnsEmpty(t *testing.T) { + t.Parallel() + + // When DefaultUpstreams is omitted and no rule matches, FilterUpstreams + // should return an empty slice (so only the mandatory first upstream remains). + cfg := GroupBasedFilterConfig{ + Rules: []GroupRule{ + {Groups: []string{"engineering"}, UpstreamProviders: []string{"jira"}}, + }, + } + f, err := NewGroupBasedFilter(cfg, testConfigured) + require.NoError(t, err) + + got, err := f.FilterUpstreams( + context.Background(), + auth.PrincipalInfo{Claims: map[string]any{"groups": []any{"finance"}}}, + testConfigured[1:], + ) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestFilterUpstreams_DoesNotMutatePrincipalClaims(t *testing.T) { + t.Parallel() + + // The filter contract requires principal (including Claims) to be + // read-only. Verify we don't mutate the claim slice. + cfg := GroupBasedFilterConfig{ + Rules: []GroupRule{ + {Groups: []string{"engineering"}, UpstreamProviders: []string{"jira"}}, + }, + } + f, err := NewGroupBasedFilter(cfg, testConfigured) + require.NoError(t, err) + + originalGroups := []any{"engineering", "sales"} + claims := map[string]any{"groups": originalGroups} + + _, err = f.FilterUpstreams(context.Background(), auth.PrincipalInfo{Claims: claims}, testConfigured[1:]) + require.NoError(t, err) + + // The underlying slice must be unchanged. + assert.Equal(t, []any{"engineering", "sales"}, originalGroups) +} diff --git a/sdk/go/client/client_test.go b/sdk/go/client/client_test.go index 6e1ee77ea0..fc1bce4dcb 100644 --- a/sdk/go/client/client_test.go +++ b/sdk/go/client/client_test.go @@ -222,7 +222,7 @@ func TestGeneratedInvokerMatchesOpenAPIOperations(t *testing.T) { Paths map[string]map[string]json.RawMessage `json:"paths"` } require.NoError(t, json.Unmarshal(content, &document)) - require.Len(t, document.Components.Schemas, 198, "update this deliberate contract count when publishing a new schema") + require.Len(t, document.Components.Schemas, 200, "update this deliberate contract count when publishing a new schema") for name := range document.Components.Schemas { require.NotContains(t, name, ".", "schema names are public SDK contract names") } diff --git a/sdk/go/client/oas_json_gen.go b/sdk/go/client/oas_json_gen.go index bb25a9ad19..6b9bf3f2f0 100644 --- a/sdk/go/client/oas_json_gen.go +++ b/sdk/go/client/oas_json_gen.go @@ -2788,6 +2788,12 @@ func (s *AuthenticationRunConfig) encodeFields(e *jx.Encoder) { e.ArrEnd() } } + { + if s.UpstreamFilter.Set { + e.FieldStart("upstream_filter") + s.UpstreamFilter.Encode(e) + } + } { if s.Upstreams != nil { e.FieldStart("upstreams") @@ -2800,7 +2806,7 @@ func (s *AuthenticationRunConfig) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfAuthenticationRunConfig = [24]string{ +var jsonFieldsNameOfAuthenticationRunConfig = [25]string{ 0: "allow_confidential_client_registration", 1: "allow_private_key_jwt_registration", 2: "allowed_audiences", @@ -2824,7 +2830,8 @@ var jsonFieldsNameOfAuthenticationRunConfig = [24]string{ 20: "storage", 21: "token_lifespans", 22: "trusted_issuers", - 23: "upstreams", + 23: "upstream_filter", + 24: "upstreams", } // Decode decodes AuthenticationRunConfig from json. @@ -3131,6 +3138,16 @@ func (s *AuthenticationRunConfig) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"trusted_issuers\"") } + case "upstream_filter": + if err := func() error { + s.UpstreamFilter.Reset() + if err := s.UpstreamFilter.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"upstream_filter\"") + } case "upstreams": if err := func() error { s.Upstreams = make([]AuthenticationUpstreamRunConfig, 0) @@ -20654,6 +20671,39 @@ func (s *OptUpgradeRequestEnv) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes UpstreamfilterGroupBasedFilterConfig as json. +func (o OptUpstreamfilterGroupBasedFilterConfig) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes UpstreamfilterGroupBasedFilterConfig from json. +func (o *OptUpstreamfilterGroupBasedFilterConfig) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptUpstreamfilterGroupBasedFilterConfig to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptUpstreamfilterGroupBasedFilterConfig) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptUpstreamfilterGroupBasedFilterConfig) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes V0ServerMeta as json. func (o OptV0ServerMeta) Encode(e *jx.Encoder) { if !o.Set { @@ -41214,6 +41264,233 @@ func (s *UpgradeWorkloadUnprocessableEntityApplicationJSON) UnmarshalJSON(data [ return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *UpstreamfilterGroupBasedFilterConfig) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *UpstreamfilterGroupBasedFilterConfig) encodeFields(e *jx.Encoder) { + { + if s.Claim.Set { + e.FieldStart("claim") + s.Claim.Encode(e) + } + } + { + if s.DefaultUpstreams != nil { + e.FieldStart("defaultUpstreams") + e.ArrStart() + for _, elem := range s.DefaultUpstreams { + e.Str(elem) + } + e.ArrEnd() + } + } + { + if s.Rules != nil { + e.FieldStart("rules") + e.ArrStart() + for _, elem := range s.Rules { + elem.Encode(e) + } + e.ArrEnd() + } + } +} + +var jsonFieldsNameOfUpstreamfilterGroupBasedFilterConfig = [3]string{ + 0: "claim", + 1: "defaultUpstreams", + 2: "rules", +} + +// Decode decodes UpstreamfilterGroupBasedFilterConfig from json. +func (s *UpstreamfilterGroupBasedFilterConfig) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode UpstreamfilterGroupBasedFilterConfig to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "claim": + if err := func() error { + s.Claim.Reset() + if err := s.Claim.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"claim\"") + } + case "defaultUpstreams": + if err := func() error { + s.DefaultUpstreams = make([]string, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem string + v, err := d.Str() + elem = string(v) + if err != nil { + return err + } + s.DefaultUpstreams = append(s.DefaultUpstreams, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"defaultUpstreams\"") + } + case "rules": + if err := func() error { + s.Rules = make([]UpstreamfilterGroupRule, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem UpstreamfilterGroupRule + if err := elem.Decode(d); err != nil { + return err + } + s.Rules = append(s.Rules, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"rules\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode UpstreamfilterGroupBasedFilterConfig") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *UpstreamfilterGroupBasedFilterConfig) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *UpstreamfilterGroupBasedFilterConfig) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *UpstreamfilterGroupRule) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *UpstreamfilterGroupRule) encodeFields(e *jx.Encoder) { + { + if s.Groups != nil { + e.FieldStart("groups") + e.ArrStart() + for _, elem := range s.Groups { + e.Str(elem) + } + e.ArrEnd() + } + } + { + if s.UpstreamProviders != nil { + e.FieldStart("upstreamProviders") + e.ArrStart() + for _, elem := range s.UpstreamProviders { + e.Str(elem) + } + e.ArrEnd() + } + } +} + +var jsonFieldsNameOfUpstreamfilterGroupRule = [2]string{ + 0: "groups", + 1: "upstreamProviders", +} + +// Decode decodes UpstreamfilterGroupRule from json. +func (s *UpstreamfilterGroupRule) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode UpstreamfilterGroupRule to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "groups": + if err := func() error { + s.Groups = make([]string, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem string + v, err := d.Str() + elem = string(v) + if err != nil { + return err + } + s.Groups = append(s.Groups, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"groups\"") + } + case "upstreamProviders": + if err := func() error { + s.UpstreamProviders = make([]string, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem string + v, err := d.Str() + elem = string(v) + if err != nil { + return err + } + s.UpstreamProviders = append(s.UpstreamProviders, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"upstreamProviders\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode UpstreamfilterGroupRule") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *UpstreamfilterGroupRule) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *UpstreamfilterGroupRule) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *V0ServerJSON) Encode(e *jx.Encoder) { e.ObjStart() diff --git a/sdk/go/client/oas_schemas_gen.go b/sdk/go/client/oas_schemas_gen.go index 450a08d739..f8069c509d 100644 --- a/sdk/go/client/oas_schemas_gen.go +++ b/sdk/go/client/oas_schemas_gen.go @@ -1563,7 +1563,8 @@ type AuthenticationRunConfig struct { // signals, and operator-facing constraints (audience/scope bounding, // subject namespace qualification, required client binding) that aren't // visible from the config shape alone. - TrustedIssuers []TokenExchangeTrustedIssuer `json:"trusted_issuers"` + TrustedIssuers []TokenExchangeTrustedIssuer `json:"trusted_issuers"` + UpstreamFilter OptUpstreamfilterGroupBasedFilterConfig `json:"upstream_filter"` // Upstreams configures connections to upstream Identity Providers for // interactive authorization. It may be empty only when DelegateClients or a // TrustedIssuer with JWTBearerGrant enables token-only operation. @@ -1686,6 +1687,11 @@ func (s *AuthenticationRunConfig) GetTrustedIssuers() []TokenExchangeTrustedIssu return s.TrustedIssuers } +// GetUpstreamFilter returns the value of UpstreamFilter. +func (s *AuthenticationRunConfig) GetUpstreamFilter() OptUpstreamfilterGroupBasedFilterConfig { + return s.UpstreamFilter +} + // GetUpstreams returns the value of Upstreams. func (s *AuthenticationRunConfig) GetUpstreams() []AuthenticationUpstreamRunConfig { return s.Upstreams @@ -1806,6 +1812,11 @@ func (s *AuthenticationRunConfig) SetTrustedIssuers(val []TokenExchangeTrustedIs s.TrustedIssuers = val } +// SetUpstreamFilter sets the value of UpstreamFilter. +func (s *AuthenticationRunConfig) SetUpstreamFilter(val OptUpstreamfilterGroupBasedFilterConfig) { + s.UpstreamFilter = val +} + // SetUpstreams sets the value of Upstreams. func (s *AuthenticationRunConfig) SetUpstreams(val []AuthenticationUpstreamRunConfig) { s.Upstreams = val @@ -12468,6 +12479,52 @@ func (o OptUpgradeRequestEnv) Or(d UpgradeRequestEnv) UpgradeRequestEnv { return d } +// NewOptUpstreamfilterGroupBasedFilterConfig returns new OptUpstreamfilterGroupBasedFilterConfig with value set to v. +func NewOptUpstreamfilterGroupBasedFilterConfig(v UpstreamfilterGroupBasedFilterConfig) OptUpstreamfilterGroupBasedFilterConfig { + return OptUpstreamfilterGroupBasedFilterConfig{ + Value: v, + Set: true, + } +} + +// OptUpstreamfilterGroupBasedFilterConfig is optional UpstreamfilterGroupBasedFilterConfig. +type OptUpstreamfilterGroupBasedFilterConfig struct { + Value UpstreamfilterGroupBasedFilterConfig + Set bool +} + +// IsSet returns true if OptUpstreamfilterGroupBasedFilterConfig was set. +func (o OptUpstreamfilterGroupBasedFilterConfig) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptUpstreamfilterGroupBasedFilterConfig) Reset() { + var v UpstreamfilterGroupBasedFilterConfig + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptUpstreamfilterGroupBasedFilterConfig) SetTo(v UpstreamfilterGroupBasedFilterConfig) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptUpstreamfilterGroupBasedFilterConfig) Get() (v UpstreamfilterGroupBasedFilterConfig, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptUpstreamfilterGroupBasedFilterConfig) Or(d UpstreamfilterGroupBasedFilterConfig) UpstreamfilterGroupBasedFilterConfig { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptV0ServerMeta returns new OptV0ServerMeta with value set to v. func NewOptV0ServerMeta(v V0ServerMeta) OptV0ServerMeta { return OptV0ServerMeta{ @@ -21939,6 +21996,108 @@ type UpgradeWorkloadUnprocessableEntityApplicationJSON string func (*UpgradeWorkloadUnprocessableEntityApplicationJSON) upgradeWorkloadRes() {} +// UpstreamFilter configures an optional group-based filter that narrows the +// multi-upstream authorization chain per-request based on the first +// upstream's resolved identity. When nil, every request walks the full +// upstream chain in configured order (preserving behavior from before the +// filter hook existed). +// The filter is applied exactly once per authorization, on the first leg's +// callback. The mandatory first upstream (Upstreams[0]) is never filtered +// out. See pkg/authserver/upstreamfilter for the configuration schema and +// pkg/authserver/server/handlers for the runtime contract. +// Ref: #/components/schemas/UpstreamfilterGroupBasedFilterConfig +type UpstreamfilterGroupBasedFilterConfig struct { + // Claim is the exact, top-level JWT/OIDC claim name to read group + // membership from. Defaults to "groups" when empty. The claim value MUST be + // a JSON array of strings; other shapes (missing, non-array, mixed types) + // fail the authorization. Nested claims (e.g. Keycloak's realm_access.roles) + // must be flattened by the identity provider — nested paths are not + // supported in this implementation. + // The value is read from upstream.Identity.Claims as the first upstream + // resolves it: an OIDC upstream fills that map from the validated ID + // token, a pure OAuth2 upstream from the userinfo response. The + // identityFromToken and synthesized-identity paths leave it empty, and + // groups carried only in an access token never reach it. A missing claim + // fails the authorization — DefaultUpstreams covers a valid-but-empty + // claim and unmatched groups, not an absent one. + Claim OptString `json:"claim"` + // DefaultUpstreams is used when the claim is a valid empty array or when no + // rule matches. When empty or omitted, no optional upstreams are added — + // the mandatory first upstream remains but the authorization walks no + // further. + DefaultUpstreams []string `json:"defaultUpstreams"` + // Rules assigns groups to upstream provider subsets. A principal matches a + // rule if any of the principal's group values appears in the rule's Groups + // list (any-of / OR semantics). Multiple matching rules are combined by + // union: the effective upstream chain contains every UpstreamProviders + // value from every matching rule, deduplicated. + Rules []UpstreamfilterGroupRule `json:"rules"` +} + +// GetClaim returns the value of Claim. +func (s *UpstreamfilterGroupBasedFilterConfig) GetClaim() OptString { + return s.Claim +} + +// GetDefaultUpstreams returns the value of DefaultUpstreams. +func (s *UpstreamfilterGroupBasedFilterConfig) GetDefaultUpstreams() []string { + return s.DefaultUpstreams +} + +// GetRules returns the value of Rules. +func (s *UpstreamfilterGroupBasedFilterConfig) GetRules() []UpstreamfilterGroupRule { + return s.Rules +} + +// SetClaim sets the value of Claim. +func (s *UpstreamfilterGroupBasedFilterConfig) SetClaim(val OptString) { + s.Claim = val +} + +// SetDefaultUpstreams sets the value of DefaultUpstreams. +func (s *UpstreamfilterGroupBasedFilterConfig) SetDefaultUpstreams(val []string) { + s.DefaultUpstreams = val +} + +// SetRules sets the value of Rules. +func (s *UpstreamfilterGroupBasedFilterConfig) SetRules(val []UpstreamfilterGroupRule) { + s.Rules = val +} + +// Ref: #/components/schemas/UpstreamfilterGroupRule +type UpstreamfilterGroupRule struct { + // Groups is the set of principal group names this rule matches. Matching + // is case-sensitive and exact. Empty and duplicate values are rejected at + // configuration validation time. + Groups []string `json:"groups"` + // UpstreamProviders lists the non-first upstream provider names to include + // in the chain when this rule matches. All names must reference a + // configured non-first upstream. Empty and duplicate values, references to + // the mandatory first upstream, and references to undeclared upstreams are + // rejected at configuration validation time. + UpstreamProviders []string `json:"upstreamProviders"` +} + +// GetGroups returns the value of Groups. +func (s *UpstreamfilterGroupRule) GetGroups() []string { + return s.Groups +} + +// GetUpstreamProviders returns the value of UpstreamProviders. +func (s *UpstreamfilterGroupRule) GetUpstreamProviders() []string { + return s.UpstreamProviders +} + +// SetGroups sets the value of Groups. +func (s *UpstreamfilterGroupRule) SetGroups(val []string) { + s.Groups = val +} + +// SetUpstreamProviders sets the value of UpstreamProviders. +func (s *UpstreamfilterGroupRule) SetUpstreamProviders(val []string) { + s.UpstreamProviders = val +} + // Ref: #/components/schemas/V0ServerJSON type V0ServerJSON struct { Schema OptURI `json:"$schema"` diff --git a/sdk/go/openapi.json b/sdk/go/openapi.json index de422ba3bb..9f0ab01079 100644 --- a/sdk/go/openapi.json +++ b/sdk/go/openapi.json @@ -623,6 +623,9 @@ "type": "array", "uniqueItems": false }, + "upstream_filter": { + "$ref": "#/components/schemas/UpstreamfilterGroupBasedFilterConfig" + }, "upstreams": { "description": "Upstreams configures connections to upstream Identity Providers for\ninteractive authorization. It may be empty only when DelegateClients or a\nTrustedIssuer with JWTBearerGrant enables token-only operation.\nMultiple upstreams are supported for sequential authorization chains.", "items": { @@ -5551,6 +5554,53 @@ }, "type": "object" }, + "UpstreamfilterGroupBasedFilterConfig": { + "description": "UpstreamFilter configures an optional group-based filter that narrows the\nmulti-upstream authorization chain per-request based on the first\nupstream's resolved identity. When nil, every request walks the full\nupstream chain in configured order (preserving behavior from before the\nfilter hook existed).\n\nThe filter is applied exactly once per authorization, on the first leg's\ncallback. The mandatory first upstream (Upstreams[0]) is never filtered\nout. See pkg/authserver/upstreamfilter for the configuration schema and\npkg/authserver/server/handlers for the runtime contract.", + "properties": { + "claim": { + "description": "Claim is the exact, top-level JWT/OIDC claim name to read group\nmembership from. Defaults to \"groups\" when empty. The claim value MUST be\na JSON array of strings; other shapes (missing, non-array, mixed types)\nfail the authorization. Nested claims (e.g. Keycloak's realm_access.roles)\nmust be flattened by the identity provider — nested paths are not\nsupported in this implementation.\n\nThe value is read from upstream.Identity.Claims as the first upstream\nresolves it: an OIDC upstream fills that map from the validated ID\ntoken, a pure OAuth2 upstream from the userinfo response. The\nidentityFromToken and synthesized-identity paths leave it empty, and\ngroups carried only in an access token never reach it. A missing claim\nfails the authorization — DefaultUpstreams covers a valid-but-empty\nclaim and unmatched groups, not an absent one.", + "type": "string" + }, + "defaultUpstreams": { + "description": "DefaultUpstreams is used when the claim is a valid empty array or when no\nrule matches. When empty or omitted, no optional upstreams are added —\nthe mandatory first upstream remains but the authorization walks no\nfurther.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "rules": { + "description": "Rules assigns groups to upstream provider subsets. A principal matches a\nrule if any of the principal's group values appears in the rule's Groups\nlist (any-of / OR semantics). Multiple matching rules are combined by\nunion: the effective upstream chain contains every UpstreamProviders\nvalue from every matching rule, deduplicated.", + "items": { + "$ref": "#/components/schemas/UpstreamfilterGroupRule" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "UpstreamfilterGroupRule": { + "properties": { + "groups": { + "description": "Groups is the set of principal group names this rule matches. Matching\nis case-sensitive and exact. Empty and duplicate values are rejected at\nconfiguration validation time.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "upstreamProviders": { + "description": "UpstreamProviders lists the non-first upstream provider names to include\nin the chain when this rule matches. All names must reference a\nconfigured non-first upstream. Empty and duplicate values, references to\nthe mandatory first upstream, and references to undeclared upstreams are\nrejected at configuration validation time.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "V0ServerJSON": { "properties": { "$schema": { diff --git a/sdk/go/openapi.yaml b/sdk/go/openapi.yaml index 8286843fe1..ed26a7c350 100644 --- a/sdk/go/openapi.yaml +++ b/sdk/go/openapi.yaml @@ -815,6 +815,8 @@ components: $ref: '#/components/schemas/TokenExchangeTrustedIssuer' type: array uniqueItems: false + upstream_filter: + $ref: '#/components/schemas/UpstreamfilterGroupBasedFilterConfig' upstreams: description: |- Upstreams configures connections to upstream Identity Providers for @@ -5172,6 +5174,81 @@ components: description: PublicKey proposes a cosign public key as the replacement trust anchor. type: string type: object + UpstreamfilterGroupBasedFilterConfig: + description: |- + UpstreamFilter configures an optional group-based filter that narrows the + multi-upstream authorization chain per-request based on the first + upstream's resolved identity. When nil, every request walks the full + upstream chain in configured order (preserving behavior from before the + filter hook existed). + + The filter is applied exactly once per authorization, on the first leg's + callback. The mandatory first upstream (Upstreams[0]) is never filtered + out. See pkg/authserver/upstreamfilter for the configuration schema and + pkg/authserver/server/handlers for the runtime contract. + properties: + claim: + description: |- + Claim is the exact, top-level JWT/OIDC claim name to read group + membership from. Defaults to "groups" when empty. The claim value MUST be + a JSON array of strings; other shapes (missing, non-array, mixed types) + fail the authorization. Nested claims (e.g. Keycloak's realm_access.roles) + must be flattened by the identity provider — nested paths are not + supported in this implementation. + + The value is read from upstream.Identity.Claims as the first upstream + resolves it: an OIDC upstream fills that map from the validated ID + token, a pure OAuth2 upstream from the userinfo response. The + identityFromToken and synthesized-identity paths leave it empty, and + groups carried only in an access token never reach it. A missing claim + fails the authorization — DefaultUpstreams covers a valid-but-empty + claim and unmatched groups, not an absent one. + type: string + defaultUpstreams: + description: |- + DefaultUpstreams is used when the claim is a valid empty array or when no + rule matches. When empty or omitted, no optional upstreams are added — + the mandatory first upstream remains but the authorization walks no + further. + items: + type: string + type: array + uniqueItems: false + rules: + description: |- + Rules assigns groups to upstream provider subsets. A principal matches a + rule if any of the principal's group values appears in the rule's Groups + list (any-of / OR semantics). Multiple matching rules are combined by + union: the effective upstream chain contains every UpstreamProviders + value from every matching rule, deduplicated. + items: + $ref: '#/components/schemas/UpstreamfilterGroupRule' + type: array + uniqueItems: false + type: object + UpstreamfilterGroupRule: + properties: + groups: + description: |- + Groups is the set of principal group names this rule matches. Matching + is case-sensitive and exact. Empty and duplicate values are rejected at + configuration validation time. + items: + type: string + type: array + uniqueItems: false + upstreamProviders: + description: |- + UpstreamProviders lists the non-first upstream provider names to include + in the chain when this rule matches. All names must reference a + configured non-first upstream. Empty and duplicate values, references to + the mandatory first upstream, and references to undeclared upstreams are + rejected at configuration validation time. + items: + type: string + type: array + uniqueItems: false + type: object V0ServerJSON: properties: $schema: