diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index cfbef65e01..246df8759e 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -586,8 +586,220 @@ type JWTBearerSubjectBinding struct { AllowedResources []string `json:"allowedResources"` } +// SPIFFEAuthenticationMethod identifies the credential type permitted for a +// SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; +// runtime parsing remains authoritative. +type SPIFFEAuthenticationMethod string + +// SPIFFEBundleSourceType identifies the selected trust-bundle source. Mirrors +// authserver.SPIFFEBundleSourceType. +type SPIFFEBundleSourceType string + +// SPIFFEBundleEndpointProfile identifies how a SPIFFE Bundle Endpoint's TLS +// connection is authenticated. Mirrors authserver.SPIFFEBundleEndpointProfile. +type SPIFFEBundleEndpointProfile string + +const ( + // SPIFFEAuthenticationMethodX509 authenticates a workload with an X.509-SVID. + SPIFFEAuthenticationMethodX509 SPIFFEAuthenticationMethod = "spiffe_x509" + // SPIFFEAuthenticationMethodJWT authenticates a workload with a JWT-SVID. + SPIFFEAuthenticationMethodJWT SPIFFEAuthenticationMethod = "spiffe_jwt" + + // SPIFFEBundleSourceTypeEndpoint selects a HTTPS SPIFFE Bundle Endpoint. + SPIFFEBundleSourceTypeEndpoint SPIFFEBundleSourceType = "bundle_endpoint" + // SPIFFEBundleSourceTypeWorkloadAPI selects the local SPIFFE Workload API. + SPIFFEBundleSourceTypeWorkloadAPI SPIFFEBundleSourceType = "workload_api" + + // SPIFFEBundleEndpointProfileHTTPSWeb authenticates the bundle endpoint's + // TLS connection with a Web PKI certificate. + SPIFFEBundleEndpointProfileHTTPSWeb SPIFFEBundleEndpointProfile = "https_web" + // SPIFFEBundleEndpointProfileHTTPSSPIFFE authenticates the bundle + // endpoint's TLS connection with a separately distributed X.509-SVID root. + SPIFFEBundleEndpointProfileHTTPSSPIFFE SPIFFEBundleEndpointProfile = "https_spiffe" +) + +// SPIFFEBundleSourceConfig is a discriminated bundle-source declaration. Type +// determines which, and only which, source payload may be set. It is +// validated for shape only; fetching or loading a bundle from the declared +// source is not implemented yet. +// +// +kubebuilder:validation:XValidation:rule="self.type == 'bundle_endpoint' ? has(self.endpoint) : !has(self.endpoint)",message="endpoint configuration must be set if and only if type is 'bundle_endpoint'" +// +kubebuilder:validation:XValidation:rule="self.type == 'workload_api' ? has(self.workloadAPI) : !has(self.workloadAPI)",message="workloadAPI configuration must be set if and only if type is 'workload_api'" +// +//nolint:lll // CEL validation rules exceed line length limit +type SPIFFEBundleSourceConfig struct { + // Type selects the trust-bundle source. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=bundle_endpoint;workload_api + Type SPIFFEBundleSourceType `json:"type"` + + // Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + // "bundle_endpoint". + // +optional + Endpoint *SPIFFEBundleEndpointSourceConfig `json:"endpoint,omitempty"` + + // WorkloadAPI selects the local SPIFFE Workload API. Required when Type + // is "workload_api". + // +optional + WorkloadAPI *SPIFFEWorkloadAPIBundleSourceConfig `json:"workloadAPI,omitempty"` +} + +// SPIFFEBundleEndpointSourceConfig declares a HTTPS SPIFFE Bundle Endpoint. +type SPIFFEBundleEndpointSourceConfig struct { + // URL is the HTTPS SPIFFE Bundle Endpoint URL. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + URL string `json:"url"` + + // Profile selects how the endpoint's TLS connection is authenticated: + // SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + // SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + // X.509-SVID root). + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=https_web;https_spiffe + Profile SPIFFEBundleEndpointProfile `json:"profile"` +} + +// SPIFFEWorkloadAPIBundleSourceConfig selects the local SPIFFE Workload API. +// It deliberately has no payload; loading and deployment details are +// deferred to the bundle-loading implementation. +type SPIFFEWorkloadAPIBundleSourceConfig struct{} + +// SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the +// embedded authorization server. Configuration is not authentication: no +// live X.509-SVID or JWT-SVID validation exists yet, so a declared trust +// domain does not by itself let any workload authenticate — RunConfig.Validate +// (pkg/authserver/config.go) currently hard-rejects any non-empty +// spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, +// a deliberate placeholder until real SVID verification lands. +type SPIFFETrustDomainConfig struct { + // Name uniquely identifies this declaration and is referenced by + // inboundGrants.spiffeClientAuth[].trustDomainRef. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + + // TrustDomain is the SPIFFE trust domain accepted by this declaration. + // This pattern is a best-effort CRD-level approximation of the SPIFFE + // trust-domain grammar; runtime parsing via + // spiffeid.TrustDomainFromString remains authoritative. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:Pattern=`^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$` + TrustDomain string `json:"trustDomain"` + + // Methods explicitly enables the supported credential types for this + // trust domain. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=2 + // +kubebuilder:validation:items:Enum=spiffe_x509;spiffe_jwt + // +listType=set + Methods []SPIFFEAuthenticationMethod `json:"methods"` + + // BundleSource declares exactly one future trust-bundle source. It is + // validated for shape only; fetching or loading a bundle from it is a + // later step. + // +kubebuilder:validation:Required + BundleSource SPIFFEBundleSourceConfig `json:"bundleSource"` +} + +// SPIFFEClientConfig associates one SPIFFE principal pattern from a declared +// trust domain with an explicit OAuth client identity and permissions. +// Configuration is not authentication: configured SPIFFE clients remain +// non-public OAuth clients without a secret until live SPIFFE credential +// validation is implemented (see SPIFFETrustDomainConfig's doc comment). +// +// GrantTypes is deliberately not exposed here: the runtime only accepts +// exactly the RFC 8693 token-exchange grant for a SPIFFE client +// (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter +// always supplies it instead of letting it be configured. +// +// +kubebuilder:validation:XValidation:rule="self.principalPattern.split('/').all(segment, segment != '.' && segment != '..')",message="principalPattern path must not contain . or .. segments" +// +//nolint:lll // CEL validation rule exceeds line length limit +type SPIFFEClientConfig struct { + // TrustDomainRef references spiffeTrustDomains[].name. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + TrustDomainRef string `json:"trustDomainRef"` + + // PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + // the declared trust domain. This pattern is a best-effort CRD-level + // approximation; runtime parsing via spiffeid.FromString remains + // authoritative. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=2048 + // +kubebuilder:validation:Pattern=`^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$` + PrincipalPattern string `json:"principalPattern"` + + // ClientID is the explicit OAuth client_id. It is never derived from a + // SPIFFE ID. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + ClientID string `json:"clientId"` + + // Methods are the credential types this association may authenticate + // with. Must be a subset of the referenced trust domain's methods. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=2 + // +kubebuilder:validation:items:Enum=spiffe_x509;spiffe_jwt + // +listType=set + Methods []SPIFFEAuthenticationMethod `json:"methods"` + + // Resources are RFC 8707 resource indicators this association may + // request. Must be a subset of the server's allowed_audiences allowlist, + // which is derived at reconcile time and not available on this CRD, so + // allowlist membership is validated at reconcile time, not admission. + // Shape (a well-formed absolute HTTP(S) URI) is independent of that + // derived allowlist and is validated here. Distinct from Audiences: a + // resource permission does not imply the same value is also a permitted + // token audience, or vice versa. + // +kubebuilder:validation:MaxItems=50 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=2048 + // +kubebuilder:validation:items:Pattern=`^https?://[^@#[:space:]]+$` + // +listType=set + // +optional + Resources []string `json:"resources,omitempty"` + + // Audiences are RFC 8693 token audiences this association may request. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=50 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=2048 + // +listType=set + Audiences []string `json:"audiences"` + + // Scopes are OAuth scopes granted to this association. Must be a subset + // of the server's effective supported scopes. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=50 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=256 + // +listType=set + Scopes []string `json:"scopes"` +} + // InboundGrantsConfig groups canonical inbound OAuth grant-family configuration. type InboundGrantsConfig struct { + // SPIFFEClientAuth associates SPIFFE principal patterns with explicit + // OAuth client identities and permissions. A sibling of TokenExchange and + // JWTBearer below, not nested under either: client authentication does + // not by itself confer a grant. See SPIFFEClientConfig. + // +kubebuilder:validation:MaxItems=100 + // +listType=atomic + // +optional + SPIFFEClientAuth []SPIFFEClientConfig `json:"spiffeClientAuth,omitempty"` + // TokenExchange configures RFC 8693 clients and issuer policies. // +optional TokenExchange *TokenExchangeInboundGrantConfig `json:"tokenExchange,omitempty"` @@ -685,7 +897,19 @@ type JWTBearerIssuerPolicyConfig struct { // delegate clients using an HTTP issuer; the shared Go validator performs the // precise loopback-host security check. // -// +kubebuilder:validation:XValidation:rule="(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer)))",message="at least one upstream provider or inbound grant family is required" +// +kubebuilder:validation:XValidation:rule="(has(self.upstreamProviders) && size(self.upstreamProviders) > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) && size(self.inboundGrants.spiffeClientAuth) > 0)))",message="at least one upstream provider or inbound grant family is required" +// +// +kubebuilder:validation:XValidation:rule="((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) && size(self.inboundGrants.spiffeClientAuth) > 0))",message="spiffeTrustDomains and inboundGrants.spiffeClientAuth must be configured together" +// +kubebuilder:validation:XValidation:rule="!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, self.spiffeTrustDomains.filter(other, other.name == domain.name).size() == 1)",message="spiffeTrustDomains must not contain duplicate names" +// +kubebuilder:validation:XValidation:rule="!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() == 1)",message="spiffeTrustDomains must not contain duplicate trust domains" +// +kubebuilder:validation:XValidation:rule="!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef == domain.name))",message="every SPIFFE trust domain must be referenced by a SPIFFE client" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))",message="every SPIFFE client trustDomainRef must reference a declared trust domain" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef && client.methods.all(method, method in domain.methods)))",message="spiffeClientAuth methods must be enabled by the referenced trust domain" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef && client.principalPattern.startsWith('spiffe://' + domain.trustDomain + '/')))",message="spiffeClientAuth principalPattern trust domain must match the referenced trust domain" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.clientId == client.clientId).size() == 1)",message="spiffeClientAuth must not contain duplicate client IDs" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, other.principalPattern == client.principalPattern).size() == 1)",message="spiffeClientAuth must not contain duplicate principal patterns" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith('synthetic:'))",message="spiffeClientAuth clientId must not use the reserved synthetic: prefix" +// +kubebuilder:validation:XValidation:rule="!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches('^[A-Za-z][A-Za-z0-9+.-]*://.+'))",message="spiffeClientAuth clientId must not be an absolute URL" // // +kubebuilder:validation:XValidation:rule="!(has(self.insecureAllowHTTP) && self.insecureAllowHTTP && ((has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.inboundGrants) && has(self.inboundGrants.tokenExchange) && has(self.inboundGrants.tokenExchange.delegateClients) && size(self.inboundGrants.tokenExchange.delegateClients) > 0)))",message="insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint" // +kubebuilder:validation:XValidation:rule="(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)",message="forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true" @@ -750,6 +974,16 @@ type EmbeddedAuthServerConfig struct { // +optional TokenLifespans *TokenLifespanConfig `json:"tokenLifespans,omitempty"` + // SPIFFETrustDomains declares SPIFFE trust domains for + // inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + // doc comment for why declaring a domain does not by itself enable + // authentication in this build. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=50 + // +listType=atomic + // +optional + SPIFFETrustDomains []SPIFFETrustDomainConfig `json:"spiffeTrustDomains,omitempty"` + // InboundGrants configures canonical inbound OAuth grant families. // +optional InboundGrants *InboundGrantsConfig `json:"inboundGrants,omitempty"` @@ -2329,18 +2563,29 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { // same reasoning as ValidateConfidentialClientTransport). The // https-non-loopback-per-entry check has no CEL equivalent here since it // needs the loopback-hostname helper, so it lives only in Go. + // + // The permission-shaped half of SPIFFE trust/client-auth config (grant + // types, scopes, resources, audiences) has no equivalent Go-level + // pre-check here, matching the DelegateClients precedent below — a + // meaningful check needs AllowedAudiences/ScopesSupported, which are only + // known once derived at reconcile time. controllerutil. + // validateDelegateClientsAndTrustedIssuers revalidates the full SPIFFE + // trust config (via RunConfig.Validate) once those derived values exist. + // + // The two checks below are admission-time-safe: they depend only on this + // object's own spec, so they run here instead of waiting for reconcile. + if err := validateSPIFFEBundleEndpoints(cfg.SPIFFETrustDomains); err != nil { + return err + } + if err := validateSPIFFEPrincipalPatternOverlap(cfg.InboundGrants); err != nil { + return err + } if err := authserver.ValidateForceConfidentialRedirectURIs( cfg.ForceConfidentialRedirectURIs, cfg.AllowConfidentialClientRegistration, ); err != nil { return err } - for i := range cfg.TrustedIssuers { - if err := validateUpstreamCABundleRef(cfg.TrustedIssuers[i].CABundleRef); err != nil { - return fmt.Errorf("trustedIssuers[%d] (%q) caBundleRef: %w", i, cfg.TrustedIssuers[i].IssuerURL, err) - } - } - seen := make(map[string]bool, len(cfg.UpstreamProviders)) for i, provider := range cfg.UpstreamProviders { if seen[provider.Name] { @@ -2353,6 +2598,79 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { } } + for i := range cfg.TrustedIssuers { + if err := validateUpstreamCABundleRef(cfg.TrustedIssuers[i].CABundleRef); err != nil { + return fmt.Errorf("trustedIssuers[%d] (%q) caBundleRef: %w", i, cfg.TrustedIssuers[i].IssuerURL, err) + } + } + return nil +} + +// validateSPIFFEBundleEndpoints rejects a structurally invalid SPIFFE Bundle +// Endpoint URL (wrong scheme, an IP-literal or loopback host, credentials, +// a query, or a fragment) at admission time. Not CEL-expressible: it needs +// real IP-literal and loopback-range detection, not a best-effort regex. +func validateSPIFFEBundleEndpoints(domains []SPIFFETrustDomainConfig) error { + for i, domain := range domains { + if domain.BundleSource.Type != SPIFFEBundleSourceTypeEndpoint || domain.BundleSource.Endpoint == nil { + continue + } + endpoint := domain.BundleSource.Endpoint + if err := authserver.ValidateSPIFFEBundleEndpoint(authserver.SPIFFEBundleEndpointSourceRunConfig{ + URL: endpoint.URL, + Profile: authserver.SPIFFEBundleEndpointProfile(endpoint.Profile), + }); err != nil { + return fmt.Errorf("spiffeTrustDomains[%d].bundleSource: %w", i, err) + } + } + return nil +} + +// validateSPIFFEPrincipalPatternOverlap validates each SPIFFE client-auth +// entry's own principalPattern and resources, then rejects two entries whose +// principalPattern values overlap (e.g. a "/agent/*" wildcard and a concrete +// "/agent/one"), all at admission time. Not CEL-expressible: real overlap +// semantics need the go-spiffe parser for normalization, the same +// runtime-parser dependency this epic has deliberately kept in Go elsewhere; +// resource shape is folded in here (rather than its own top-level call) to +// keep validateEmbeddedAuthServer's own branching within the complexity +// budget, since both are per-entry, admission-time-safe checks over the same +// slice. +func validateSPIFFEPrincipalPatternOverlap(inboundGrants *InboundGrantsConfig) error { + if inboundGrants == nil { + return nil + } + entries := inboundGrants.SPIFFEClientAuth + // Validate each entry's own pattern and resources first, in its own + // index, so a malformed value is always attributed to the entry that + // owns it rather than to whichever earlier index the pairwise loop below + // happened to be comparing it against. + for i := range entries { + if err := authserver.ValidateSPIFFEPrincipalPattern(entries[i].PrincipalPattern); err != nil { + return fmt.Errorf("inboundGrants.spiffeClientAuth[%d].principalPattern: %w", i, err) + } + if err := authserver.ValidateResourceIndicators( + entries[i].Resources, fmt.Sprintf("inboundGrants.spiffeClientAuth[%d].resources", i), + ); err != nil { + return err + } + } + for i := range entries { + for j := i + 1; j < len(entries); j++ { + // Patterns were already validated above, so the error return + // here is unreachable in practice. + overlaps, err := authserver.SPIFFEPatternsOverlap(entries[i].PrincipalPattern, entries[j].PrincipalPattern) + if err != nil { + return fmt.Errorf("inboundGrants.spiffeClientAuth[%d].principalPattern: %w", i, err) + } + if overlaps { + return fmt.Errorf( + "inboundGrants.spiffeClientAuth[%d].principalPattern %q overlaps spiffeClientAuth[%d].principalPattern %q", + i, entries[i].PrincipalPattern, j, entries[j].PrincipalPattern, + ) + } + } + } return nil } diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 9d2a16b74c..2bafedd676 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -929,6 +929,182 @@ func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) { expectErr: true, errMsg: "actor_matcher", }, + { + // validateEmbeddedAuthServer deliberately runs no Go-level + // permission-shaped SPIFFE pre-check (resources/scopes) at this + // layer — same precedent as DelegateClients: a meaningful + // resources/scopes check needs AllowedAudiences/ScopesSupported, + // which only exist once derived at reconcile time. (It does run + // the admission-time-safe bundle-URL and principal-overlap + // checks below — see validateSPIFFEBundleEndpoints and + // validateSPIFFEPrincipalPatternOverlap tests.) A resources + // entry and a non-default scope must therefore pass here even + // though they'd need revalidating once those derived values are + // known (see + // TestBuildAuthServerRunConfigInvalidSPIFFEIsTypedAndNotYetEnforced + // in controllerutil for the reconcile-time revalidation this + // relies on). CEL (spiffe_cel_test.go) covers structural + // correctness (trust-domain refs, method subsets, etc.) at + // admission. + name: "spiffe client with resources and custom scope - valid at this layer", + config: &MCPExternalAuthConfig{ + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []UpstreamProviderConfig{{ + Name: "github", + Type: UpstreamProviderTypeOIDC, + OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "client-id"}, + }}, + SPIFFETrustDomains: []SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: SPIFFEBundleSourceConfig{ + Type: SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceConfig{}, + }, + }}, + InboundGrants: &InboundGrantsConfig{ + SPIFFEClientAuth: []SPIFFEClientConfig{{ + TrustDomainRef: "example", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + // Not in allowed_audiences and not a default + // scope — would be rejected if the removed + // admission-time pre-check fabricated a nil + // allowlist/default-scopes set instead of + // deferring to reconcile time. + Resources: []string{"https://backend.example.com"}, + Audiences: []string{"https://mcp.example.com"}, + Scopes: []string{"custom:scope"}, + }}, + }, + }, + }, + }, + expectErr: false, + }, + { + name: "spiffe bundle-endpoint URL rejects non-https scheme", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("http://bundle.example.com"), + expectErr: true, + errMsg: "must be an absolute HTTPS URL", + }, + { + name: "spiffe bundle-endpoint URL rejects userinfo", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://user:pass@bundle.example.com"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL rejects a query string", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://bundle.example.com?x=1"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL rejects a fragment", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://bundle.example.com/bundle#frag"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL rejects an IP-literal host", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://192.0.2.1"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL rejects the localhost host", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://localhost"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL rejects the 127.0.0.1 loopback host", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://127.0.0.1"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL rejects the [::1] loopback host", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://[::1]"), + expectErr: true, + errMsg: "must not contain credentials", + }, + { + name: "spiffe bundle-endpoint URL accepts a valid https URL", + config: mustEmbeddedAuthServerConfigWithBundleEndpoint("https://bundle.example.com"), + expectErr: false, + }, + { + name: "spiffe principal patterns reject an identical duplicate pair", + config: mustEmbeddedAuthServerConfigWithPrincipalPatterns( + "spiffe://example.org/ns/default/agent", "spiffe://example.org/ns/default/agent", + ), + expectErr: true, + errMsg: "overlaps", + }, + { + name: "spiffe principal patterns reject a wildcard overlapping a concrete principal", + config: mustEmbeddedAuthServerConfigWithPrincipalPatterns( + "spiffe://example.org/agent/*", "spiffe://example.org/agent/one", + ), + expectErr: true, + errMsg: "overlaps", + }, + { + name: "spiffe principal patterns reject two overlapping wildcards", + config: mustEmbeddedAuthServerConfigWithPrincipalPatterns( + "spiffe://example.org/agent/*", "spiffe://example.org/agent/one/*", + ), + expectErr: true, + errMsg: "overlaps", + }, + { + name: "spiffe principal patterns accept a genuinely non-overlapping pair", + config: mustEmbeddedAuthServerConfigWithPrincipalPatterns( + "spiffe://example.org/agent/*", "spiffe://example.org/other/*", + ), + expectErr: false, + }, + { + // Regression case: when only the SECOND entry in a pair is + // malformed, the error must name index 1, not index 0 — a + // pairwise loop that always blames the first entry in the pair + // it happens to be comparing would get this wrong. + name: "spiffe principal patterns blame the correct index when the second entry is malformed", + config: mustEmbeddedAuthServerConfigWithPrincipalPatterns( + "spiffe://example.org/agent/one", "spiffe://example.org/agent~2", + ), + expectErr: true, + errMsg: "spiffeClientAuth[1].principalPattern", + }, + { + name: "spiffe resource indicator rejects an empty-host authority", + config: mustEmbeddedAuthServerConfigWithResource("https://:443/resource"), + expectErr: true, + errMsg: "must be an absolute HTTP(S) URI", + }, + { + name: "spiffe resource indicator rejects userinfo", + config: mustEmbeddedAuthServerConfigWithResource("https://user@mcp.example.com/resource"), + expectErr: true, + errMsg: "must be an absolute HTTP(S) URI", + }, + { + name: "spiffe resource indicator rejects a fragment", + config: mustEmbeddedAuthServerConfigWithResource("https://mcp.example.com/resource#fragment"), + expectErr: true, + errMsg: "must be an absolute HTTP(S) URI", + }, + { + name: "spiffe resource indicator accepts a well-formed URI", + config: mustEmbeddedAuthServerConfigWithResource("https://mcp.example.com/resource"), + expectErr: false, + }, } for _, tt := range tests { @@ -1070,6 +1246,116 @@ func TestEmbeddedAuthServerConfig_ValidateInboundGrants_DuplicateCredentialIssue } } +// mustEmbeddedAuthServerConfigWithBundleEndpoint builds a minimal valid +// MCPExternalAuthConfig with a single spiffeTrustDomains entry whose +// bundleSource is a bundle_endpoint with the given URL (always using the +// https_web profile), for exercising validateSPIFFEBundleEndpoints in +// isolation. +func mustEmbeddedAuthServerConfigWithBundleEndpoint(url string) *MCPExternalAuthConfig { + return &MCPExternalAuthConfig{ + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []UpstreamProviderConfig{{ + Name: "github", + Type: UpstreamProviderTypeOIDC, + OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "client-id"}, + }}, + SPIFFETrustDomains: []SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: SPIFFEBundleSourceConfig{ + Type: SPIFFEBundleSourceTypeEndpoint, + Endpoint: &SPIFFEBundleEndpointSourceConfig{ + URL: url, Profile: SPIFFEBundleEndpointProfileHTTPSWeb, + }, + }, + }}, + }, + }, + } +} + +// mustEmbeddedAuthServerConfigWithPrincipalPatterns builds a minimal valid +// MCPExternalAuthConfig with two spiffeClientAuth entries using the given +// principal patterns, for exercising validateSPIFFEPrincipalPatternOverlap +// in isolation. +func mustEmbeddedAuthServerConfigWithPrincipalPatterns(first, second string) *MCPExternalAuthConfig { + return &MCPExternalAuthConfig{ + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []UpstreamProviderConfig{{ + Name: "github", + Type: UpstreamProviderTypeOIDC, + OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "client-id"}, + }}, + SPIFFETrustDomains: []SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: SPIFFEBundleSourceConfig{ + Type: SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceConfig{}, + }, + }}, + InboundGrants: &InboundGrantsConfig{ + SPIFFEClientAuth: []SPIFFEClientConfig{ + { + TrustDomainRef: "example", PrincipalPattern: first, ClientID: "spiffe-client-1", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.com"}, Scopes: []string{"openid"}, + }, + { + TrustDomainRef: "example", PrincipalPattern: second, ClientID: "spiffe-client-2", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.com"}, Scopes: []string{"openid"}, + }, + }, + }, + }, + }, + } +} + +// mustEmbeddedAuthServerConfigWithResource builds a minimal valid +// MCPExternalAuthConfig with a single spiffeClientAuth entry carrying the +// given resource indicator, for exercising validateSPIFFEResourceShapes in +// isolation. +func mustEmbeddedAuthServerConfigWithResource(resource string) *MCPExternalAuthConfig { + return &MCPExternalAuthConfig{ + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []UpstreamProviderConfig{{ + Name: "github", + Type: UpstreamProviderTypeOIDC, + OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "client-id"}, + }}, + SPIFFETrustDomains: []SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: SPIFFEBundleSourceConfig{ + Type: SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceConfig{}, + }, + }}, + InboundGrants: &InboundGrantsConfig{ + SPIFFEClientAuth: []SPIFFEClientConfig{{ + TrustDomainRef: "example", PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.com"}, Scopes: []string{"openid"}, + Resources: []string{resource}, + }}, + }, + }, + }, + } +} + func TestMCPExternalAuthConfig_ZeroUpstreamAlternatives(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 6fe5c96a88..51f90f42d5 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -288,6 +288,13 @@ func (in *EmbeddedAuthServerConfig) DeepCopyInto(out *EmbeddedAuthServerConfig) *out = new(TokenLifespanConfig) **out = **in } + if in.SPIFFETrustDomains != nil { + in, out := &in.SPIFFETrustDomains, &out.SPIFFETrustDomains + *out = make([]SPIFFETrustDomainConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.InboundGrants != nil { in, out := &in.InboundGrants, &out.InboundGrants *out = new(InboundGrantsConfig) @@ -661,6 +668,13 @@ func (in *IdentityFromTokenConfig) DeepCopy() *IdentityFromTokenConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InboundGrantsConfig) DeepCopyInto(out *InboundGrantsConfig) { *out = *in + if in.SPIFFEClientAuth != nil { + in, out := &in.SPIFFEClientAuth, &out.SPIFFEClientAuth + *out = make([]SPIFFEClientConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.TokenExchange != nil { in, out := &in.TokenExchange, &out.TokenExchange *out = new(TokenExchangeInboundGrantConfig) @@ -2894,6 +2908,117 @@ func (in *RoleMapping) DeepCopy() *RoleMapping { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SPIFFEBundleEndpointSourceConfig) DeepCopyInto(out *SPIFFEBundleEndpointSourceConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SPIFFEBundleEndpointSourceConfig. +func (in *SPIFFEBundleEndpointSourceConfig) DeepCopy() *SPIFFEBundleEndpointSourceConfig { + if in == nil { + return nil + } + out := new(SPIFFEBundleEndpointSourceConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SPIFFEBundleSourceConfig) DeepCopyInto(out *SPIFFEBundleSourceConfig) { + *out = *in + if in.Endpoint != nil { + in, out := &in.Endpoint, &out.Endpoint + *out = new(SPIFFEBundleEndpointSourceConfig) + **out = **in + } + if in.WorkloadAPI != nil { + in, out := &in.WorkloadAPI, &out.WorkloadAPI + *out = new(SPIFFEWorkloadAPIBundleSourceConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SPIFFEBundleSourceConfig. +func (in *SPIFFEBundleSourceConfig) DeepCopy() *SPIFFEBundleSourceConfig { + if in == nil { + return nil + } + out := new(SPIFFEBundleSourceConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SPIFFEClientConfig) DeepCopyInto(out *SPIFFEClientConfig) { + *out = *in + if in.Methods != nil { + in, out := &in.Methods, &out.Methods + *out = make([]SPIFFEAuthenticationMethod, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Audiences != nil { + in, out := &in.Audiences, &out.Audiences + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SPIFFEClientConfig. +func (in *SPIFFEClientConfig) DeepCopy() *SPIFFEClientConfig { + if in == nil { + return nil + } + out := new(SPIFFEClientConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SPIFFETrustDomainConfig) DeepCopyInto(out *SPIFFETrustDomainConfig) { + *out = *in + if in.Methods != nil { + in, out := &in.Methods, &out.Methods + *out = make([]SPIFFEAuthenticationMethod, len(*in)) + copy(*out, *in) + } + in.BundleSource.DeepCopyInto(&out.BundleSource) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SPIFFETrustDomainConfig. +func (in *SPIFFETrustDomainConfig) DeepCopy() *SPIFFETrustDomainConfig { + if in == nil { + return nil + } + out := new(SPIFFETrustDomainConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SPIFFEWorkloadAPIBundleSourceConfig) DeepCopyInto(out *SPIFFEWorkloadAPIBundleSourceConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SPIFFEWorkloadAPIBundleSourceConfig. +func (in *SPIFFEWorkloadAPIBundleSourceConfig) DeepCopy() *SPIFFEWorkloadAPIBundleSourceConfig { + if in == nil { + return nil + } + out := new(SPIFFEWorkloadAPIBundleSourceConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { *out = *in diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index 487dcb234d..fa345187e3 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -310,6 +310,73 @@ func buildJWTBearerGrantPolicy(config *mcpv1beta1.JWTBearerGrantConfig) *tokenex return policy } +// buildSPIFFETrustDomainRunConfigs converts CRD SPIFFETrustDomainConfig +// entries to authserver.SPIFFETrustDomainRunConfig, the runtime type +// authserver.RunConfig.SPIFFETrustDomains consumes directly. None of these +// fields reference a Secret, so no env-var indirection is needed here. +func buildSPIFFETrustDomainRunConfigs( + domains []mcpv1beta1.SPIFFETrustDomainConfig, +) []authserver.SPIFFETrustDomainRunConfig { + configs := make([]authserver.SPIFFETrustDomainRunConfig, len(domains)) + for i, domain := range domains { + methods := make([]authserver.SPIFFEAuthenticationMethod, len(domain.Methods)) + for j, method := range domain.Methods { + methods[j] = authserver.SPIFFEAuthenticationMethod(method) + } + configs[i] = authserver.SPIFFETrustDomainRunConfig{ + Name: domain.Name, + TrustDomain: domain.TrustDomain, + Methods: methods, + BundleSource: buildSPIFFEBundleSourceRunConfig(domain.BundleSource), + } + } + return configs +} + +// buildSPIFFEBundleSourceRunConfig converts the CRD's discriminated +// bundle-source union to the runtime shape. +func buildSPIFFEBundleSourceRunConfig(source mcpv1beta1.SPIFFEBundleSourceConfig) authserver.SPIFFEBundleSourceRunConfig { + converted := authserver.SPIFFEBundleSourceRunConfig{Type: authserver.SPIFFEBundleSourceType(source.Type)} + if source.Endpoint != nil { + converted.Endpoint = &authserver.SPIFFEBundleEndpointSourceRunConfig{ + URL: source.Endpoint.URL, + Profile: authserver.SPIFFEBundleEndpointProfile(source.Endpoint.Profile), + } + } + if source.WorkloadAPI != nil { + converted.WorkloadAPI = &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{} + } + return converted +} + +// buildSPIFFEClientAuthRunConfigs converts CRD SPIFFEClientConfig entries to +// authserver.SPIFFEClientAuthRunConfig. GrantTypes is not a CRD field: the +// runtime only accepts exactly the RFC 8693 token-exchange grant for a +// SPIFFE client (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so +// it is always supplied here rather than configured. +func buildSPIFFEClientAuthRunConfigs( + clients []mcpv1beta1.SPIFFEClientConfig, +) []authserver.SPIFFEClientAuthRunConfig { + configs := make([]authserver.SPIFFEClientAuthRunConfig, len(clients)) + for i, spiffeClient := range clients { + methods := make([]authserver.SPIFFEAuthenticationMethod, len(spiffeClient.Methods)) + for j, method := range spiffeClient.Methods { + methods[j] = authserver.SPIFFEAuthenticationMethod(method) + } + configs[i] = authserver.SPIFFEClientAuthRunConfig{ + TrustDomainRef: spiffeClient.TrustDomainRef, + PrincipalPattern: spiffeClient.PrincipalPattern, + ClientID: spiffeClient.ClientID, + Methods: methods, + Resources: append([]string(nil), spiffeClient.Resources...), + Audiences: append([]string(nil), spiffeClient.Audiences...), + Scopes: append([]string(nil), spiffeClient.Scopes...), + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + } + } + return configs +} + // EmbeddedAuthServerConfigName returns the config name that should be used for // embedded auth server volume/env generation, or empty string if neither ref applies. // AuthServerRef takes precedence; externalAuthConfigRef is used as a fallback. @@ -827,7 +894,9 @@ func buildInboundGrantsRunConfig( if config == nil { return nil, nil } - grants := &authserver.InboundGrantsRunConfig{} + grants := &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: buildSPIFFEClientAuthRunConfigs(config.SPIFFEClientAuth), + } if config.TokenExchange != nil { delegateClients, err := buildDelegateClientRunConfigs(config.TokenExchange.DelegateClients) if err != nil { @@ -918,6 +987,7 @@ func BuildAuthServerRunConfig( ScopesSupported: scopesSupported, BaselineClientScopes: authConfig.BaselineClientScopes, InboundGrants: inboundGrants, + SPIFFETrustDomains: buildSPIFFETrustDomainRunConfigs(authConfig.SPIFFETrustDomains), } if len(authConfig.DelegateClients) > 0 { @@ -1038,7 +1108,8 @@ func buildAuthServerSecretsConfig(config *authserver.RunConfig, authConfig *mcpv // or allow_may_act combined with the delegate-client wildcard) as a // reconcile error rather than a pod crash loop. func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) error { - if len(config.DelegateClients) == 0 && len(config.TrustedIssuers) == 0 && config.InboundGrants == nil { + if len(config.DelegateClients) == 0 && len(config.TrustedIssuers) == 0 && config.InboundGrants == nil && + len(config.SPIFFETrustDomains) == 0 { return nil } @@ -1049,9 +1120,10 @@ func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) erro InsecureAllowHTTP: config.InsecureAllowHTTP, AllowPrivateKeyJWTRegistration: config.AllowPrivateKeyJWTRegistration, InsecureAllowConfidentialOverLoopbackHTTP: config.InsecureAllowConfidentialOverLoopbackHTTP, - DelegateClients: config.DelegateClients, - TrustedIssuers: config.TrustedIssuers, - InboundGrants: config.InboundGrants, + DelegateClients: config.DelegateClients, + TrustedIssuers: config.TrustedIssuers, + InboundGrants: config.InboundGrants, + SPIFFETrustDomains: config.SPIFFETrustDomains, } if err := validationConfig.Validate(); err != nil { return fmt.Errorf("invalid embedded auth server delegate clients or trusted issuers: %w", err) diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index ab2e04bf13..58a498681c 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -3452,3 +3452,176 @@ func TestBuildTrustedIssuerRunConfigs_JWTBearerGrant(t *testing.T) { acceptedAudiences[0] = "https://auth.example.com/source-mutated" assert.Equal(t, "https://auth.example.com/legacy-token", configs[0].JWTBearerGrant.AcceptedAudiences[0]) } + +func TestBuildSPIFFETrustDomainRunConfigs(t *testing.T) { + t.Parallel() + + methods := []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509} + configs := buildSPIFFETrustDomainRunConfigs([]mcpv1beta1.SPIFFETrustDomainConfig{ + { + Name: "example", TrustDomain: "example.org", Methods: methods, + BundleSource: mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &mcpv1beta1.SPIFFEWorkloadAPIBundleSourceConfig{}, + }, + }, + { + Name: "federated", TrustDomain: "federated.org", Methods: methods, + BundleSource: mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeEndpoint, + Endpoint: &mcpv1beta1.SPIFFEBundleEndpointSourceConfig{ + URL: "https://bundle.example.com", Profile: mcpv1beta1.SPIFFEBundleEndpointProfileHTTPSWeb, + }, + }, + }, + }) + + require.Len(t, configs, 2) + assert.Equal(t, "example", configs[0].Name) + assert.Equal(t, "example.org", configs[0].TrustDomain) + assert.Equal(t, []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, configs[0].Methods) + assert.Equal(t, authserver.SPIFFEBundleSourceTypeWorkloadAPI, configs[0].BundleSource.Type) + require.NotNil(t, configs[0].BundleSource.WorkloadAPI) + assert.Nil(t, configs[0].BundleSource.Endpoint) + + assert.Equal(t, authserver.SPIFFEBundleSourceTypeEndpoint, configs[1].BundleSource.Type) + require.NotNil(t, configs[1].BundleSource.Endpoint) + assert.Equal(t, "https://bundle.example.com", configs[1].BundleSource.Endpoint.URL) + assert.Equal(t, authserver.SPIFFEBundleEndpointProfileHTTPSWeb, configs[1].BundleSource.Endpoint.Profile) + assert.Nil(t, configs[1].BundleSource.WorkloadAPI) + + // The runtime type must not retain the CRD object's backing slices. + methods[0] = mcpv1beta1.SPIFFEAuthenticationMethodJWT + assert.Equal(t, authserver.SPIFFEAuthenticationMethod("spiffe_x509"), configs[0].Methods[0]) +} + +func TestBuildSPIFFEClientAuthRunConfigs(t *testing.T) { + t.Parallel() + + audiences := []string{"https://mcp.example.com"} + scopes := []string{"openid"} + resources := []string{"https://backend.example.com"} + configs := buildSPIFFEClientAuthRunConfigs([]mcpv1beta1.SPIFFEClientConfig{{ + TrustDomainRef: "example", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + Resources: resources, + Audiences: audiences, + Scopes: scopes, + }}) + + require.Len(t, configs, 1) + assert.Equal(t, "example", configs[0].TrustDomainRef) + assert.Equal(t, "spiffe://example.org/ns/default/agent", configs[0].PrincipalPattern) + assert.Equal(t, "spiffe-client", configs[0].ClientID) + assert.Equal(t, []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, configs[0].Methods) + assert.Equal(t, []string{"https://backend.example.com"}, configs[0].Resources) + assert.Equal(t, []string{"https://mcp.example.com"}, configs[0].Audiences) + assert.Equal(t, []string{"openid"}, configs[0].Scopes) + // GrantTypes is not a CRD field; the converter always supplies exactly + // the RFC 8693 token-exchange grant. + assert.Equal(t, []string{authserver.SPIFFEGrantTypeTokenExchange}, configs[0].GrantTypes) + + // The runtime type must not retain the CRD object's backing slices. + audiences[0] = "https://mutated.example.com" + assert.Equal(t, "https://mcp.example.com", configs[0].Audiences[0]) +} + +// TestBuildAuthServerRunConfigInvalidSPIFFEIsTypedAndNotYetEnforced covers the +// "not yet enforced" gate documented in +// pkg/authserver/config.go's validateSPIFFENotYetEnforced: a well-formed, +// non-empty SPIFFE trust configuration is admitted by the CRD's CEL rules +// (see spiffe_cel_test.go), but BuildAuthServerRunConfig's reconcile-time +// revalidation (validateDelegateClientsAndTrustedIssuers) still rejects it +// via RunConfig.Validate(), as a terminal InvalidEmbeddedAuthServerConfigError +// rather than a pod crash loop. This is expected until real SVID verification +// lands. +func TestBuildAuthServerRunConfigInvalidSPIFFEIsTypedAndNotYetEnforced(t *testing.T) { + t.Parallel() + + _, err := BuildAuthServerRunConfig("default", "test-server", &mcpv1beta1.EmbeddedAuthServerConfig{ + SPIFFETrustDomains: []mcpv1beta1.SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + BundleSource: mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &mcpv1beta1.SPIFFEWorkloadAPIBundleSourceConfig{}, + }, + }}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + SPIFFEClientAuth: []mcpv1beta1.SPIFFEClientConfig{{ + TrustDomainRef: "example", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.com"}, + Scopes: []string{"openid"}, + }}, + }, + }, []string{"https://mcp.example.com"}, []string{"openid"}, "https://mcp.example.com") + + require.Error(t, err) + assert.Contains(t, err.Error(), "SPIFFE client authentication is not yet enforced") + var invalidConfigErr *InvalidEmbeddedAuthServerConfigError + assert.True(t, stderrors.As(err, &invalidConfigErr)) +} + +// TestBuildAuthServerRunConfigSPIFFEResourcesAndScopesValidateOnceDerivedValuesExist +// proves the fix for the admission-time false-rejection bug: a SPIFFE +// client's resources/scopes must NOT be checked against a fabricated +// nil/empty allowlist at the CRD-admission-equivalent Go layer (there is no +// such check any more — see EmbeddedAuthServerConfig.Validate in +// mcpexternalauthconfig_types.go, which runs no SPIFFE pre-check, matching +// the DelegateClients precedent). The real resources/scopes-subset check +// only runs here, once BuildAuthServerRunConfig has the actual derived +// AllowedAudiences/ScopesSupported — and it must still correctly accept a +// resource that is allowed and reject one that isn't. +func TestBuildAuthServerRunConfigSPIFFEResourcesAndScopesValidateOnceDerivedValuesExist(t *testing.T) { + t.Parallel() + + authConfig := func(resource string) *mcpv1beta1.EmbeddedAuthServerConfig { + return &mcpv1beta1.EmbeddedAuthServerConfig{ + SPIFFETrustDomains: []mcpv1beta1.SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + BundleSource: mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &mcpv1beta1.SPIFFEWorkloadAPIBundleSourceConfig{}, + }, + }}, + InboundGrants: &mcpv1beta1.InboundGrantsConfig{ + SPIFFEClientAuth: []mcpv1beta1.SPIFFEClientConfig{{ + TrustDomainRef: "example", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + Resources: []string{resource}, + Audiences: []string{"https://mcp.example.com"}, + // A custom, non-default scope: rejected by + // registration.DefaultScopes but valid once the real + // ScopesSupported below is consulted. + Scopes: []string{"custom:scope"}, + }}, + }, + } + } + + // Resource is in AllowedAudiences and scope is in ScopesSupported: the + // only remaining rejection is the unrelated "not yet enforced" gate, + // proving resources/scopes passed on real derived values. + _, err := BuildAuthServerRunConfig("default", "test-server", authConfig("https://backend.example.com"), + []string{"https://backend.example.com"}, []string{"custom:scope"}, "https://mcp.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "SPIFFE client authentication is not yet enforced") + assert.NotContains(t, err.Error(), "resource") + assert.NotContains(t, err.Error(), "scopes") + + // Resource is NOT in AllowedAudiences: still rejected, but for the + // correct reason, proving the check still runs with real derived values. + _, err = BuildAuthServerRunConfig("default", "test-server", authConfig("https://unlisted.example.com"), + []string{"https://backend.example.com"}, []string{"custom:scope"}, "https://mcp.example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "resource") + assert.Contains(t, err.Error(), "not allowed by allowed_audiences") +} diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/spiffe_cel_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/spiffe_cel_test.go new file mode 100644 index 0000000000..0e4ccca23f --- /dev/null +++ b/cmd/thv-operator/test-integration/mcp-external-auth/spiffe_cel_test.go @@ -0,0 +1,387 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// These tests exercise the SPIFFE CEL and pattern validation through the real +// apiserver. Runtime parsing remains authoritative for complete URI, +// trust-domain, and principal validation. +var _ = Describe("MCPExternalAuthConfig SPIFFE CEL validation", func() { + const namespace = "default" + + BeforeEach(func() { + _ = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}) + }) + + workloadAPIBundleSource := func() mcpv1beta1.SPIFFEBundleSourceConfig { + return mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &mcpv1beta1.SPIFFEWorkloadAPIBundleSourceConfig{}, + } + } + + makeAuthConfig := func(name string, trustDomains, clients bool, principalPattern string) *mcpv1beta1.MCPExternalAuthConfig { + config := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + ClientID: "test-client-id", + }, + }}, + }, + }, + } + if trustDomains { + config.Spec.EmbeddedAuthServer.SPIFFETrustDomains = []mcpv1beta1.SPIFFETrustDomainConfig{{ + Name: "example", TrustDomain: "example.org", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + BundleSource: workloadAPIBundleSource(), + }} + } + if clients { + config.Spec.EmbeddedAuthServer.InboundGrants = &mcpv1beta1.InboundGrantsConfig{ + SPIFFEClientAuth: []mcpv1beta1.SPIFFEClientConfig{{ + TrustDomainRef: "example", PrincipalPattern: principalPattern, ClientID: "spiffe-client", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.com"}, Scopes: []string{"openid"}, + }}, + } + } + return config + } + + type validationCase struct { + name string + trustDomains bool + clients bool + principal string + mutate func(*mcpv1beta1.EmbeddedAuthServerConfig) + shouldAdmit bool + errMatch string + } + + configured := func(name, trustDomain, principal, clientID string) func(*mcpv1beta1.EmbeddedAuthServerConfig) { + return func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains = append(config.SPIFFETrustDomains, mcpv1beta1.SPIFFETrustDomainConfig{ + Name: name, TrustDomain: trustDomain, + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + BundleSource: workloadAPIBundleSource(), + }) + client := config.InboundGrants.SPIFFEClientAuth[0] + client.TrustDomainRef = name + client.PrincipalPattern = principal + client.ClientID = clientID + config.InboundGrants.SPIFFEClientAuth = append(config.InboundGrants.SPIFFEClientAuth, client) + } + } + + cases := []validationCase{ + {name: "trust domains without clients", trustDomains: true, errMatch: "must be configured together"}, + { + name: "clients without trust domains", clients: true, principal: "spiffe://example.org/*", + errMatch: "must be configured together", + }, + { + name: "domain-wide principal wildcard", trustDomains: true, clients: true, + principal: "spiffe://example.org/*", shouldAdmit: true, + }, + { + name: "path principal wildcard", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/*", shouldAdmit: true, + }, + { + name: "workload principal path", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", shouldAdmit: true, + }, + { + name: "distinct trust-domain names and client IDs", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", shouldAdmit: true, + mutate: configured("development", "dev.example.org", "spiffe://dev.example.org/ns/default/agent", "dev-client"), + }, + { + name: "duplicate trust-domain names", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "spiffeTrustDomains must not contain duplicate names", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains = append(config.SPIFFETrustDomains, config.SPIFFETrustDomains[0]) + }, + }, + { + name: "duplicate trust-domain values", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "must not contain duplicate trust domains", + mutate: configured("secondary", "example.org", "spiffe://example.org/ns/other/agent", "other-client"), + }, + { + name: "unreferenced trust domain", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "every SPIFFE trust domain must be referenced", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains = append(config.SPIFFETrustDomains, mcpv1beta1.SPIFFETrustDomainConfig{ + Name: "unused", TrustDomain: "unused.example.org", + Methods: []mcpv1beta1.SPIFFEAuthenticationMethod{mcpv1beta1.SPIFFEAuthenticationMethodX509}, + BundleSource: workloadAPIBundleSource(), + }) + }, + }, + { + name: "duplicate client IDs", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "spiffeClientAuth must not contain duplicate client IDs", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + client := config.InboundGrants.SPIFFEClientAuth[0] + client.PrincipalPattern = "spiffe://example.org/ns/other/agent" + config.InboundGrants.SPIFFEClientAuth = append(config.InboundGrants.SPIFFEClientAuth, client) + }, + }, + { + name: "duplicate principal patterns", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", + errMatch: "must not contain duplicate principal patterns", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + client := config.InboundGrants.SPIFFEClientAuth[0] + client.ClientID = "other-client" + config.InboundGrants.SPIFFEClientAuth = append(config.InboundGrants.SPIFFEClientAuth, client) + }, + }, + { + name: "reserved synthetic client ID", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "reserved synthetic: prefix", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].ClientID = "synthetic:spiffe-client" + }, + }, + { + name: "absolute URL client ID", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "must not be an absolute URL", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].ClientID = "https://client.example.com/metadata.json" + }, + }, + { + name: "unknown trust domain reference", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "trustDomainRef must reference a declared trust domain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].TrustDomainRef = "unknown" + }, + }, + { + name: "client method is a subset", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", shouldAdmit: true, + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].Methods = []mcpv1beta1.SPIFFEAuthenticationMethod{ + mcpv1beta1.SPIFFEAuthenticationMethodX509, mcpv1beta1.SPIFFEAuthenticationMethodJWT, + } + }, + }, + { + name: "client method is not declared", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "methods must be enabled by the referenced trust domain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Methods = []mcpv1beta1.SPIFFEAuthenticationMethod{ + mcpv1beta1.SPIFFEAuthenticationMethodJWT, + } + }, + }, + { + name: "mismatched principal trust domain", trustDomains: true, clients: true, + principal: "spiffe://other.example.org/ns/default/agent", errMatch: "principalPattern trust domain must match", + }, + { + name: "audiences are required", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "audiences", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Audiences = nil + }, + }, + { + name: "scopes are required", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "scopes", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Scopes = nil + }, + }, + { + name: "resources are optional", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", shouldAdmit: true, + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Resources = []string{"https://backend.example.com"} + }, + }, + { + name: "empty-string audience is rejected", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "audiences", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Audiences = []string{""} + }, + }, + { + name: "empty-string scope is rejected", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "scopes", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Scopes = []string{""} + }, + }, + { + name: "malformed resource URI is rejected", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "resources", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.InboundGrants.SPIFFEClientAuth[0].Resources = []string{"not-a-url"} + }, + }, + { + name: "valid underscore trust domain", trustDomains: true, clients: true, + principal: "spiffe://example_org/ns/default/agent", shouldAdmit: true, + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].TrustDomain = "example_org" + }, + }, + { + name: "leading trust-domain dot", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "trustDomain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].TrustDomain = ".example.org" + }, + }, + { + name: "trailing trust-domain dot", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "trustDomain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].TrustDomain = "example.org." + }, + }, + { + name: "leading trust-domain hyphen", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "trustDomain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].TrustDomain = "-example.org" + }, + }, + { + name: "trailing trust-domain hyphen", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "trustDomain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].TrustDomain = "example.org-" + }, + }, + { + name: "consecutive trust-domain dots", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", errMatch: "trustDomain", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].TrustDomain = "example..org" + }, + }, + { + name: "three-dot path segment", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/.../agent", shouldAdmit: true, + }, + { + name: "dot path segment", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/./agent", errMatch: "principalPattern path must not contain", + }, + { + name: "dot-dot path segment", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/../agent", errMatch: "principalPattern path must not contain", + }, + { + name: "bare trust-domain principal", trustDomains: true, clients: true, + principal: "spiffe://example.org", errMatch: "principalPattern", + }, + { + // ~ is not a valid SPIFFE path-segment character (verified + // against go-spiffe's spiffeid.FromString), so a concrete + // principal containing it must be rejected at admission time, + // not just by the runtime parser at reconcile time. + name: "tilde in concrete principal path segment", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent~1", errMatch: "principalPattern", + }, + { + name: "tilde in wildcard principal pattern", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent~1/*", errMatch: "principalPattern", + }, + { + name: "uppercase trust-domain principal", trustDomains: true, clients: true, + principal: "spiffe://Example.org/ns/default/agent", errMatch: "principalPattern", + }, + { + name: "bundle-endpoint source requires endpoint payload", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", + errMatch: "endpoint configuration must be set if and only if type is 'bundle_endpoint'", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].BundleSource = mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeEndpoint, + } + }, + }, + { + name: "workload-api source rejects endpoint payload", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", + errMatch: "workloadAPI configuration must be set if and only if type is 'workload_api'", + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].BundleSource = mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeWorkloadAPI, + Endpoint: &mcpv1beta1.SPIFFEBundleEndpointSourceConfig{ + URL: "https://bundle.example.com", Profile: mcpv1beta1.SPIFFEBundleEndpointProfileHTTPSWeb, + }, + } + }, + }, + { + // Covers the deviation from the old commit's shape: the + // top-level "at least one upstream provider or inbound grant + // family" rule must also accept a SPIFFE-only config (no + // upstream providers, no tokenExchange/jwtBearer) — every other + // case in this file always carries an upstream provider, so + // without this case the rule change is untested. + name: "spiffe-only config with no upstream providers is admitted", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", shouldAdmit: true, + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.UpstreamProviders = nil + }, + }, + { + name: "bundle-endpoint source is admitted", trustDomains: true, clients: true, + principal: "spiffe://example.org/ns/default/agent", shouldAdmit: true, + mutate: func(config *mcpv1beta1.EmbeddedAuthServerConfig) { + config.SPIFFETrustDomains[0].BundleSource = mcpv1beta1.SPIFFEBundleSourceConfig{ + Type: mcpv1beta1.SPIFFEBundleSourceTypeEndpoint, + Endpoint: &mcpv1beta1.SPIFFEBundleEndpointSourceConfig{ + URL: "https://bundle.example.com", Profile: mcpv1beta1.SPIFFEBundleEndpointProfileHTTPSWeb, + }, + } + }, + }, + } + + for i, test := range cases { + test := test + It(test.name, func() { + config := makeAuthConfig(fmt.Sprintf("spiffe-validation-%d", i), test.trustDomains, test.clients, test.principal) + if test.mutate != nil { + test.mutate(config.Spec.EmbeddedAuthServer) + } + err := k8sClient.Create(ctx, config) + if test.shouldAdmit { + Expect(err).NotTo(HaveOccurred(), "expected apiserver to admit config: %s", test.name) + DeferCleanup(func() { Expect(k8sClient.Delete(ctx, config)).To(Succeed()) }) + return + } + Expect(err).To(HaveOccurred(), "expected apiserver to reject config: %s", test.name) + Expect(err.Error()).To(ContainSubstring(test.errMatch)) + }) + } +}) diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 6b61549643..6b29c0ebe4 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -535,6 +535,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -765,6 +879,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -1892,7 +2120,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -2940,6 +3223,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -3170,6 +3567,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -4297,7 +4808,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 8d2fbcaf88..cfe115fe79 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -411,6 +411,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -641,6 +755,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -1768,7 +1996,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -4978,6 +5261,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -5208,6 +5605,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -6335,7 +6846,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 7953ad41ee..3eacb211dd 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -538,6 +538,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -768,6 +882,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -1895,7 +2123,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -2943,6 +3226,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -3173,6 +3570,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -4300,7 +4811,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index b50d1c8fcf..4858264964 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -414,6 +414,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -644,6 +758,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -1771,7 +1999,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint @@ -4981,6 +5264,120 @@ spec: type: array x-kubernetes-list-type: atomic type: object + spiffeClientAuth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit + OAuth client identities and permissions. A sibling of TokenExchange and + JWTBearer below, not nested under either: client authentication does + not by itself confer a grant. See SPIFFEClientConfig. + items: + description: |- + SPIFFEClientConfig associates one SPIFFE principal pattern from a declared + trust domain with an explicit OAuth client identity and permissions. + Configuration is not authentication: configured SPIFFE clients remain + non-public OAuth clients without a secret until live SPIFFE credential + validation is implemented (see SPIFFETrustDomainConfig's doc comment). + + GrantTypes is deliberately not exposed here: the runtime only accepts + exactly the RFC 8693 token-exchange grant for a SPIFFE client + (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter + always supplies it instead of letting it be configured. + properties: + audiences: + description: Audiences are RFC 8693 token audiences + this association may request. + items: + maxLength: 2048 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + clientId: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + maxLength: 253 + minLength: 1 + type: string + methods: + description: |- + Methods are the credential types this association may authenticate + with. Must be a subset of the referenced trust domain's methods. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + principalPattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. This pattern is a best-effort CRD-level + approximation; runtime parsing via spiffeid.FromString remains + authoritative. + maxLength: 2048 + pattern: ^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?|/\*)$ + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist, + which is derived at reconcile time and not available on this CRD, so + allowlist membership is validated at reconcile time, not admission. + Shape (a well-formed absolute HTTP(S) URI) is independent of that + derived allowlist and is validated here. Distinct from Audiences: a + resource permission does not imply the same value is also a permitted + token audience, or vice versa. + items: + maxLength: 2048 + minLength: 1 + pattern: ^https?://[^@#[:space:]]+$ + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: set + scopes: + description: |- + Scopes are OAuth scopes granted to this association. Must be a subset + of the server's effective supported scopes. + items: + maxLength: 256 + minLength: 1 + type: string + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: set + trustDomainRef: + description: TrustDomainRef references spiffeTrustDomains[].name. + maxLength: 253 + minLength: 1 + type: string + required: + - audiences + - clientId + - methods + - principalPattern + - scopes + - trustDomainRef + type: object + x-kubernetes-validations: + - message: principalPattern path must not contain . or .. + segments + rule: self.principalPattern.split('/').all(segment, segment + != '.' && segment != '..') + maxItems: 100 + type: array + x-kubernetes-list-type: atomic tokenExchange: description: TokenExchange configures RFC 8693 clients and issuer policies. @@ -5211,6 +5608,120 @@ spec: maxItems: 5 type: array x-kubernetes-list-type: atomic + spiffeTrustDomains: + description: |- + SPIFFETrustDomains declares SPIFFE trust domains for + inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's + doc comment for why declaring a domain does not by itself enable + authentication in this build. + items: + description: |- + SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the + embedded authorization server. Configuration is not authentication: no + live X.509-SVID or JWT-SVID validation exists yet, so a declared trust + domain does not by itself let any workload authenticate — RunConfig.Validate + (pkg/authserver/config.go) currently hard-rejects any non-empty + spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, + a deliberate placeholder until real SVID verification lands. + properties: + bundleSource: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + description: |- + Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is + "bundle_endpoint". + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). + enum: + - https_web + - https_spiffe + type: string + url: + description: URL is the HTTPS SPIFFE Bundle Endpoint + URL. + maxLength: 2048 + minLength: 1 + type: string + required: + - profile + - url + type: object + type: + description: Type selects the trust-bundle source. + enum: + - bundle_endpoint + - workload_api + type: string + workloadAPI: + description: |- + WorkloadAPI selects the local SPIFFE Workload API. Required when Type + is "workload_api". + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: endpoint configuration must be set if and only + if type is 'bundle_endpoint' + rule: 'self.type == ''bundle_endpoint'' ? has(self.endpoint) + : !has(self.endpoint)' + - message: workloadAPI configuration must be set if and + only if type is 'workload_api' + rule: 'self.type == ''workload_api'' ? has(self.workloadAPI) + : !has(self.workloadAPI)' + methods: + description: |- + Methods explicitly enables the supported credential types for this + trust domain. + items: + description: |- + SPIFFEAuthenticationMethod identifies the credential type permitted for a + SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; + runtime parsing remains authoritative. + enum: + - spiffe_x509 + - spiffe_jwt + type: string + maxItems: 2 + minItems: 1 + type: array + x-kubernetes-list-type: set + name: + description: |- + Name uniquely identifies this declaration and is referenced by + inboundGrants.spiffeClientAuth[].trustDomainRef. + maxLength: 253 + minLength: 1 + type: string + trustDomain: + description: |- + TrustDomain is the SPIFFE trust domain accepted by this declaration. + This pattern is a best-effort CRD-level approximation of the SPIFFE + trust-domain grammar; runtime parsing via + spiffeid.TrustDomainFromString remains authoritative. + maxLength: 255 + minLength: 1 + pattern: ^([a-z0-9_]|[a-z0-9_]([a-z0-9_-]|\.[a-z0-9_-])*[a-z0-9_])$ + type: string + required: + - bundleSource + - methods + - name + - trustDomain + type: object + maxItems: 50 + minItems: 1 + type: array + x-kubernetes-list-type: atomic storage: description: |- Storage configures the storage backend for the embedded auth server. @@ -6338,7 +6849,62 @@ spec: > 0) || (has(self.delegateClients) && size(self.delegateClients) > 0) || (has(self.trustedIssuers) && self.trustedIssuers.exists(issuer, has(issuer.jwtBearerGrant))) || (has(self.inboundGrants) && (has(self.inboundGrants.tokenExchange) - || has(self.inboundGrants.jwtBearer))) + || has(self.inboundGrants.jwtBearer) || (has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0))) + - message: spiffeTrustDomains and inboundGrants.spiffeClientAuth must + be configured together + rule: ((has(self.spiffeTrustDomains) && size(self.spiffeTrustDomains) + > 0) == (has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && size(self.inboundGrants.spiffeClientAuth) > 0)) + - message: spiffeTrustDomains must not contain duplicate names + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.name == domain.name).size() + == 1)' + - message: spiffeTrustDomains must not contain duplicate trust domains + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + self.spiffeTrustDomains.filter(other, other.trustDomain == domain.trustDomain).size() + == 1)' + - message: every SPIFFE trust domain must be referenced by a SPIFFE + client + rule: '!has(self.spiffeTrustDomains) || self.spiffeTrustDomains.all(domain, + has(self.inboundGrants) && has(self.inboundGrants.spiffeClientAuth) + && self.inboundGrants.spiffeClientAuth.exists(client, client.trustDomainRef + == domain.name))' + - message: every SPIFFE client trustDomainRef must reference a declared + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, has(self.spiffeTrustDomains) + && self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef))' + - message: spiffeClientAuth methods must be enabled by the referenced + trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.methods.all(method, method in domain.methods)))' + - message: spiffeClientAuth principalPattern trust domain must match + the referenced trust domain + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !has(self.spiffeTrustDomains) + || !self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef) + || self.spiffeTrustDomains.exists(domain, domain.name == client.trustDomainRef + && client.principalPattern.startsWith(''spiffe://'' + domain.trustDomain + + ''/'')))' + - message: spiffeClientAuth must not contain duplicate client IDs + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.clientId == client.clientId).size() == 1)' + - message: spiffeClientAuth must not contain duplicate principal patterns + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, self.inboundGrants.spiffeClientAuth.filter(other, + other.principalPattern == client.principalPattern).size() == 1)' + - message: 'spiffeClientAuth clientId must not use the reserved synthetic: + prefix' + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.startsWith(''synthetic:''))' + - message: spiffeClientAuth clientId must not be an absolute URL + rule: '!has(self.inboundGrants) || !has(self.inboundGrants.spiffeClientAuth) + || self.inboundGrants.spiffeClientAuth.all(client, !client.clientId.matches(''^[A-Za-z][A-Za-z0-9+.-]*://.+''))' - message: insecureAllowHTTP cannot be combined with confidential client registration or delegateClients; client secrets would be issued or used in cleartext over an unauthenticated endpoint diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index ed5942484d..536ef83d3c 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1979,6 +1979,7 @@ _Appears in:_ | `signingKeySecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
Supports key rotation by allowing multiple keys (oldest keys are used for verification only).
If not specified, an ephemeral signing key will be auto-generated (development only -
JWTs will be invalid after restart). | | MaxItems: 5
Optional: \{\}
| | `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
authorization codes and refresh tokens (opaque tokens).
Current secret must be at least 32 bytes and cryptographically random.
Supports secret rotation via multiple entries (first is current, rest are for verification).
If not specified, an ephemeral secret will be auto-generated (development only -
auth codes and refresh tokens will be invalid after restart). | | Optional: \{\}
| | `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.
If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\}
| +| `spiffeTrustDomains` _[api.v1beta1.SPIFFETrustDomainConfig](#apiv1beta1spiffetrustdomainconfig) array_ | SPIFFETrustDomains declares SPIFFE trust domains for
inboundGrants.spiffeClientAuth associations. See SPIFFETrustDomainConfig's
doc comment for why declaring a domain does not by itself enable
authentication in this build. | | MaxItems: 50
MinItems: 1
Optional: \{\}
| | `inboundGrants` _[api.v1beta1.InboundGrantsConfig](#apiv1beta1inboundgrantsconfig)_ | InboundGrants configures canonical inbound OAuth grant families. | | Optional: \{\}
| | `upstreamProviders` _[api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.
When configured, the embedded auth server delegates interactive authentication
to these providers. It may be omitted only when delegateClients or a trusted
issuer with jwtBearerGrant enables token-only operation.
MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | Optional: \{\}
| | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
| @@ -2321,6 +2322,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | +| `spiffeClientAuth` _[api.v1beta1.SPIFFEClientConfig](#apiv1beta1spiffeclientconfig) array_ | SPIFFEClientAuth associates SPIFFE principal patterns with explicit
OAuth client identities and permissions. A sibling of TokenExchange and
JWTBearer below, not nested under either: client authentication does
not by itself confer a grant. See SPIFFEClientConfig. | | MaxItems: 100
Optional: \{\}
| | `tokenExchange` _[api.v1beta1.TokenExchangeInboundGrantConfig](#apiv1beta1tokenexchangeinboundgrantconfig)_ | TokenExchange configures RFC 8693 clients and issuer policies. | | Optional: \{\}
| | `jwtBearer` _[api.v1beta1.JWTBearerInboundGrantConfig](#apiv1beta1jwtbearerinboundgrantconfig)_ | JWTBearer configures RFC 7523 issuer policies. | | Optional: \{\}
| @@ -4149,6 +4151,171 @@ _Appears in:_ | `priority` _integer_ | Priority determines evaluation order (lower values = higher priority)
Allows fine-grained control over role selection precedence
When omitted, this mapping has the lowest possible priority and
configuration order acts as tie-breaker via stable sort | | Minimum: 0
Optional: \{\}
| +#### api.v1beta1.SPIFFEAuthenticationMethod + +_Underlying type:_ _string_ + +SPIFFEAuthenticationMethod identifies the credential type permitted for a +SPIFFE workload. Mirrors authserver.SPIFFEAuthenticationMethod field-for-field; +runtime parsing remains authoritative. + + + +_Appears in:_ +- [api.v1beta1.SPIFFEClientConfig](#apiv1beta1spiffeclientconfig) +- [api.v1beta1.SPIFFETrustDomainConfig](#apiv1beta1spiffetrustdomainconfig) + +| Field | Description | +| --- | --- | +| `spiffe_x509` | SPIFFEAuthenticationMethodX509 authenticates a workload with an X.509-SVID.
| +| `spiffe_jwt` | SPIFFEAuthenticationMethodJWT authenticates a workload with a JWT-SVID.
| + + +#### api.v1beta1.SPIFFEBundleEndpointProfile + +_Underlying type:_ _string_ + +SPIFFEBundleEndpointProfile identifies how a SPIFFE Bundle Endpoint's TLS +connection is authenticated. Mirrors authserver.SPIFFEBundleEndpointProfile. + + + +_Appears in:_ +- [api.v1beta1.SPIFFEBundleEndpointSourceConfig](#apiv1beta1spiffebundleendpointsourceconfig) + +| Field | Description | +| --- | --- | +| `https_web` | SPIFFEBundleEndpointProfileHTTPSWeb authenticates the bundle endpoint's
TLS connection with a Web PKI certificate.
| +| `https_spiffe` | SPIFFEBundleEndpointProfileHTTPSSPIFFE authenticates the bundle
endpoint's TLS connection with a separately distributed X.509-SVID root.
| + + +#### api.v1beta1.SPIFFEBundleEndpointSourceConfig + + + +SPIFFEBundleEndpointSourceConfig declares a HTTPS SPIFFE Bundle Endpoint. + + + +_Appears in:_ +- [api.v1beta1.SPIFFEBundleSourceConfig](#apiv1beta1spiffebundlesourceconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `url` _string_ | URL is the HTTPS SPIFFE Bundle Endpoint URL. | | MaxLength: 2048
MinLength: 1
Required: \{\}
| +| `profile` _[api.v1beta1.SPIFFEBundleEndpointProfile](#apiv1beta1spiffebundleendpointprofile)_ | Profile selects how the endpoint's TLS connection is authenticated:
SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or
SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed
X.509-SVID root). | | Enum: [https_web https_spiffe]
Required: \{\}
| + + +#### api.v1beta1.SPIFFEBundleSourceConfig + + + +SPIFFEBundleSourceConfig is a discriminated bundle-source declaration. Type +determines which, and only which, source payload may be set. It is +validated for shape only; fetching or loading a bundle from the declared +source is not implemented yet. + + + +_Appears in:_ +- [api.v1beta1.SPIFFETrustDomainConfig](#apiv1beta1spiffetrustdomainconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _[api.v1beta1.SPIFFEBundleSourceType](#apiv1beta1spiffebundlesourcetype)_ | Type selects the trust-bundle source. | | Enum: [bundle_endpoint workload_api]
Required: \{\}
| +| `endpoint` _[api.v1beta1.SPIFFEBundleEndpointSourceConfig](#apiv1beta1spiffebundleendpointsourceconfig)_ | Endpoint declares a HTTPS SPIFFE Bundle Endpoint. Required when Type is
"bundle_endpoint". | | Optional: \{\}
| +| `workloadAPI` _[api.v1beta1.SPIFFEWorkloadAPIBundleSourceConfig](#apiv1beta1spiffeworkloadapibundlesourceconfig)_ | WorkloadAPI selects the local SPIFFE Workload API. Required when Type
is "workload_api". | | Optional: \{\}
| + + +#### api.v1beta1.SPIFFEBundleSourceType + +_Underlying type:_ _string_ + +SPIFFEBundleSourceType identifies the selected trust-bundle source. Mirrors +authserver.SPIFFEBundleSourceType. + + + +_Appears in:_ +- [api.v1beta1.SPIFFEBundleSourceConfig](#apiv1beta1spiffebundlesourceconfig) + +| Field | Description | +| --- | --- | +| `bundle_endpoint` | SPIFFEBundleSourceTypeEndpoint selects a HTTPS SPIFFE Bundle Endpoint.
| +| `workload_api` | SPIFFEBundleSourceTypeWorkloadAPI selects the local SPIFFE Workload API.
| + + +#### api.v1beta1.SPIFFEClientConfig + + + +SPIFFEClientConfig associates one SPIFFE principal pattern from a declared +trust domain with an explicit OAuth client identity and permissions. +Configuration is not authentication: configured SPIFFE clients remain +non-public OAuth clients without a secret until live SPIFFE credential +validation is implemented (see SPIFFETrustDomainConfig's doc comment). + +GrantTypes is deliberately not exposed here: the runtime only accepts +exactly the RFC 8693 token-exchange grant for a SPIFFE client +(validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so the converter +always supplies it instead of letting it be configured. + + + +_Appears in:_ +- [api.v1beta1.InboundGrantsConfig](#apiv1beta1inboundgrantsconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `trustDomainRef` _string_ | TrustDomainRef references spiffeTrustDomains[].name. | | MaxLength: 253
MinLength: 1
Required: \{\}
| +| `principalPattern` _string_ | PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within
the declared trust domain. This pattern is a best-effort CRD-level
approximation; runtime parsing via spiffeid.FromString remains
authoritative. | | MaxLength: 2048
Pattern: `^spiffe://[a-z0-9._-]+((/[a-zA-Z0-9._-]+)+(/\*)?\|/\*)$`
Required: \{\}
| +| `clientId` _string_ | ClientID is the explicit OAuth client_id. It is never derived from a
SPIFFE ID. | | MaxLength: 253
MinLength: 1
Required: \{\}
| +| `methods` _[api.v1beta1.SPIFFEAuthenticationMethod](#apiv1beta1spiffeauthenticationmethod) array_ | Methods are the credential types this association may authenticate
with. Must be a subset of the referenced trust domain's methods. | | MaxItems: 2
MinItems: 1
Required: \{\}
items:Enum: [spiffe_x509 spiffe_jwt]
| +| `resources` _string array_ | Resources are RFC 8707 resource indicators this association may
request. Must be a subset of the server's allowed_audiences allowlist,
which is derived at reconcile time and not available on this CRD, so
allowlist membership is validated at reconcile time, not admission.
Shape (a well-formed absolute HTTP(S) URI) is independent of that
derived allowlist and is validated here. Distinct from Audiences: a
resource permission does not imply the same value is also a permitted
token audience, or vice versa. | | MaxItems: 50
items:MaxLength: 2048
items:MinLength: 1
items:Pattern: `^https?://[^@#[:space:]]+$`
Optional: \{\}
| +| `audiences` _string array_ | Audiences are RFC 8693 token audiences this association may request. | | MaxItems: 50
MinItems: 1
Required: \{\}
items:MaxLength: 2048
items:MinLength: 1
| +| `scopes` _string array_ | Scopes are OAuth scopes granted to this association. Must be a subset
of the server's effective supported scopes. | | MaxItems: 50
MinItems: 1
Required: \{\}
items:MaxLength: 256
items:MinLength: 1
| + + +#### api.v1beta1.SPIFFETrustDomainConfig + + + +SPIFFETrustDomainConfig declares one SPIFFE trust domain accepted by the +embedded authorization server. Configuration is not authentication: no +live X.509-SVID or JWT-SVID validation exists yet, so a declared trust +domain does not by itself let any workload authenticate — RunConfig.Validate +(pkg/authserver/config.go) currently hard-rejects any non-empty +spiffeTrustDomains at authserver startup via validateSPIFFENotYetEnforced, +a deliberate placeholder until real SVID verification lands. + + + +_Appears in:_ +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name uniquely identifies this declaration and is referenced by
inboundGrants.spiffeClientAuth[].trustDomainRef. | | MaxLength: 253
MinLength: 1
Required: \{\}
| +| `trustDomain` _string_ | TrustDomain is the SPIFFE trust domain accepted by this declaration.
This pattern is a best-effort CRD-level approximation of the SPIFFE
trust-domain grammar; runtime parsing via
spiffeid.TrustDomainFromString remains authoritative. | | MaxLength: 255
MinLength: 1
Pattern: `^([a-z0-9_]\|[a-z0-9_]([a-z0-9_-]\|\.[a-z0-9_-])*[a-z0-9_])$`
Required: \{\}
| +| `methods` _[api.v1beta1.SPIFFEAuthenticationMethod](#apiv1beta1spiffeauthenticationmethod) array_ | Methods explicitly enables the supported credential types for this
trust domain. | | MaxItems: 2
MinItems: 1
Required: \{\}
items:Enum: [spiffe_x509 spiffe_jwt]
| +| `bundleSource` _[api.v1beta1.SPIFFEBundleSourceConfig](#apiv1beta1spiffebundlesourceconfig)_ | BundleSource declares exactly one future trust-bundle source. It is
validated for shape only; fetching or loading a bundle from it is a
later step. | | Required: \{\}
| + + +#### api.v1beta1.SPIFFEWorkloadAPIBundleSourceConfig + + + +SPIFFEWorkloadAPIBundleSourceConfig selects the local SPIFFE Workload API. +It deliberately has no payload; loading and deployment details are +deferred to the bundle-loading implementation. + + + +_Appears in:_ +- [api.v1beta1.SPIFFEBundleSourceConfig](#apiv1beta1spiffebundlesourceconfig) + + + #### api.v1beta1.SecretKeyRef diff --git a/pkg/authserver/spiffe_trust.go b/pkg/authserver/spiffe_trust.go index 000bed6eff..50250642da 100644 --- a/pkg/authserver/spiffe_trust.go +++ b/pkg/authserver/spiffe_trust.go @@ -487,21 +487,28 @@ func validateSPIFFEBundleSource(source SPIFFEBundleSourceRunConfig, index int) e } func validateSPIFFEBundleEndpoint(endpoint SPIFFEBundleEndpointSourceRunConfig, index int) error { + if err := ValidateSPIFFEBundleEndpoint(endpoint); err != nil { + return fmt.Errorf("spiffe_trust_domains[%d].bundle_source.endpoint: %w", index, err) + } + return nil +} + +// ValidateSPIFFEBundleEndpoint validates a SPIFFE Bundle Endpoint URL and its +// TLS-authentication profile. Exported so the operator CRD admission path +// can reject the same structurally invalid endpoints at admission time +// instead of only at reconcile time; fetching or loading a bundle from the +// endpoint remains a separate, later step. +func ValidateSPIFFEBundleEndpoint(endpoint SPIFFEBundleEndpointSourceRunConfig) error { endpointURL := endpoint.URL u, err := url.ParseRequestURI(endpointURL) if err != nil || u.Scheme != "https" || u.Host == "" || u.Hostname() == "" { - return fmt.Errorf( - "spiffe_trust_domains[%d].bundle_source.endpoint.url must be an absolute HTTPS URL with a valid authority", - index, - ) + return fmt.Errorf("url must be an absolute HTTPS URL with a valid authority") } if u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Contains(endpointURL, "?") || strings.Contains(endpointURL, "#") || net.ParseIP(u.Hostname()) != nil || networking.IsLoopbackHost(u.Hostname()) { return fmt.Errorf( - "spiffe_trust_domains[%d].bundle_source.endpoint.url must not contain credentials, query, "+ - "fragment, an IP-literal host, or a loopback host", - index, + "url must not contain credentials, query, fragment, an IP-literal host, or a loopback host", ) } switch endpoint.Profile { @@ -509,8 +516,7 @@ func validateSPIFFEBundleEndpoint(endpoint SPIFFEBundleEndpointSourceRunConfig, return nil default: return fmt.Errorf( - "spiffe_trust_domains[%d].bundle_source.endpoint.profile must be %q or %q", - index, SPIFFEBundleEndpointProfileHTTPSWeb, SPIFFEBundleEndpointProfileHTTPSSPIFFE, + "profile must be %q or %q", SPIFFEBundleEndpointProfileHTTPSWeb, SPIFFEBundleEndpointProfileHTTPSSPIFFE, ) } } @@ -710,7 +716,7 @@ func validateSPIFFEResources(values []string, field string, allowedResources []s if len(values) == 0 { return nil } - if err := validateResourceIndicators(values, field); err != nil { + if err := ValidateResourceIndicators(values, field); err != nil { return err } for _, value := range values { @@ -721,7 +727,13 @@ func validateSPIFFEResources(values []string, field string, allowedResources []s return nil } -func validateResourceIndicators(values []string, field string) error { +// ValidateResourceIndicators validates the context-independent shape of RFC +// 8707 resource indicators: each must be a syntactically valid absolute +// HTTP(S) URI with a non-empty host, no userinfo, and no fragment. It does +// not check allowlist membership, which needs the reconcile-time-derived +// allowed_audiences value (see validateSPIFFEResources and +// MCPExternalAuthConfig's own admission-time call for this shape-only half). +func ValidateResourceIndicators(values []string, field string) error { if len(values) == 0 { return nil } @@ -730,7 +742,7 @@ func validateResourceIndicators(values []string, field string) error { } for _, value := range values { u, err := url.ParseRequestURI(value) - if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil || u.Fragment != "" || strings.Contains(value, "#") { return fmt.Errorf("%s: resource indicator %q must be an absolute HTTP(S) URI without a fragment", field, value) } @@ -774,6 +786,35 @@ func overlapsSPIFFEPatterns(patterns []string, principal string) bool { return false } +// ValidateSPIFFEPrincipalPattern validates a single principal pattern — a +// concrete SPIFFE ID or a terminal /* wildcard — using the same +// normalization as the runtime association validator, without comparing it +// to any other pattern. Exported so an admission-time caller can validate +// each entry in a set up front and attribute a normalization failure to the +// correct entry, before running SPIFFEPatternsOverlap pairwise across the set. +func ValidateSPIFFEPrincipalPattern(pattern string) error { + _, err := normalizeSPIFFEPrincipal(pattern, true) + return err +} + +// SPIFFEPatternsOverlap reports whether two principal patterns — each a +// concrete SPIFFE ID or a terminal /* wildcard — overlap, using the same +// normalization (via the go-spiffe parser) and prefix semantics as the +// runtime association validator. Exported so the operator CRD admission +// path can reject overlapping principal patterns (e.g. "/agent/*" and +// "/agent/one") at admission time instead of only at reconcile time. +func SPIFFEPatternsOverlap(first, second string) (bool, error) { + normalizedFirst, err := normalizeSPIFFEPrincipal(first, true) + if err != nil { + return false, err + } + normalizedSecond, err := normalizeSPIFFEPrincipal(second, true) + if err != nil { + return false, err + } + return spiffePatternsOverlap(normalizedFirst, normalizedSecond), nil +} + func spiffePatternsOverlap(first, second string) bool { firstWildcard := strings.HasSuffix(first, "/*") secondWildcard := strings.HasSuffix(second, "/*") diff --git a/pkg/authserver/spiffe_trust_test.go b/pkg/authserver/spiffe_trust_test.go index 5d2db71fa3..471f1d6729 100644 --- a/pkg/authserver/spiffe_trust_test.go +++ b/pkg/authserver/spiffe_trust_test.go @@ -152,6 +152,15 @@ func TestValidateSPIFFETrust(t *testing.T) { {name: "resource indicator must be an absolute HTTP(S) URI", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { grants.SPIFFEClientAuth[0].Resources = []string{"not-a-uri"} }, wantErr: "must be an absolute HTTP(S) URI"}, + {name: "resource indicator with empty-host authority is rejected", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Resources = []string{"https://:443/resource"} + }, wantErr: "must be an absolute HTTP(S) URI"}, + {name: "resource indicator with userinfo is rejected", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Resources = []string{"https://user@mcp.example.org/resource"} + }, wantErr: "must be an absolute HTTP(S) URI"}, + {name: "resource indicator with fragment is rejected", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Resources = []string{"https://mcp.example.org/resource#fragment"} + }, wantErr: "must be an absolute HTTP(S) URI"}, {name: "resource in global allowlist is valid", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { grants.SPIFFEClientAuth[0].Resources = []string{"https://mcp.example.org/resource"} }},