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/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index d6047faa93..6fb37f7e04 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -238,6 +238,8 @@ MCPExternalAuthConfig resources can be referenced via two paths: **Controller**: `cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go` +`EmbeddedAuthServerConfig` can also carry top-level SPIFFE trust-domain declarations in `spiffeTrustDomains` and canonical client associations in `inboundGrants.spiffeClientAuth` (`cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go`). The operator converts these CRD fields into runtime config via `buildSPIFFETrustDomainRunConfigs`/`buildSPIFFEClientAuthRunConfigs` in `cmd/thv-operator/pkg/controllerutil/authserver.go`, which flow into the RunConfig delivered to the proxy-runner alongside the rest of the embedded auth server settings. See [SPIFFE Association Declarations](18-spiffe-association-declarations.md) for the full configuration and policy model — **any non-empty configuration here is currently rejected by `RunConfig.Validate()` before the runner starts**, pending real SVID verification; the CRD fields, conversion, and runtime model exist, but this is not yet a deployable feature. + ### MCPOIDCConfig Defines shared OIDC provider configuration that can be referenced by multiple workload CRDs (MCPServer, MCPRemoteProxy, VirtualMCPServer) in the same namespace. diff --git a/docs/arch/11-auth-server-storage.md b/docs/arch/11-auth-server-storage.md index d753cc6a15..70197d3859 100644 --- a/docs/arch/11-auth-server-storage.md +++ b/docs/arch/11-auth-server-storage.md @@ -431,3 +431,19 @@ All call sites use `unwrapStorage(stor)` or the equivalent JWT-bearer constructi When the embedded authorization server is deployed in an environment that cannot reach `https://toolhive.dev/oauth/client-metadata.json` or any public CIMD metadata URL, set `authServer.cimd.enabled: false`. Clients will fall back to DCR (`/oauth/register`) which uses only the local storage backend and requires no outbound connectivity. **Implementation:** `pkg/authserver/storage/cimd_decorator.go` + +## SPIFFE Storage Decorator + +**Current status: not reachable in this build.** `RunConfig.Validate()` rejects any non-empty `spiffeTrustDomains`/`inboundGrants.spiffeClientAuth` before the authorization-server runner is created (`validateSPIFFENotYetEnforced` in `pkg/authserver/config.go`; `pkg/authserver/runner/embeddedauthserver.go`), so storage creation and decorator installation never happen. The decorator, overlay, and durable-reservation behavior below is the design this epic has implemented and tested in isolation, not current operational behavior — see [SPIFFE Association Declarations](18-spiffe-association-declarations.md) for the full status. + +When top-level `spiffeTrustDomains` and `inboundGrants.spiffeClientAuth` are configured, the embedded authorization server wraps its storage backend in a `SPIFFEStorageDecorator` — installed as the outermost decorator, after CIMD (`decorateStorageForSPIFFE` in `pkg/authserver/server_impl.go`). This decorator overlays a fixed set of statically configured OAuth clients ahead of the dynamic DCR/CIMD backend. These declarations register associations and clients; the current server does not yet verify live X.509-SVIDs or JWT-SVIDs. + +### What it does + +`SPIFFEStorageDecorator` embeds the full `storage.Storage` interface and overrides `GetClient`, `RegisterClient`, and `ReconcileConfiguredClient`. `GetClient` checks its static client map first and only falls through to the wrapped storage (CIMD, then DCR) when the requested client ID is not one of the configured associations. `RegisterClient` and `ReconcileConfiguredClient` reject any DCR, delegate-client, or configured-client attempt that targets a client ID reserved by a static SPIFFE association. CIMD never calls `RegisterClient`; it durably persists resolved clients via `UpsertDCRIssuedClient` instead (a best-effort write-through for token-endpoint session rehydration — see the CIMD section above), which explicitly refuses to clobber a configured/SPIFFE-reconciled client at the same ID, so it cannot collide with a static association. + +Its clients come entirely from the configured SPIFFE trust-domain and client-association declarations (see [SPIFFE Association Declarations](18-spiffe-association-declarations.md)). They are built once at startup, held in memory, and never written to the storage backend (memory or Redis); they are never eligible for dynamic registration or replacement. + +At startup, the decorator durably claims each configured static client ID in the storage backend (memory or Redis) via `ReconcileConfiguredClient` (`preflightDurableCollisions`), using an inert placeholder rather than the real client. This is create-only for anything except a matching restart: it succeeds when the ID is unclaimed or already holds a matching placeholder from a prior run with the same configuration, and fails — refusing to start the server — when the ID is DCR-issued or holds a placeholder for a *different* association. This closes a cross-replica race that a read-only `GetClient` check alone cannot: with Redis and multiple replicas, an older or still-rolling replica without this SPIFFE config could otherwise DCR-register the same client ID after a newer replica's read-only check passed. The reverse collision can't happen: the decorator's `GetClient` always checks its static map first, so a durable client can never shadow a static one. + +**Implementation:** `pkg/authserver/storage/spiffe_decorator.go` diff --git a/docs/arch/18-spiffe-association-declarations.md b/docs/arch/18-spiffe-association-declarations.md new file mode 100644 index 0000000000..cbed41457e --- /dev/null +++ b/docs/arch/18-spiffe-association-declarations.md @@ -0,0 +1,101 @@ +# SPIFFE Association Declarations + +**Current status: rejected at startup, not deployable yet.** `RunConfig.Validate()` hard-fails on any non-empty `spiffeTrustDomains`/`inboundGrants.spiffeClientAuth` before the runner is even created (`validateSPIFFENotYetEnforced` in `pkg/authserver/config.go`) — nothing in this build ever verifies an X.509-SVID or JWT-SVID against a configured trust bundle, so accepting the configuration silently would let an operator believe SPIFFE client authentication is active when no credential is ever checked. The model, registry, and storage decorator described below exist in code and are covered by tests, but none of it runs against a real deployment today; this document describes the design a future PR will enable once real SVID verification lands, not current operational behavior. + +The embedded authorization server can carry a **configuration-only** SPIFFE association model across the `RunConfig` boundary. This model registers associations and static OAuth clients for workloads that may later authenticate with SPIFFE. It does not currently verify live X.509-SVIDs or JWT-SVIDs, and it does not fetch or load a trust bundle. + +## Model and boundaries + +Configuration separates top-level trust declarations from canonical client associations: + +- `spiffe_trust_domains` in `RunConfig` (`spiffeTrustDomains` in the CRD) names a canonical SPIFFE trust domain, explicitly lists permitted future methods (`spiffe_x509` and/or `spiffe_jwt`), and declares a future trust-bundle source. +- `inbound_grants.spiffe_client_auth` in `RunConfig` (`inboundGrants.spiffeClientAuth` in the CRD) associates an exact SPIFFE ID or a terminal `/*` descendant `principalPattern` with one explicit OAuth `client_id`, methods, resources, audiences, and scopes. This is a sibling of `inbound_grants.token_exchange` and `inbound_grants.jwt_bearer`, not nested under either — client authentication does not by itself confer a grant. + +The OAuth client ID is configured explicitly; it is never derived from the SPIFFE ID or pattern. Every SPIFFE client implicitly receives only the RFC 8693 token-exchange grant. The standalone `RunConfig` schema (`SPIFFEClientAuthRunConfig.GrantTypes`) does expose a `grant_types` field, but validation restricts it to that single value; the CRD omits it entirely and the operator's conversion always synthesizes it (`cmd/thv-operator/pkg/controllerutil/authserver.go`). + +### Resources and audiences are separate dimensions + +Each association can configure two independent request dimensions: + +- `audiences` are RFC 8693 token audiences. They are not bounded by the server's `allowed_audiences` allowlist and may contain non-URI logical identifiers. +- `resources` are RFC 8707 resource indicators. Each one must be a syntactically valid absolute HTTP(S) URI, and each must also be a member of the server's `allowed_audiences` allowlist (`RunConfig.AllowedAudiences`) — the same allowlist `DelegateClientRunConfig.Audiences` is validated against. + +Permission in one dimension never implies permission in the other: a value allowed as a `resource` is not automatically a permitted `audience`, and vice versa. `resources` is optional; `audiences` is required. + +`resources` is validated for shape and allowlist membership at startup, and the runtime OAuth client built for a SPIFFE association (`registration.NewSPIFFEClient`) is constructed from `scopes`, `audiences`, and `resources` — `Resources()` and `GetAudience()` are checked independently during a token-exchange request, matching the `audiences`/`resources` dimension split above. + +### Trust-bundle source (declared, not yet used) + +Every trust domain must declare exactly one `bundle_source`, a discriminated union naming where a future bundle loader would get the trust bundle from. It is validated for shape only — nothing fetches or loads a bundle from it yet: + +- `type: bundle_endpoint` requires an `endpoint` block with: + - `url`: an absolute HTTPS URL with no userinfo, query string, or fragment; the host must not be an IP literal and must not be a loopback address. + - `profile`: either `https_web` (the endpoint's TLS connection is authenticated with a Web PKI certificate) or `https_spiffe` (authenticated with an X.509-SVID trusted by a separately distributed root), per the SPIFFE Bundle Endpoint profiles. +- `type: workload_api` selects the local SPIFFE Workload API and carries no payload. + +The following canonical operator excerpt shows the supported shape. It illustrates the configuration schema only — as noted above, `RunConfig.Validate()` currently rejects any non-empty `spiffeTrustDomains`, so this is not yet deployable as-is. `allowedAudiences` is intentionally absent here: it is not a configurable field on `embeddedAuthServer` — it is derived at reconcile time from the resolved incoming OIDC configuration. + +```yaml +spec: + type: embeddedAuthServer + embeddedAuthServer: + issuer: https://auth.example.com + spiffeTrustDomains: + - name: production + trustDomain: example.org + methods: [spiffe_x509, spiffe_jwt] + bundleSource: + type: bundle_endpoint + endpoint: + url: https://bundle.example.org/spiffe + profile: https_web + inboundGrants: + spiffeClientAuth: + - trustDomainRef: production + principalPattern: spiffe://example.org/workloads/reporting/* + clientId: reporting-workloads + methods: [spiffe_x509] + resources: [https://mcp.example.com] + audiences: [https://mcp.example.com] + scopes: [openid] +``` + +An association is valid only when it references a declared domain, its `principalPattern` belongs to that domain, and its methods are enabled by that domain. A principal consisting only of a trust domain, such as `spiffe://example.org`, is invalid because it cannot match an SVID. Patterns may be exact or end in `/*`; the domain-wide `spiffe://example.org/*` wildcard remains valid. A wildcard matches descendants at a path boundary only, not its base path or a partial segment. Each pattern and client ID has one owner. Duplicate or overlapping patterns, duplicate client IDs, unknown domains, disabled methods, an unreferenced trust domain, and invalid or incomplete policies all cause startup validation to fail rather than relying on configuration order. + +## Static OAuth client registry + +At authorization-server startup, validated associations build an immutable registry and static OAuth-client overlay (`SPIFFEStorageDecorator`), installed as the outermost storage decorator — after CIMD (`decorateStorageForSPIFFE` in `pkg/authserver/server_impl.go`). Its clients are configuration-only: they are held in memory and never written to the storage backend (memory or Redis), cannot be registered or replaced dynamically through `/oauth/register`, and retain only the association's configured policy. `GetClient` checks this static map first and only falls through to the dynamic backend (CIMD, then DCR) when the requested client ID is not one of the configured associations, so a durable client can never shadow a static one. `RegisterClient` rejects any DCR or delegate-client registration attempt that targets a reserved static client ID. An unknown `spiffe://` client ID does not trigger CIMD resolution. + +### Startup collision handling + +Startup does not simply refuse to start whenever a client with a static ID already exists in durable storage. Each configured static client ID is durably claimed through `ReconcileConfiguredClient` (`preflightDurableCollisions` in `pkg/authserver/storage/spiffe_decorator.go`), which is create-only for anything except a matching restart: + +- If no client is stored at that ID, it creates an inert placeholder and starts normally. +- If a placeholder with the same configured association (same scopes, audiences, resources, and SPIFFE identity) already exists — the restart-with-unchanged-configuration case — reconciliation succeeds idempotently. +- If the existing record is DCR-issued, or is a configured client with a different fingerprint (a *different* association reusing the same client ID), reconciliation fails and the server refuses to start. + +The durably-claimed record is always an inert placeholder — a client with no grant types and no response types, so it can never itself be issued a token — never the real SPIFFE client with its configured scopes and audiences. This durable claim, not just an in-process `GetClient` check, closes a cross-replica race: with Redis and multiple replicas, an older or still-rolling replica without this SPIFFE config could otherwise DCR-register the same client ID after a newer replica's read-only check passed. Claiming the ID durably makes the reservation visible to every replica immediately. + +On every startup, the server reconstructs the static registry and its overlay from serialized configuration. A restart with the same configuration produces the same associations and reconciles cleanly against the previous run's placeholders. A **changed** association (a different fingerprint — scopes, audiences, resources, grant types, response types, or SPIFFE identity — at the same client ID) does not take effect: `ReconcileConfiguredClient` fails and the server refuses to start, exactly as described under "Startup collision handling" above. A **removed** association's active policy does take effect on a successful restart — the in-memory overlay is rebuilt from the current configuration, so a client with no matching association is no longer served as a static client. What does *not* clean up is its durable reservation: nothing currently deletes the inert placeholder `ReconcileConfiguredClient` claimed for that client ID, so it persists in storage indefinitely, preventing the ID from being reused by DCR or a delegate client (tracked as [#6477](https://github.com/stacklok/toolhive/issues/6477)). Dynamic clients remain subject to the storage backend's own persistence, but no stale static client is restored from storage — the in-memory overlay's clients always come from the current configuration, never from a prior run's storage state. + +## Security and delivery scope + +Configuration is not authentication. In particular, a client ID, a declared association, a request header, an unverified SPIFFE-looking URI, or a client-supplied trust domain is never workload identity. Until credential validation is implemented, configured SPIFFE clients remain non-public OAuth clients without a secret and token requests cannot authenticate through these declarations. + +Configured SPIFFE associations establish configuration, policy, and static-client ownership only. They do **not**: + +- fetch, load, or rotate a trust bundle, even though a `bundle_source` is declared; +- authenticate workloads with X.509-SVIDs or JWT-SVIDs; +- authenticate token requests or issue tokens through SPIFFE; +- pair users with applications; +- advertise discovery metadata for SPIFFE methods; +- deploy SPIRE or mount Workload API sockets; or +- claim full SPIFFE interoperability or end-to-end coverage. + +Future credential-validation code must establish identity from validated SVIDs and then resolve that verified identity through this registry. It must fail closed for missing associations, client-ID ownership mismatches, unknown trust domains, and methods not enabled by policy. + +## Related documentation + +- [Auth Server Storage Architecture](11-auth-server-storage.md) — dynamic storage and CIMD behavior below the static overlay +- [Kubernetes Operator Architecture](09-operator-architecture.md) — operator-to-runner configuration boundary +- [External Subject-Token Exchange](17-token-exchange-delegation.md) — separate RFC 8693 delegation trust model diff --git a/docs/arch/README.md b/docs/arch/README.md index 956f43772d..f98ef659ea 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -141,6 +141,12 @@ Welcome to the ToolHive architecture documentation. This directory contains comp - Accepted limitations: client-set equivalence, disjoint subject namespaces, partial provenance - Operational gotchas: audience/scope binding, discovery redirects, JWKS caching, diagnostics +18. **[SPIFFE Association Declarations](18-spiffe-association-declarations.md)** + - Not yet deployable: rejected at startup pending real SVID verification + - Configuration-only SPIFFE trust, association, and static-client model + - Fail-closed policy validation and durable, restart-safe static-client reservation + - Explicit authentication and bundle-loading delivery boundaries + ### Existing Documentation For middleware architecture, see: **[docs/middleware.md](../middleware.md)** 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"} }},