Skip to content

Commit f57d37e

Browse files
committed
Expose SPIFFE client-auth registration through the operator CRDs
The SPIFFE client-authentication runtime model (trust domains + static workload-to-client associations) already exists in pkg/authserver, but was only reachable by hand-authoring an auth-server RunConfig file -- there was no way to declare it through VirtualMCPServer or MCPExternalAuthConfig. Add SPIFFETrustDomains and InboundGrants.SPIFFEClientAuth to the shared EmbeddedAuthServerConfig CRD type, with CEL admission validation for everything derivable from the object's own spec (paired configuration, no duplicate names/trust domains/client IDs/principal patterns, full cross-referencing between domains and clients, method-subset enforcement, principal/trust-domain consistency, and client-ID hygiene). Wire the new fields through to authserver.RunConfig via the same converter pattern used for DelegateClients/TrustedIssuers, and extend the existing reconcile-time revalidation (validateDelegateClientsAndTrustedIssuers) to cover SPIFFE trust domains too. Cross-field checks that need reconcile-time-derived values (AllowedAudiences, ScopesSupported -- neither is CRD-exposed) are left to that reconcile-time path rather than a premature admission-time Go check: an earlier attempt at that check passed nil for both, which the runtime validator treats as "validate against nothing" rather than "skip", silently rejecting any resources or custom scopes entry. A syntactically valid SPIFFE config is admitted by CEL but still fails reconciliation with a terminal error until RunConfig's "not yet enforced" placeholder gate is lifted by a future PR -- no live SVID verification exists yet, so this is expected, not a regression. Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
1 parent 6dbb23e commit f57d37e

11 files changed

Lines changed: 3384 additions & 14 deletions

cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go

Lines changed: 235 additions & 1 deletion
Large diffs are not rendered by default.

cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,59 @@ func TestMCPExternalAuthConfig_validateEmbeddedAuthServer(t *testing.T) {
779779
expectErr: true,
780780
errMsg: "actor_matcher",
781781
},
782+
{
783+
// validateEmbeddedAuthServer deliberately runs no Go-level SPIFFE
784+
// pre-check at this layer — same precedent as DelegateClients: a
785+
// meaningful resources/scopes check needs AllowedAudiences/
786+
// ScopesSupported, which only exist once derived at reconcile
787+
// time. A resources entry and a non-default scope must therefore
788+
// pass here even though they'd need revalidating once those
789+
// derived values are known (see
790+
// TestBuildAuthServerRunConfigInvalidSPIFFEIsTypedAndNotYetEnforced
791+
// in controllerutil for the reconcile-time revalidation this
792+
// relies on). CEL (spiffe_cel_test.go) covers structural
793+
// correctness (trust-domain refs, method subsets, etc.) at
794+
// admission.
795+
name: "spiffe client with resources and custom scope - valid at this layer",
796+
config: &MCPExternalAuthConfig{
797+
Spec: MCPExternalAuthConfigSpec{
798+
Type: ExternalAuthTypeEmbeddedAuthServer,
799+
EmbeddedAuthServer: &EmbeddedAuthServerConfig{
800+
Issuer: "https://auth.example.com",
801+
UpstreamProviders: []UpstreamProviderConfig{{
802+
Name: "github",
803+
Type: UpstreamProviderTypeOIDC,
804+
OIDCConfig: &OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "client-id"},
805+
}},
806+
SPIFFETrustDomains: []SPIFFETrustDomainConfig{{
807+
Name: "example", TrustDomain: "example.org",
808+
Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509},
809+
BundleSource: SPIFFEBundleSourceConfig{
810+
Type: SPIFFEBundleSourceTypeWorkloadAPI,
811+
WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceConfig{},
812+
},
813+
}},
814+
InboundGrants: &InboundGrantsConfig{
815+
SPIFFEClientAuth: []SPIFFEClientConfig{{
816+
TrustDomainRef: "example",
817+
PrincipalPattern: "spiffe://example.org/ns/default/agent",
818+
ClientID: "spiffe-client",
819+
Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509},
820+
// Not in allowed_audiences and not a default
821+
// scope — would be rejected if the removed
822+
// admission-time pre-check fabricated a nil
823+
// allowlist/default-scopes set instead of
824+
// deferring to reconcile time.
825+
Resources: []string{"https://backend.example.com"},
826+
Audiences: []string{"https://mcp.example.com"},
827+
Scopes: []string{"custom:scope"},
828+
}},
829+
},
830+
},
831+
},
832+
},
833+
expectErr: false,
834+
},
782835
}
783836

