From b7aebaf540a158c64ef5eb76388269974b49f276 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Mon, 11 May 2026 09:56:51 -0400 Subject: [PATCH] feat: add SPIFFE-based Dynamic Client Registration (DCR) Implements issue #1421 - eliminate admin credentials from client registration using SPIFFE JWT-SVID authentication. Changes: - Add internal/spire/client.go: SPIRE workload API client wrapper - Add internal/keycloak/dcr.go: DCR client for JWT-SVID authentication - Add --enable-dcr-registration flag (disabled by default, experimental) - Add --spire-socket-path flag for SPIRE agent socket location - Update ClientRegistrationReconciler to support both DCR and admin paths - Refactor registration logic into registerClientWithDCR() and registerClientWithAdminCreds() methods DCR benefits over admin credentials: - Short-lived JWT-SVID tokens (auto-rotates hourly) - Limited DCR permissions instead of full realm admin - Better audit trail (operator SPIFFE ID vs generic 'admin' user) - No manual credential rotation required When --enable-dcr-registration=true: 1. Operator fetches JWT-SVID from SPIRE 2. Uses JWT-SVID to authenticate with Keycloak DCR endpoint 3. Registers OAuth client with limited DCR permissions 4. No admin credentials needed When false (default): - Uses existing admin credential path from kagenti-system namespace - Backward compatible with existing deployments Note: DCR requires testing with Keycloak to verify JWT-SVID acceptance at the DCR endpoint. This is currently unverified and marked experimental. Assisted-By: Claude Code Signed-off-by: Alan Cha --- kagenti-operator/cmd/main.go | 33 +++- .../clientregistration_controller.go | 179 ++++++++++++++---- kagenti-operator/internal/keycloak/dcr.go | 149 +++++++++++++++ kagenti-operator/internal/spire/client.go | 120 ++++++++++++ 4 files changed, 438 insertions(+), 43 deletions(-) create mode 100644 kagenti-operator/internal/keycloak/dcr.go create mode 100644 kagenti-operator/internal/spire/client.go diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index ebf4feb0..23d09211 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -48,6 +48,7 @@ import ( "github.com/kagenti/operator/internal/keycloak" "github.com/kagenti/operator/internal/mlflow" "github.com/kagenti/operator/internal/signature" + spireclient "github.com/kagenti/operator/internal/spire" "github.com/kagenti/operator/internal/tekton" webhookconfig "github.com/kagenti/operator/internal/webhook/config" "github.com/kagenti/operator/internal/webhook/injector" @@ -91,6 +92,8 @@ func main() { var tlsOpts []func(*tls.Config) var enableClientRegistration bool + var enableDCRRegistration bool + var spireSocketPath string var configPath string var featureGatesPath string @@ -125,6 +128,10 @@ func main() { "If set, HTTP/2 will be enabled for the metrics and webhook servers") flag.BoolVar(&enableClientRegistration, "enable-client-registration", true, "Enable operator-managed Keycloak client registration for agent/tool workloads") + flag.BoolVar(&enableDCRRegistration, "enable-dcr-registration", false, + "Use SPIFFE-based Dynamic Client Registration instead of admin credentials (experimental)") + flag.StringVar(&spireSocketPath, "spire-socket-path", "unix:///run/spire/sockets/agent.sock", + "Path to SPIRE Agent workload API socket (for DCR JWT-SVID authentication)") flag.StringVar(&configPath, "config-path", "/etc/kagenti/config.yaml", "Path to platform config file") flag.StringVar(&featureGatesPath, "feature-gates-path", "/etc/kagenti/feature-gates/feature-gates.yaml", "Path to feature gates config file") @@ -389,16 +396,34 @@ func main() { if enableClientRegistration { operatorNS := getOperatorNamespace() - setupLog.Info("Client registration controller will read keycloak-admin-secret from operator namespace", - "namespace", operatorNS) - if err = (&controller.ClientRegistrationReconciler{ + + reconciler := &controller.ClientRegistrationReconciler{ Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), OperatorNamespace: operatorNS, SpireTrustDomain: spireTrustDomain, KeycloakAdminTokenCache: &keycloak.CachedAdminTokenProvider{}, - }).SetupWithManager(mgr); err != nil { + UseDCR: enableDCRRegistration, + } + + if enableDCRRegistration { + // Initialize SPIRE client for DCR authentication + spireClient := spireclient.NewClient(spireSocketPath) + if err := spireClient.Connect(ctx); err != nil { + setupLog.Error(err, "failed to connect to SPIRE agent", "socketPath", spireSocketPath) + setupLog.Info("DCR requires SPIRE agent connection. Ensure SPIRE agent socket is mounted.") + os.Exit(1) + } + reconciler.SpireClient = spireClient + setupLog.Info("DCR mode enabled: using SPIFFE JWT-SVID for client registration", + "spireSocket", spireSocketPath) + } else { + setupLog.Info("Client registration controller will read keycloak-admin-secret from operator namespace", + "namespace", operatorNS) + } + + if err = reconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClientRegistration") os.Exit(1) } diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index d1cec7c5..059c6b10 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -32,6 +32,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/yaml" + "github.com/go-logr/logr" + agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" "github.com/kagenti/operator/internal/keycloak" ) @@ -50,6 +52,13 @@ const ( AnnotationKeycloakClientSecretName = "kagenti.io/keycloak-client-credentials-secret-name" ) +// SpireClient is an interface for fetching JWT-SVIDs from SPIRE Agent. +// This allows for testing and mocking. +type SpireClient interface { + // FetchJWTSVID fetches a JWT-SVID with the given audience. + FetchJWTSVID(ctx context.Context, audience string) (jwtToken string, expiresAt time.Time, err error) +} + // ClientRegistrationReconciler registers OAuth clients in Keycloak and patches agent/tool workloads that // use the default path (label absent or not "true") so the webhook injects envoy/SPIRE without the // legacy registration sidecar. The Secret is created before the pod template annotation is set so new Pods @@ -69,7 +78,15 @@ type ClientRegistrationReconciler struct { SpireTrustDomain string // KeycloakAdminTokenCache caches admin password-grant tokens by Keycloak URL and credentials to // avoid a token request on every reconcile. If nil, PasswordGrantToken is used without caching. + // Only used when UseDCR is false. KeycloakAdminTokenCache *keycloak.CachedAdminTokenProvider + + // UseDCR enables Dynamic Client Registration with JWT-SVID instead of admin credentials. + // When true, SpireClient must be set. + UseDCR bool + // SpireClient is used to fetch JWT-SVIDs for DCR authentication. + // Only used when UseDCR is true. + SpireClient SpireClient } func (r *ClientRegistrationReconciler) uncachedReader() client.Reader { @@ -208,24 +225,6 @@ func (r *ClientRegistrationReconciler) reconcileOne( return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - // Read keycloak-admin-secret from the operator's namespace, not from agent namespace. - // This prevents Keycloak admin credentials from being replicated to every agent namespace, - // which would be a security risk if an agent namespace is compromised. - adminSecret := &corev1.Secret{} - if err := r.uncachedReader().Get(ctx, types.NamespacedName{Namespace: r.OperatorNamespace, Name: keycloakAdminSecret}, adminSecret); err != nil { - if apierrors.IsNotFound(err) { - logger.Info("waiting for keycloak-admin-secret", "namespace", r.OperatorNamespace) - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil - } - return ctrl.Result{}, err - } - adminUser := string(adminSecret.Data["KEYCLOAK_ADMIN_USERNAME"]) - adminPass := string(adminSecret.Data["KEYCLOAK_ADMIN_PASSWORD"]) - if adminUser == "" || adminPass == "" { - logger.Info("keycloak-admin-secret missing username/password keys") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil - } - spireEnabled := strings.EqualFold(strings.TrimSpace(ab.SpireEnabled), "true") clientName := ns + "/" + workloadName clientID, err := resolveKeycloakClientID(ns, workloadName, template.Spec.ServiceAccountName, spireEnabled, r.SpireTrustDomain) @@ -241,28 +240,23 @@ func (r *ClientRegistrationReconciler) reconcileOne( tokenExch := strings.TrimSpace(ab.KeycloakTokenExchangeEnabled) != "false" audienceScopeOn := strings.TrimSpace(ab.KeycloakAudienceScopeEnabled) != "false" - kc := keycloak.Admin{BaseURL: ab.KeycloakURL, HTTPClient: keycloak.DefaultHTTPClient()} - var token string - if r.KeycloakAdminTokenCache != nil { - token, err = r.KeycloakAdminTokenCache.Token(ctx, &kc, adminUser, adminPass) + var clientSecret string + if r.UseDCR { + // DCR path: Use SPIFFE JWT-SVID for authentication + clientSecret, err = r.registerClientWithDCR(ctx, logger, ab, clientID, clientName, authType, tokenExch) + if err != nil { + logger.Error(err, "DCR client registration failed", "clientId", clientID) + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + logger.Info("Client registered via DCR", "clientId", clientID, "method", "jwt-svid") } else { - token, err = kc.PasswordGrantToken(ctx, adminUser, adminPass) - } - if err != nil { - logger.Error(err, "Keycloak admin token failed") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil - } - _, clientSecret, err := kc.RegisterOrFetchClientWithToken(ctx, token, keycloak.ClientRegistrationParams{ - Realm: ab.KeycloakRealm, - ClientID: clientID, - ClientName: clientName, - ClientAuthType: authType, - SpiffeIDPAlias: ab.SpiffeIDPAlias, - TokenExchangeEnable: tokenExch, - }) - if err != nil { - logger.Error(err, "Keycloak client registration failed", "clientId", clientID) - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + // Admin credentials path: Read keycloak-admin-secret from operator namespace + clientSecret, err = r.registerClientWithAdminCreds(ctx, logger, ab, clientID, clientName, authType, tokenExch, audienceScopeOn) + if err != nil { + logger.Error(err, "Client registration with admin creds failed", "clientId", clientID) + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + logger.Info("Client registered via admin API", "clientId", clientID, "method", "admin-credentials") } if err := kc.EnsureAudienceScope(ctx, token, keycloak.AudienceParams{ @@ -488,6 +482,113 @@ func clientRegistrationWorkloadPredicate(obj client.Object) bool { } } +// registerClientWithDCR registers a Keycloak client using Dynamic Client Registration with JWT-SVID. +func (r *ClientRegistrationReconciler) registerClientWithDCR( + ctx context.Context, + logger logr.Logger, + ab *authBridgeConfig, + clientID, clientName, authType string, + tokenExch bool, +) (clientSecret string, err error) { + if r.SpireClient == nil { + return "", fmt.Errorf("DCR enabled but SPIRE client not initialized") + } + + // Fetch JWT-SVID from SPIRE with Keycloak as the audience + // The audience should match what Keycloak expects for DCR authentication + audience := ab.KeycloakURL + "/realms/" + ab.KeycloakRealm + jwtSVID, _, err := r.SpireClient.FetchJWTSVID(ctx, audience) + if err != nil { + return "", fmt.Errorf("fetch JWT-SVID for DCR: %w", err) + } + + // Use DCR client to register + dcrClient := keycloak.DCRClient{ + BaseURL: ab.KeycloakURL, + HTTPClient: keycloak.DefaultHTTPClient(), + } + + secret, _, err := dcrClient.RegisterClientWithJWTSVID(ctx, jwtSVID, keycloak.ClientRegistrationParams{ + Realm: ab.KeycloakRealm, + ClientID: clientID, + ClientName: clientName, + ClientAuthType: authType, + SpiffeIDPAlias: ab.SpiffeIDPAlias, + TokenExchangeEnable: tokenExch, + }) + if err != nil { + return "", fmt.Errorf("DCR registration: %w", err) + } + + logger.V(1).Info("DCR registration succeeded", "clientId", clientID, "audience", audience) + return secret, nil +} + +// registerClientWithAdminCreds registers a Keycloak client using admin credentials (legacy path). +func (r *ClientRegistrationReconciler) registerClientWithAdminCreds( + ctx context.Context, + logger logr.Logger, + ab *authBridgeConfig, + clientID, clientName, authType string, + tokenExch, audienceScopeOn bool, +) (clientSecret string, err error) { + // Read keycloak-admin-secret from the operator's namespace + adminSecret := &corev1.Secret{} + if err := r.uncachedReader().Get(ctx, types.NamespacedName{ + Namespace: r.OperatorNamespace, + Name: keycloakAdminSecret, + }, adminSecret); err != nil { + if apierrors.IsNotFound(err) { + return "", fmt.Errorf("keycloak-admin-secret not found in namespace %s", r.OperatorNamespace) + } + return "", fmt.Errorf("get keycloak-admin-secret: %w", err) + } + + adminUser := string(adminSecret.Data["KEYCLOAK_ADMIN_USERNAME"]) + adminPass := string(adminSecret.Data["KEYCLOAK_ADMIN_PASSWORD"]) + if adminUser == "" || adminPass == "" { + return "", fmt.Errorf("keycloak-admin-secret missing username/password keys") + } + + // Get admin token + kc := keycloak.Admin{BaseURL: ab.KeycloakURL, HTTPClient: keycloak.DefaultHTTPClient()} + var token string + if r.KeycloakAdminTokenCache != nil { + token, err = r.KeycloakAdminTokenCache.Token(ctx, &kc, adminUser, adminPass) + } else { + token, err = kc.PasswordGrantToken(ctx, adminUser, adminPass) + } + if err != nil { + return "", fmt.Errorf("get admin token: %w", err) + } + + // Register client + _, secret, err := kc.RegisterOrFetchClientWithToken(ctx, token, keycloak.ClientRegistrationParams{ + Realm: ab.KeycloakRealm, + ClientID: clientID, + ClientName: clientName, + ClientAuthType: authType, + SpiffeIDPAlias: ab.SpiffeIDPAlias, + TokenExchangeEnable: tokenExch, + }) + if err != nil { + return "", fmt.Errorf("register client: %w", err) + } + + // Ensure audience scope (only for admin path) + if err := kc.EnsureAudienceScope(ctx, token, keycloak.AudienceParams{ + Realm: ab.KeycloakRealm, + ClientName: clientName, + AudienceClientID: clientID, + PlatformClientIDs: parsePlatformClientIDs(ab.PlatformClientIDs), + AudienceScopeEnabled: audienceScopeOn, + }); err != nil { + logger.Error(err, "audience scope management failed (credentials will still be written)", "clientId", clientID) + } + + return secret, nil +} + // SetupWithManager registers the controller. injectTools is resolved at reconcile time from cluster // feature gates; the predicate uses injectTools=true so tool workloads are not dropped before gates load. func (r *ClientRegistrationReconciler) SetupWithManager(mgr ctrl.Manager) error { diff --git a/kagenti-operator/internal/keycloak/dcr.go b/kagenti-operator/internal/keycloak/dcr.go new file mode 100644 index 00000000..d39c354c --- /dev/null +++ b/kagenti-operator/internal/keycloak/dcr.go @@ -0,0 +1,149 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +*/ + +package keycloak + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// DCRClient implements Dynamic Client Registration using JWT-SVID authentication +// instead of admin credentials. This eliminates the need for long-lived admin +// credentials and reduces the security surface to DCR-only permissions. +// +// Keycloak DCR endpoint: POST /realms/{realm}/clients-registrations/default +// Authentication: Bearer +// +// See: https://www.keycloak.org/docs/latest/securing_apps/#_client_registration +type DCRClient struct { + BaseURL string // e.g. https://keycloak.example.com:8080 (no trailing path) + HTTPClient *http.Client +} + +func (d *DCRClient) httpc() *http.Client { + if d.HTTPClient != nil { + return d.HTTPClient + } + return http.DefaultClient +} + +// dcrRequest represents the payload for Keycloak DCR endpoint. +// This is a subset of the full client representation, focusing on the fields +// needed for operator-managed client registration. +type dcrRequest struct { + ClientID string `json:"clientId"` + ClientName string `json:"clientName,omitempty"` + RedirectURIs []string `json:"redirectUris,omitempty"` + GrantTypes []string `json:"grantTypes,omitempty"` + ResponseTypes []string `json:"responseTypes,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + ClientAuthenticatorType string `json:"clientAuthenticatorType,omitempty"` +} + +// dcrResponse represents the response from Keycloak DCR endpoint. +type dcrResponse struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret,omitempty"` + RegistrationAccessToken string `json:"registrationAccessToken"` + ClientIDIssuedAt int64 `json:"clientIdIssuedAt"` +} + +// RegisterClientWithJWTSVID registers an OAuth client using Dynamic Client Registration +// with JWT-SVID authentication instead of admin credentials. +// +// Parameters: +// - ctx: Context for the request +// - jwtSVID: JWT-SVID token obtained from SPIRE +// - params: Client registration parameters +// +// Returns: +// - clientSecret: The generated client secret (for client-secret auth) +// - registrationToken: Registration access token for future updates +// - error: Any error that occurred during registration +func (d *DCRClient) RegisterClientWithJWTSVID(ctx context.Context, jwtSVID string, params ClientRegistrationParams) (clientSecret, registrationToken string, err error) { + base := trimBaseURL(d.BaseURL) + endpoint := fmt.Sprintf("%s/realms/%s/clients-registrations/default", base, params.Realm) + + // Build DCR request + authType := params.ClientAuthType + if authType == "" { + authType = "client-secret" + } + + attrs := map[string]string{ + "standard.token.exchange.enabled": fmt.Sprintf("%t", params.TokenExchangeEnable), + } + + // For federated-jwt auth, configure JWT authentication + if authType == "federated-jwt" { + alias := params.SpiffeIDPAlias + if alias == "" { + alias = "spire-spiffe" + } + attrs["jwt.credential.issuer"] = alias + attrs["jwt.credential.sub"] = params.ClientID + } + + req := dcrRequest{ + ClientID: params.ClientID, + ClientName: params.ClientName, + // DCR defaults for service-to-service auth + GrantTypes: []string{"client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"}, + ResponseTypes: []string{"token"}, + Attributes: attrs, + ClientAuthenticatorType: authType, + } + + body, err := json.Marshal(req) + if err != nil { + return "", "", fmt.Errorf("marshal DCR request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", "", err + } + + // Authenticate with JWT-SVID + httpReq.Header.Set("Authorization", "Bearer "+jwtSVID) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := d.httpc().Do(httpReq) + if err != nil { + return "", "", fmt.Errorf("DCR request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("DCR failed: status %d: %s", resp.StatusCode, truncate(respBody, 512)) + } + + var dcrResp dcrResponse + if err := json.Unmarshal(respBody, &dcrResp); err != nil { + return "", "", fmt.Errorf("decode DCR response: %w", err) + } + + return dcrResp.ClientSecret, dcrResp.RegistrationAccessToken, nil +} + +// UpdateClientWithJWTSVID updates an existing OAuth client using the registration access token. +// +// Note: This is for future use. Currently, the operator uses RegisterOrFetchClient which +// handles both create and update. For DCR, we need to store the registrationAccessToken +// and use it for updates instead of admin credentials. +func (d *DCRClient) UpdateClientWithJWTSVID(ctx context.Context, registrationToken string, params ClientRegistrationParams) error { + // Implementation for update using PUT /realms/{realm}/clients-registrations/default/{clientId} + // with Authorization: Bearer + return fmt.Errorf("DCR update not yet implemented") +} diff --git a/kagenti-operator/internal/spire/client.go b/kagenti-operator/internal/spire/client.go new file mode 100644 index 00000000..29c9ecd5 --- /dev/null +++ b/kagenti-operator/internal/spire/client.go @@ -0,0 +1,120 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +*/ + +package spire + +import ( + "context" + "fmt" + "time" + + "github.com/spiffe/go-spiffe/v2/workloadapi" +) + +// Client wraps the SPIRE Workload API client for obtaining JWT-SVIDs. +type Client struct { + // SocketPath is the path to the SPIRE Agent's workload API socket. + // Default: "unix:///run/spire/sockets/agent.sock" + SocketPath string + + // client is the underlying workloadapi.Client + client *workloadapi.Client +} + +// NewClient creates a new SPIRE client. +func NewClient(socketPath string) *Client { + if socketPath == "" { + socketPath = "unix:///run/spire/sockets/agent.sock" + } + return &Client{ + SocketPath: socketPath, + } +} + +// Connect establishes a connection to the SPIRE Agent. +// This should be called once during operator startup. +func (c *Client) Connect(ctx context.Context) error { + client, err := workloadapi.New(ctx, workloadapi.WithAddr(c.SocketPath)) + if err != nil { + return fmt.Errorf("connect to SPIRE workload API at %s: %w", c.SocketPath, err) + } + c.client = client + return nil +} + +// Close closes the connection to the SPIRE Agent. +func (c *Client) Close() error { + if c.client != nil { + return c.client.Close() + } + return nil +} + +// FetchJWTSVID fetches a JWT-SVID from the SPIRE Agent for the given audience. +// +// The audience should be the Keycloak DCR endpoint, e.g. "keycloak-dcr" or the full URL. +// The returned JWT-SVID is a signed JWT token that can be used to authenticate with Keycloak. +// +// Parameters: +// - ctx: Context for the request +// - audience: The audience for the JWT-SVID (typically the Keycloak realm or DCR endpoint) +// +// Returns: +// - jwtToken: The JWT-SVID token as a string +// - expiresAt: When the JWT-SVID expires +// - error: Any error that occurred +func (c *Client) FetchJWTSVID(ctx context.Context, audience string) (jwtToken string, expiresAt time.Time, err error) { + if c.client == nil { + return "", time.Time{}, fmt.Errorf("SPIRE client not connected") + } + + // Fetch JWT-SVID with the specified audience + jwtSVID, err := c.client.FetchJWTSVID(ctx, workloadapi.WithAudience(audience)) + if err != nil { + return "", time.Time{}, fmt.Errorf("fetch JWT-SVID: %w", err) + } + + return jwtSVID.Marshal(), jwtSVID.Expiry, nil +} + +// FetchJWTSVIDs fetches multiple JWT-SVIDs for different audiences. +// This is useful when the operator needs to authenticate with multiple services. +func (c *Client) FetchJWTSVIDs(ctx context.Context, audiences []string) (map[string]string, error) { + if c.client == nil { + return nil, fmt.Errorf("SPIRE client not connected") + } + + result := make(map[string]string) + for _, aud := range audiences { + jwtSVID, err := c.client.FetchJWTSVID(ctx, workloadapi.WithAudience(aud)) + if err != nil { + return nil, fmt.Errorf("fetch JWT-SVID for audience %s: %w", aud, err) + } + result[aud] = jwtSVID.Marshal() + } + + return result, nil +} + +// GetSPIFFEID returns the SPIFFE ID of the workload (the operator). +// This can be used for logging and debugging. +func (c *Client) GetSPIFFEID(ctx context.Context) (string, error) { + if c.client == nil { + return "", fmt.Errorf("SPIRE client not connected") + } + + x509Context, err := c.client.FetchX509Context(ctx) + if err != nil { + return "", fmt.Errorf("fetch X509 context: %w", err) + } + + if len(x509Context.SVIDs) == 0 { + return "", fmt.Errorf("no X509-SVIDs available") + } + + return x509Context.SVIDs[0].ID.String(), nil +}