784837
for _, tt := range tests {

cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

Lines changed: 125 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/pkg/controllerutil/authserver.go

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,73 @@ func buildJWTBearerGrantPolicy(config *mcpv1beta1.JWTBearerGrantConfig) *tokenex
310310
return policy
311311
}
312312

313+
// buildSPIFFETrustDomainRunConfigs converts CRD SPIFFETrustDomainConfig
314+
// entries to authserver.SPIFFETrustDomainRunConfig, the runtime type
315+
// authserver.RunConfig.SPIFFETrustDomains consumes directly. None of these
316+
// fields reference a Secret, so no env-var indirection is needed here.
317+
func buildSPIFFETrustDomainRunConfigs(
318+
domains []mcpv1beta1.SPIFFETrustDomainConfig,
319+
) []authserver.SPIFFETrustDomainRunConfig {
320+
configs := make([]authserver.SPIFFETrustDomainRunConfig, len(domains))
321+
for i, domain := range domains {
322+
methods := make([]authserver.SPIFFEAuthenticationMethod, len(domain.Methods))
323+
for j, method := range domain.Methods {
324+
methods[j] = authserver.SPIFFEAuthenticationMethod(method)
325+
}
326+
configs[i] = authserver.SPIFFETrustDomainRunConfig{
327+
Name: domain.Name,
328+
TrustDomain: domain.TrustDomain,
329+
Methods: methods,
330+
BundleSource: buildSPIFFEBundleSourceRunConfig(domain.BundleSource),
331+
}
332+
}
333+
return configs
334+
}
335+
336+
// buildSPIFFEBundleSourceRunConfig converts the CRD's discriminated
337+
// bundle-source union to the runtime shape.
338+
func buildSPIFFEBundleSourceRunConfig(source mcpv1beta1.SPIFFEBundleSourceConfig) authserver.SPIFFEBundleSourceRunConfig {
339+
converted := authserver.SPIFFEBundleSourceRunConfig{Type: authserver.SPIFFEBundleSourceType(source.Type)}
340+
if source.Endpoint != nil {
341+
converted.Endpoint = &authserver.SPIFFEBundleEndpointSourceRunConfig{
342+
URL: source.Endpoint.URL,
343+
Profile: authserver.SPIFFEBundleEndpointProfile(source.Endpoint.Profile),
344+
}
345+
}
346+
if source.WorkloadAPI != nil {
347+
converted.WorkloadAPI = &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}
348+
}
349+
return converted
350+
}
351+
352+
// buildSPIFFEClientAuthRunConfigs converts CRD SPIFFEClientConfig entries to
353+
// authserver.SPIFFEClientAuthRunConfig. GrantTypes is not a CRD field: the
354+
// runtime only accepts exactly the RFC 8693 token-exchange grant for a
355+
// SPIFFE client (validateSPIFFEGrants in pkg/authserver/spiffe_trust.go), so
356+
// it is always supplied here rather than configured.
357+
func buildSPIFFEClientAuthRunConfigs(
358+
clients []mcpv1beta1.SPIFFEClientConfig,
359+
) []authserver.SPIFFEClientAuthRunConfig {
360+
configs := make([]authserver.SPIFFEClientAuthRunConfig, len(clients))
361+
for i, spiffeClient := range clients {
362+
methods := make([]authserver.SPIFFEAuthenticationMethod, len(spiffeClient.Methods))
363+
for j, method := range spiffeClient.Methods {
364+
methods[j] = authserver.SPIFFEAuthenticationMethod(method)
365+
}
366+
configs[i] = authserver.SPIFFEClientAuthRunConfig{
367+
TrustDomainRef: spiffeClient.TrustDomainRef,
368+
PrincipalPattern: spiffeClient.PrincipalPattern,
369+
ClientID: spiffeClient.ClientID,
370+
Methods: methods,
371+
Resources: append([]string(nil), spiffeClient.Resources...),
372+
Audiences: append([]string(nil), spiffeClient.Audiences...),
373+
Scopes: append([]string(nil), spiffeClient.Scopes...),
374+
GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange},
375+
}
376+
}
377+
return configs
378+
}
379+
313380
// EmbeddedAuthServerConfigName returns the config name that should be used for
314381
// embedded auth server volume/env generation, or empty string if neither ref applies.
315382
// AuthServerRef takes precedence; externalAuthConfigRef is used as a fallback.
@@ -827,7 +894,9 @@ func buildInboundGrantsRunConfig(
827894
if config == nil {
828895
return nil, nil
829896
}
830-
grants := &authserver.InboundGrantsRunConfig{}
897+
grants := &authserver.InboundGrantsRunConfig{
898+
SPIFFEClientAuth: buildSPIFFEClientAuthRunConfigs(config.SPIFFEClientAuth),
899+
}
831900
if config.TokenExchange != nil {
832901
delegateClients, err := buildDelegateClientRunConfigs(config.TokenExchange.DelegateClients)
833902
if err != nil {
@@ -912,6 +981,7 @@ func BuildAuthServerRunConfig(
912981
ScopesSupported: scopesSupported,
913982
BaselineClientScopes: authConfig.BaselineClientScopes,
914983
InboundGrants: inboundGrants,
984+
SPIFFETrustDomains: buildSPIFFETrustDomainRunConfigs(authConfig.SPIFFETrustDomains),
915985
}
916986

917987
if len(authConfig.DelegateClients) > 0 {
@@ -1032,7 +1102,8 @@ func buildAuthServerSecretsConfig(config *authserver.RunConfig, authConfig *mcpv
10321102
// or allow_may_act combined with the delegate-client wildcard) as a
10331103
// reconcile error rather than a pod crash loop.
10341104
func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) error {
1035-
if len(config.DelegateClients) == 0 && len(config.TrustedIssuers) == 0 && config.InboundGrants == nil {
1105+
if len(config.DelegateClients) == 0 && len(config.TrustedIssuers) == 0 && config.InboundGrants == nil &&
1106+
len(config.SPIFFETrustDomains) == 0 {
10361107
return nil
10371108
}
10381109

@@ -1043,9 +1114,10 @@ func validateDelegateClientsAndTrustedIssuers(config *authserver.RunConfig) erro
10431114
InsecureAllowHTTP: config.InsecureAllowHTTP,
10441115
AllowPrivateKeyJWTRegistration: config.AllowPrivateKeyJWTRegistration,
10451116
InsecureAllowConfidentialOverLoopbackHTTP: config.InsecureAllowConfidentialOverLoopbackHTTP,
1046-
DelegateClients: config.DelegateClients,
1047-
TrustedIssuers: config.TrustedIssuers,
1048-
InboundGrants: config.InboundGrants,
1117+
DelegateClients: config.DelegateClients,
1118+
TrustedIssuers: config.TrustedIssuers,
1119+
InboundGrants: config.InboundGrants,
1120+
SPIFFETrustDomains: config.SPIFFETrustDomains,
10491121
}
10501122
if err := validationConfig.Validate(); err != nil {
10511123
return fmt.Errorf("invalid embedded auth server delegate clients or trusted issuers: %w", err)

0 commit comments

Comments
 (0)