From 9fae89616f042d7af980d353e8e0985320292e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Tue, 25 Aug 2026 13:38:47 +0000 Subject: [PATCH 1/5] Refactor authentication flow in the webhook. Remove the obsolete long-live token authentication option. Add the possibility to setup multitenant environment using SA keys per namespace. Add the possibility to use WIF auth flow. --- cmd/webhook/main.go | 2 +- internal/repository/config.go | 11 +- internal/repository/dns_client.go | 38 -- internal/repository/rrset_repository.go | 11 +- internal/repository/rrset_repositry_test.go | 13 +- internal/repository/zone_repository.go | 7 +- internal/repository/zone_repository_test.go | 14 +- internal/resolver/config.go | 75 +++- internal/resolver/config_test.go | 163 ++----- internal/resolver/resolver.go | 97 ++-- internal/resolver/resolver_test.go | 474 ++++---------------- 11 files changed, 240 insertions(+), 665 deletions(-) delete mode 100644 internal/repository/dns_client.go diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index 5841e1e2..0f5f9b7e 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -40,7 +40,7 @@ func main() { }).DialContext, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, - IdleConnTimeout: 30 * time.Second, + IdleConnTimeout: 30 * time.Second, }, } diff --git a/internal/repository/config.go b/internal/repository/config.go index d692b887..fda5cbc1 100644 --- a/internal/repository/config.go +++ b/internal/repository/config.go @@ -1,13 +1,8 @@ package repository -import "net/http" +import stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" type Config struct { - ApiBasePath string - ServiceAccountBaseUrl string - AuthToken string - ProjectId string - HttpClient *http.Client - SaKeyPath string - UseSaKey bool + ProjectId string + ApiClient *stackitdnsclient.APIClient } diff --git a/internal/repository/dns_client.go b/internal/repository/dns_client.go deleted file mode 100644 index e760e1bb..00000000 --- a/internal/repository/dns_client.go +++ /dev/null @@ -1,38 +0,0 @@ -package repository - -import ( - stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" -) - -func newStackitDnsClient( - stackitConfig ...stackitconfig.ConfigurationOption, -) (*stackitdnsclient.APIClient, error) { - return stackitdnsclient.NewAPIClient(stackitConfig...) -} - -func newStackitDnsClientBearerToken(config Config) (*stackitdnsclient.APIClient, error) { - return newStackitDnsClient( - stackitconfig.WithToken(config.AuthToken), - stackitconfig.WithHTTPClient(new(*config.HttpClient)), - stackitconfig.WithEndpoint(config.ApiBasePath), - ) -} - -func newStackitDnsClientKeyPath(config Config) (*stackitdnsclient.APIClient, error) { - return newStackitDnsClient( - stackitconfig.WithServiceAccountKeyPath(config.SaKeyPath), - stackitconfig.WithHTTPClient(new(*config.HttpClient)), - stackitconfig.WithEndpoint(config.ApiBasePath), - stackitconfig.WithTokenEndpoint(config.ServiceAccountBaseUrl), - ) -} - -func chooseNewStackitDnsClient(config Config) (*stackitdnsclient.APIClient, error) { - switch { - case config.UseSaKey: - return newStackitDnsClientKeyPath(config) - default: - return newStackitDnsClientBearerToken(config) - } -} diff --git a/internal/repository/rrset_repository.go b/internal/repository/rrset_repository.go index b0f89440..c4a34a9b 100644 --- a/internal/repository/rrset_repository.go +++ b/internal/repository/rrset_repository.go @@ -39,13 +39,12 @@ func (r rrSetRepositoryFactory) NewRRSetRepository( config Config, zoneId string, ) (RRSetRepository, error) { - apiClient, err := chooseNewStackitDnsClient(config) - if err != nil { - return nil, err + if config.ApiClient == nil { + return nil, fmt.Errorf("API client is not initialized") } return &rrSetRepository{ - apiClient: apiClient, + apiClient: config.ApiClient, projectId: config.ProjectId, zoneId: zoneId, }, nil @@ -142,7 +141,9 @@ func (r *rrSetRepository) DeleteRRSet(ctx context.Context, rrSetId string) error return ErrRRSetNotFound } } + + return err } - return err + return nil } diff --git a/internal/repository/rrset_repositry_test.go b/internal/repository/rrset_repositry_test.go index 5683061e..d867d55e 100644 --- a/internal/repository/rrset_repositry_test.go +++ b/internal/repository/rrset_repositry_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cert-manager-webhook/internal/repository" + stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" "github.com/stretchr/testify/require" ) @@ -155,11 +156,15 @@ func setupRRSetRepositoryTests(t *testing.T) (context.Context, repository.Config server := getTestServer(t) t.Cleanup(server.Close) + apiClient, _ := stackitdnsclient.NewAPIClient( + stackitconfig.WithEndpoint(server.URL), + stackitconfig.WithHTTPClient(server.Client()), + stackitconfig.WithoutAuthentication(), + ) + config := repository.Config{ - ApiBasePath: server.URL, - AuthToken: "test-token", - ProjectId: "1234", - HttpClient: server.Client(), + ProjectId: "1234", + ApiClient: apiClient, } rrSetRepositoryFactory := repository.NewRRSetRepositoryFactory() diff --git a/internal/repository/zone_repository.go b/internal/repository/zone_repository.go index 5e4d2a06..c926b65c 100644 --- a/internal/repository/zone_repository.go +++ b/internal/repository/zone_repository.go @@ -30,13 +30,12 @@ type zoneRepositoryFactory struct{} func (z zoneRepositoryFactory) NewZoneRepository( config Config, ) (ZoneRepository, error) { - apiClient, err := chooseNewStackitDnsClient(config) - if err != nil { - return nil, err + if config.ApiClient == nil { + return nil, fmt.Errorf("API client is not initialized") } return &zoneRepository{ - apiClient: apiClient, + apiClient: config.ApiClient, projectId: config.ProjectId, }, nil } diff --git a/internal/repository/zone_repository_test.go b/internal/repository/zone_repository_test.go index f67e6723..95025233 100644 --- a/internal/repository/zone_repository_test.go +++ b/internal/repository/zone_repository_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/stackitcloud/stackit-cert-manager-webhook/internal/repository" + stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" + stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,12 +18,16 @@ func TestZoneRepository_FetchZone(t *testing.T) { server := getTestServer(t) t.Cleanup(server.Close) + apiClient, _ := stackitdnsclient.NewAPIClient( + stackitconfig.WithEndpoint(server.URL), + stackitconfig.WithHTTPClient(server.Client()), + stackitconfig.WithoutAuthentication(), + ) + createZoneRepo := func(projectID string) repository.ZoneRepository { config := repository.Config{ - ApiBasePath: server.URL, - AuthToken: "test-token", - ProjectId: projectID, - HttpClient: server.Client(), + ProjectId: projectID, + ApiClient: apiClient, } zoneRepository, err := repository.NewZoneRepositoryFactory().NewZoneRepository(config) require.NoError(t, err) diff --git a/internal/resolver/config.go b/internal/resolver/config.go index a94857ce..61668254 100644 --- a/internal/resolver/config.go +++ b/internal/resolver/config.go @@ -23,18 +23,52 @@ type defaultConfigProvider struct { const ( secretAccessScopeWebhook = "webhook" - secretAccessScopeIssuer = "issuer" + secretAccessScopeIssuer = "issuer" +) + +type AuthType int + +const ( + AuthTypeDefault AuthType = iota + AuthTypeDynamicSA + AuthTypeStaticSA + AuthTypeWIF ) type StackitDnsProviderConfig struct { - ProjectId string `json:"projectId"` - ApiBasePath string `json:"apiBasePath"` - AuthTokenSecretRef string `json:"authTokenSecretRef"` - AuthTokenSecretKey string `json:"authTokenSecretKey"` - AuthTokenSecretNamespace string `json:"authTokenSecretNamespace"` - ServiceAccountKeyPath string `json:"serviceAccountKeyPath"` - ServiceAccountBaseUrl string `json:"serviceAccountBaseUrl"` - AcmeTxtRecordTTL int32 `json:"acmeTxtRecordTTL"` + ProjectId string `json:"projectId"` + ApiBasePath string `json:"apiBasePath"` + ServiceAccountSecretRef string `json:"serviceAccountSecretRef"` + ServiceAccountSecretKey string `json:"serviceAccountSecretKey"` + ServiceAccountSecretNamespace string `json:"serviceAccountSecretNamespace"` + ServiceAccountKeyPath string `json:"serviceAccountKeyPath"` + UseWorkloadIdentityFederation bool `json:"useWorkloadIdentityFederation"` + ServiceAccountBaseUrl string `json:"serviceAccountBaseUrl"` + AcmeTxtRecordTTL int32 `json:"acmeTxtRecordTTL"` +} + +func determineAuthType(cfg *StackitDnsProviderConfig) (AuthType, error) { + var activeTypes []AuthType + + if len(cfg.ServiceAccountSecretRef) > 0 { + activeTypes = append(activeTypes, AuthTypeDynamicSA) + } + if len(cfg.ServiceAccountKeyPath) > 0 { + activeTypes = append(activeTypes, AuthTypeStaticSA) + } + if cfg.UseWorkloadIdentityFederation { + activeTypes = append(activeTypes, AuthTypeWIF) + } + + if len(activeTypes) > 1 { + return AuthTypeDefault, fmt.Errorf("ambiguous authentication configuration: specify at most one of serviceAccountSecretRef, serviceAccountKeyPath, or useWorkloadIdentityFederation") + } + + if len(activeTypes) == 1 { + return activeTypes[0], nil + } + + return AuthTypeDefault, nil } func (d defaultConfigProvider) LoadConfig(cfgJSON *extapi.JSON) (StackitDnsProviderConfig, error) { @@ -57,11 +91,13 @@ func (d defaultConfigProvider) LoadConfig(cfgJSON *extapi.JSON) (StackitDnsProvi scope := d.secretAccessScope if scope == "" || scope == secretAccessScopeWebhook { - cfg.AuthTokenSecretNamespace = webhookNamespace - } + if cfg.ServiceAccountSecretNamespace == "" { + cfg.ServiceAccountSecretNamespace = webhookNamespace + } - if err := validateSecretNamespace(cfg.AuthTokenSecretNamespace, webhookNamespace, scope); err != nil { - return cfg, err + if err := validateSecretNamespace(cfg.ServiceAccountSecretNamespace, webhookNamespace, scope); err != nil { + return cfg, err + } } if err := validateConfig(&cfg); err != nil { @@ -75,13 +111,15 @@ func validateSecretNamespace(configuredNamespace, webhookNamespace, accessScope switch accessScope { case "", secretAccessScopeWebhook: if configuredNamespace != webhookNamespace { - return fmt.Errorf("authTokenSecretNamespace must be %s, got: %s", webhookNamespace, configuredNamespace) + return fmt.Errorf("serviceAccountSecretNamespace must be %s, got: %s", webhookNamespace, configuredNamespace) } + return nil case secretAccessScopeIssuer: if configuredNamespace == "" { - return fmt.Errorf("authTokenSecretNamespace must be specified when secretAccessScope=issuer") + return fmt.Errorf("serviceAccountSecretNamespace must be specified when secretAccessScope=issuer") } + return nil default: return fmt.Errorf("invalid secretAccessScope %q", accessScope) @@ -140,12 +178,7 @@ func setDefaultValues(cfg *StackitDnsProviderConfig) { if cfg.ApiBasePath == "" { cfg.ApiBasePath = "https://dns.api.stackit.cloud" } - if cfg.AuthTokenSecretRef == "" { - cfg.AuthTokenSecretRef = "stackit-cert-manager-webhook" - } - if cfg.AuthTokenSecretKey == "" { - cfg.AuthTokenSecretKey = "auth-token" - } + if cfg.AcmeTxtRecordTTL == 0 { cfg.AcmeTxtRecordTTL = 600 } diff --git a/internal/resolver/config_test.go b/internal/resolver/config_test.go index a940b9b2..25370a72 100644 --- a/internal/resolver/config_test.go +++ b/internal/resolver/config_test.go @@ -1,25 +1,39 @@ package resolver import ( - "net/http" "os" "testing" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - "go.uber.org/zap" v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" ) +func createNamespaceFile(t *testing.T, content string) string { + t.Helper() + + f, err := os.CreateTemp("", "namespace-*") + require.NoError(t, err) + + t.Cleanup(func() { + os.Remove(f.Name()) + }) + + _, err = f.Write([]byte(content)) + require.NoError(t, err) + + err = f.Close() + require.NoError(t, err) + + return f.Name() +} + func TestLoadConfig(t *testing.T) { t.Parallel() - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - d := defaultConfigProvider{} t.Run("nil cfgJSON", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "test-namespace") + d := defaultConfigProvider{fileNamespaceName: fileName} cfg, err := d.LoadConfig(nil) require.Error(t, err) @@ -29,9 +43,10 @@ func TestLoadConfig(t *testing.T) { t.Run("valid cfgJSON", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "test-namespace") + d := defaultConfigProvider{fileNamespaceName: fileName} - rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test", "authTokenSecretNamespace": "test"}`)} - + rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test", "serviceAccountSecretNamespace": "test-namespace"}`)} cfg, err := d.LoadConfig(rawCfg) require.NoError(t, err) require.Equal(t, "test", cfg.ProjectId) @@ -39,6 +54,8 @@ func TestLoadConfig(t *testing.T) { t.Run("not parsable cfgJSON", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "test-namespace") + d := defaultConfigProvider{fileNamespaceName: fileName} rawCfg := &v1.JSON{Raw: []byte(`{"projectId":}`)} cfg, err := d.LoadConfig(rawCfg) @@ -49,155 +66,51 @@ func TestLoadConfig(t *testing.T) { t.Run("invalid cfgJSON", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "test-namespace") + d := defaultConfigProvider{fileNamespaceName: fileName} rawCfg := &v1.JSON{Raw: []byte(`{"projectId": ""}`)} - cfg, err := d.LoadConfig(rawCfg) + _, err := d.LoadConfig(rawCfg) require.Error(t, err) require.Contains(t, err.Error(), "projectId must be specified") - require.Equal(t, StackitDnsProviderConfig{}, cfg) - }) - - t.Run("missing projectId", func(t *testing.T) { - t.Parallel() - - rawCfg := &v1.JSON{Raw: []byte(`{}`)} - cfg, err := d.LoadConfig(rawCfg) - require.Error(t, err) - require.Equal(t, "projectId must be specified", err.Error()) - require.Equal(t, StackitDnsProviderConfig{}, cfg) }) t.Run("default values set", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "test-namespace") + d := defaultConfigProvider{fileNamespaceName: fileName} - rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test", "authTokenSecretNamespace": "test"}`)} // Only projectId provided + rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test", "serviceAccountSecretNamespace": "test-namespace"}`)} cfg, err := d.LoadConfig(rawCfg) require.NoError(t, err) require.Equal(t, "test", cfg.ProjectId) require.Equal(t, "https://dns.api.stackit.cloud", cfg.ApiBasePath) - require.Equal(t, "stackit-cert-manager-webhook", cfg.AuthTokenSecretRef) - require.Equal(t, "auth-token", cfg.AuthTokenSecretKey) require.Equal(t, int32(600), cfg.AcmeTxtRecordTTL) }) - - t.Run("custom service account base url", func(t *testing.T) { - t.Parallel() - - rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test", "authTokenSecretNamespace": "test", "serviceAccountBaseUrl": "https://custom.stackit.cloud/dns"}`)} - cfg, err := d.LoadConfig(rawCfg) - require.NoError(t, err) - require.Equal(t, "test", cfg.ProjectId) - require.Equal(t, "https://custom.stackit.cloud/dns", cfg.ServiceAccountBaseUrl) - }) } func TestDefaultConfigProvider_LoadConfigNamespaceFile(t *testing.T) { t.Parallel() - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - d := defaultConfigProvider{} - t.Run("determine namespace from file", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "test-namespace") + dcp := defaultConfigProvider{fileNamespaceName: fileName} rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test"}`)} - - f, err := os.CreateTemp("", "example") - require.NoError(t, err) - defer os.Remove(f.Name()) - _, err = f.Write([]byte("test-namespace")) - require.NoError(t, err) - err = f.Close() - require.NoError(t, err) - - dcp := defaultConfigProvider{fileNamespaceName: f.Name()} cfg, err := dcp.LoadConfig(rawCfg) require.NoError(t, err) - require.Equal(t, "test-namespace", cfg.AuthTokenSecretNamespace) + require.Equal(t, "test-namespace", cfg.ServiceAccountSecretNamespace) }) t.Run("fail determine namespace from file, no content", func(t *testing.T) { t.Parallel() + fileName := createNamespaceFile(t, "") + dcp := defaultConfigProvider{fileNamespaceName: fileName} rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test"}`)} - - f, err := os.CreateTemp("", "example") - require.NoError(t, err) - defer os.Remove(f.Name()) - _, err = f.Write([]byte("")) - require.NoError(t, err) - err = f.Close() - require.NoError(t, err) - - dcp := defaultConfigProvider{fileNamespaceName: f.Name()} - _, err = dcp.LoadConfig(rawCfg) + _, err := dcp.LoadConfig(rawCfg) require.Error(t, err) require.Contains(t, err.Error(), "invalid webhook pod namespace provided") }) - - t.Run("fail to determine namespace from file", func(t *testing.T) { - t.Parallel() - - rawCfg := &v1.JSON{Raw: []byte(`{"projectId":"test"}`)} - - _, err := d.LoadConfig(rawCfg) - require.Error(t, err) - require.Contains(t, err.Error(), "failed to find the webhook pod namespace") - }) -} - -func TestGetRepositoryConfig_WithSaKeyPath(t *testing.T) { - saKeyPath := "/path/to/sa/key" - - t.Setenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH", saKeyPath) - defer func() { - t.Setenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH", "") - }() - - r := &stackitDnsProviderResolver{ - httpClient: &http.Client{}, - logger: zap.NewNop(), - } - - cfg := &StackitDnsProviderConfig{ - ApiBasePath: "https://api.stackit.cloud", - ProjectId: "test-project", - ServiceAccountBaseUrl: "https://sa-custom.stackit.cloud", - } - - config, err := r.getRepositoryConfig(cfg) - require.NoError(t, err) - require.Equal(t, saKeyPath, config.SaKeyPath) - require.Equal(t, "https://sa-custom.stackit.cloud", config.ServiceAccountBaseUrl) - require.True(t, config.UseSaKey) -} - -func TestGetRepositoryConfig_NoEnvSet(t *testing.T) { - oldAuthToken := stackitAuthToken - stackitAuthToken = "token" // global variable from resolver.go - - t.Setenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH", "") - defer func() { - stackitAuthToken = oldAuthToken - }() - - s := NewSecretFetcher() - r := &stackitDnsProviderResolver{ - httpClient: &http.Client{}, - secretFetcher: s, - logger: zap.NewNop(), - } - - cfg := &StackitDnsProviderConfig{ - ApiBasePath: "https://api.stackit.cloud", - ProjectId: "test-project", - } - - config, err := r.getRepositoryConfig(cfg) - - require.NoError(t, err) - require.False(t, config.UseSaKey) - require.Equal(t, stackitAuthToken, config.AuthToken) } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index f21a29af..075370de 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -5,13 +5,13 @@ import ( "errors" "fmt" "net/http" - "os" "slices" "strings" "github.com/cert-manager/cert-manager/pkg/acme/webhook" "github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1" "github.com/stackitcloud/stackit-cert-manager-webhook/internal/repository" + stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" "go.uber.org/zap" "k8s.io/client-go/kubernetes" @@ -20,8 +20,6 @@ import ( const typeTxtRecord = "TXT" -var stackitAuthToken = os.Getenv("STACKIT_AUTH_TOKEN") - func NewResolver( httpClient *http.Client, logger *zap.Logger, @@ -184,15 +182,14 @@ func (s *stackitDnsProviderResolver) initializeResolverContext( } func (s *stackitDnsProviderResolver) createRRSet( - initResolverRes *initResolverContextResult, key string, + initResolverRes *initResolverContextResult, + key string, ) error { rrSet := stackitdnsclient.RecordSet{ Comment: new("This record set is managed by stackit-cert-manager-webhook"), Name: initResolverRes.rrSetName, Records: []stackitdnsclient.Record{ - { - Content: key, - }, + {Content: key}, }, Ttl: initResolverRes.acmeTxtDefaultTTL, Type: typeTxtRecord, @@ -203,71 +200,57 @@ func (s *stackitDnsProviderResolver) createRRSet( return initResolverRes.rrSetRepository.CreateRRSet(s.ctx, rrSet) } -// getAuthToken from Kubernetes secretFetcher. -func (s *stackitDnsProviderResolver) getAuthToken(cfg *StackitDnsProviderConfig) (string, error) { - if stackitAuthToken != "" { - return stackitAuthToken, nil - } - - token, err := s.secretFetcher.StringFromSecret( - cfg.AuthTokenSecretNamespace, - cfg.AuthTokenSecretRef, - cfg.AuthTokenSecretKey, - ) +func (s *stackitDnsProviderResolver) getRepositoryConfig( + cfg *StackitDnsProviderConfig, +) (repository.Config, error) { + authType, err := determineAuthType(cfg) if err != nil { - return "", err + return repository.Config{}, err } - return token, nil -} - -// geSaKeyPath gets the Service Account Key Path from the environment. -func (s *stackitDnsProviderResolver) getSaKeyPath(cfg *StackitDnsProviderConfig) string { - if cfg.ServiceAccountKeyPath != "" { - return cfg.ServiceAccountKeyPath + options := []stackitconfig.ConfigurationOption{ + stackitconfig.WithHTTPClient(s.httpClient), + stackitconfig.WithEndpoint(cfg.ApiBasePath), } - return os.Getenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH") -} - -func (s *stackitDnsProviderResolver) checkUseSaAuthentication(cfg *StackitDnsProviderConfig) bool { - return s.getSaKeyPath(cfg) != "" -} - -func (s *stackitDnsProviderResolver) getRepositoryConfig( - cfg *StackitDnsProviderConfig, -) (repository.Config, error) { - config := repository.Config{ - ApiBasePath: cfg.ApiBasePath, - ProjectId: cfg.ProjectId, - HttpClient: s.httpClient, - UseSaKey: false, - ServiceAccountBaseUrl: cfg.ServiceAccountBaseUrl, + if len(cfg.ServiceAccountBaseUrl) > 0 { + options = append(options, stackitconfig.WithTokenEndpoint(cfg.ServiceAccountBaseUrl)) } - switch { - case s.checkUseSaAuthentication(cfg): - config.SaKeyPath = s.getSaKeyPath(cfg) - config.UseSaKey = true - s.logger.Info( - "Using service account key for authentication", - zap.String("saKeyPath", config.SaKeyPath), - zap.String("serviceAccountBaseUrl", config.ServiceAccountBaseUrl), + switch authType { + case AuthTypeDynamicSA: + saKeyJSON, err := s.secretFetcher.StringFromSecret( + cfg.ServiceAccountSecretNamespace, + cfg.ServiceAccountSecretRef, + cfg.ServiceAccountSecretKey, ) - default: - authToken, err := s.getAuthToken(cfg) if err != nil { - return repository.Config{}, err + return repository.Config{}, fmt.Errorf("failed to fetch service account key from secret: %w", err) } - config.AuthToken = authToken - s.logger.Info("Using auth token for authentication") + options = append(options, stackitconfig.WithServiceAccountKey(saKeyJSON)) + s.logger.Info("Using dynamic service account key from secret for authentication") + case AuthTypeStaticSA: + options = append(options, stackitconfig.WithServiceAccountKeyPath(cfg.ServiceAccountKeyPath)) + s.logger.Info("Using static service account key path for authentication", zap.String("saKeyPath", cfg.ServiceAccountKeyPath)) + case AuthTypeWIF: + options = append(options, stackitconfig.WithWorkloadIdentityFederationAuth()) + s.logger.Info("Using workload identity federation for authentication") + case AuthTypeDefault: + s.logger.Info("Using default SDK authentication flow") } - return config, nil + apiClient, err := stackitdnsclient.NewAPIClient(options...) + if err != nil { + return repository.Config{}, fmt.Errorf("failed to create STACKIT API client: %w", err) + } + + return repository.Config{ + ProjectId: cfg.ProjectId, + ApiClient: apiClient, + }, nil } func getZoneDnsNameAndRRSetName(ch *v1alpha1.ChallengeRequest) (string, string) { - // Remove trailing . from domain domain := strings.TrimSuffix(ch.ResolvedZone, ".") return domain, ch.ResolvedFQDN diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index a9667632..3b788743 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -1,8 +1,13 @@ package resolver_test import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "fmt" "net/http" + "os" "testing" "github.com/cert-manager/cert-manager/pkg/acme/webhook" @@ -33,22 +38,28 @@ const ( keepKey = "keep-me" ) +func generateDummyPrivateKey() string { + privKey, _ := rsa.GenerateKey(rand.Reader, 2048) + privKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privKey), + } + + return string(pem.EncodeToMemory(privKeyPEM)) +} + func TestName(t *testing.T) { t.Parallel() - r := resolver.NewResolver(nil, zap.NewNop(), nil, nil, nil, nil) - assert.Equal(t, r.Name(), "stackit") } func TestInitialize(t *testing.T) { t.Parallel() - r := resolver.NewResolver(nil, zap.NewNop(), nil, nil, nil, nil) t.Run("successful init", func(t *testing.T) { t.Parallel() - kubeConfig := &rest.Config{} err := r.Initialize(kubeConfig, nil) assert.NoError(t, err) @@ -56,14 +67,13 @@ func TestInitialize(t *testing.T) { t.Run("unsuccessful init", func(t *testing.T) { t.Parallel() - kubeConfig := &rest.Config{Burst: -1, RateLimiter: nil, QPS: 1} err := r.Initialize(kubeConfig, nil) assert.Error(t, err) }) } -type presentSuite struct { +type baseResolverSuite struct { suite.Suite ctrl *gomock.Controller mockSecretFetcher *resolver_mock.MockSecretFetcher @@ -73,9 +83,10 @@ type presentSuite struct { mockZoneRepository *repository_mock.MockZoneRepository mockRRSetRepository *repository_mock.MockRRSetRepository resolver webhook.Solver + dummySAKeyJSON string } -func (s *presentSuite) SetupTest() { +func (s *baseResolverSuite) SetupTest() { s.mockSecretFetcher = resolver_mock.NewMockSecretFetcher(s.ctrl) s.mockConfigProvider = resolver_mock.NewMockConfigProvider(s.ctrl) s.mockZoneRepositoryFactory = repository_mock.NewMockZoneRepositoryFactory(s.ctrl) @@ -83,6 +94,19 @@ func (s *presentSuite) SetupTest() { s.mockZoneRepository = repository_mock.NewMockZoneRepository(s.ctrl) s.mockRRSetRepository = repository_mock.NewMockRRSetRepository(s.ctrl) + dummyKey := generateDummyPrivateKey() + s.dummySAKeyJSON = fmt.Sprintf(`{"id":"00000000-0000-0000-0000-000000000000","credentials":{"privateKey":%q}}`, dummyKey) + + s.T().Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "dummy-token") + s.T().Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test@example.com") + + wifFile, err := os.CreateTemp("", "wif-*") + s.Require().NoError(err) + _, _ = wifFile.Write([]byte("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.sig")) + wifFile.Close() + s.T().Setenv("STACKIT_FEDERATED_TOKEN_FILE", wifFile.Name()) + s.T().Cleanup(func() { os.Remove(wifFile.Name()) }) + s.resolver = resolver.NewResolver( &http.Client{}, zap.NewNop(), @@ -93,16 +117,18 @@ func (s *presentSuite) SetupTest() { ) } -func (s *presentSuite) TearDownSuite() { +func (s *baseResolverSuite) TearDownSuite() { s.ctrl.Finish() } -func TestPresentTestSuite(t *testing.T) { - t.Parallel() +type presentSuite struct { + baseResolverSuite +} +//nolint:paralleltest // manipulates global environment variables +func TestPresentTestSuite(t *testing.T) { pSuite := new(presentSuite) pSuite.ctrl = gomock.NewController(t) - suite.Run(t, pSuite) } @@ -118,7 +144,10 @@ func (s *presentSuite) TestConfigProviderError() { func (s *presentSuite) TestFailGetAuthToken() { s.mockConfigProvider.EXPECT(). LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) + Return(resolver.StackitDnsProviderConfig{ + ServiceAccountSecretRef: "secret", + }, nil) + s.mockSecretFetcher.EXPECT(). StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). Return("", fmt.Errorf("error fetching token")) @@ -132,435 +161,87 @@ func (s *presentSuite) TestFailGetAuthToken() { ) } -func (s *presentSuite) TestFailFetchZone() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) +func (s *presentSuite) setupCommonPresentMocks() { s.mockZoneRepositoryFactory.EXPECT(). NewZoneRepository(gomock.Any()). Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("error fetching zone")) - - err := s.resolver.Present(challengeRequest) - s.Error(err) - s.Containsf( - err.Error(), - "error fetching zone", - "error message should contain error from zoneRepository", - ) -} -func (s *presentSuite) TestFailFetchRRSet() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) s.mockZoneRepository.EXPECT(). FetchZone(gomock.Any(), gomock.Any()). Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, fmt.Errorf("error fetching rr set")) - - err := s.resolver.Present(challengeRequest) - s.Error(err) - s.Containsf( - err.Error(), - "error fetching rr set", - "error message should contain error from rrSetRepository", - ) -} -func (s *presentSuite) TestSuccessCreateRRSet() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) s.mockRRSetRepositoryFactory.EXPECT(). NewRRSetRepository(gomock.Any(), gomock.Any()). Return(s.mockRRSetRepository, nil) + s.mockRRSetRepository.EXPECT(). FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, repository.ErrRRSetNotFound) + s.mockRRSetRepository.EXPECT(). CreateRRSet(gomock.Any(), gomock.Any()). Return(nil) - - err := s.resolver.Present(challengeRequest) - s.NoError(err) } -func (s *presentSuite) TestSuccessUpdateRRSet() { +func (s *presentSuite) TestAuthMethodSelectionDynamicSA() { s.mockConfigProvider.EXPECT(). LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.RecordSet{ - Records: []stackitdnsclient_new.Record{}, + Return(resolver.StackitDnsProviderConfig{ + ServiceAccountSecretRef: "secret", + ServiceAccountSecretKey: "sa.json", + ServiceAccountSecretNamespace: "default", }, nil) - s.mockRRSetRepository.EXPECT(). - UpdateRRSet(gomock.Any(), matchedBy(func(rrSet stackitdnsclient_new.RecordSet) bool { - return len(rrSet.Records) == 1 - })). - Return(nil) - err := s.resolver.Present(challengeRequest) - s.NoError(err) -} - -func (s *presentSuite) TestSuccessPresentIdempotent() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) + StringFromSecret("default", "secret", "sa.json"). + Return(s.dummySAKeyJSON, nil) - challengeKey := "challenge-key" - req := &v1alpha1.ChallengeRequest{ - Config: configJson, - Key: challengeKey, - } - - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.RecordSet{ - Records: []stackitdnsclient_new.Record{ - {Content: challengeKey}, - }, - }, nil) + s.setupCommonPresentMocks() - s.mockRRSetRepository.EXPECT(). - UpdateRRSet(gomock.Any(), matchedBy(func(rrSet stackitdnsclient_new.RecordSet) bool { - return len(rrSet.Records) == 1 && rrSet.Records[0].Content == challengeKey - })). - Return(nil) - - err := s.resolver.Present(req) + err := s.resolver.Present(challengeRequest) s.NoError(err) } -//nolint:gocognit // this is a test -func (s *presentSuite) TestSuccessPresentAppended() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) +func (s *presentSuite) TestAuthMethodSelectionStaticSA() { + f, err := os.CreateTemp("", "sa.json") + s.Require().NoError(err) + defer os.Remove(f.Name()) + _, _ = f.Write([]byte(s.dummySAKeyJSON)) + f.Close() - existingKey := "existing-key" - newKey := "new-key" - req := &v1alpha1.ChallengeRequest{ - Config: configJson, - Key: newKey, - } - - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.RecordSet{ - Records: []stackitdnsclient_new.Record{ - {Content: existingKey}, - }, - }, nil) - - s.mockRRSetRepository.EXPECT(). - UpdateRRSet(gomock.Any(), matchedBy(func(rrSet stackitdnsclient_new.RecordSet) bool { - if len(rrSet.Records) != 2 { - return false - } - foundExisting := false - foundNew := false - for _, r := range rrSet.Records { - if r.Content == existingKey { - foundExisting = true - } - if r.Content == newKey { - foundNew = true - } - } - - return foundExisting && foundNew - })). - Return(nil) - - err := s.resolver.Present(req) - s.NoError(err) -} - -func (s *presentSuite) TestPresentRRSetWithEmptyRecords() { s.mockConfigProvider.EXPECT(). LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.RecordSet{ - Records: []stackitdnsclient_new.Record{}, + Return(resolver.StackitDnsProviderConfig{ + ServiceAccountKeyPath: f.Name(), }, nil) - s.mockRRSetRepository.EXPECT(). - UpdateRRSet(gomock.Any(), matchedBy(func(rrSet stackitdnsclient_new.RecordSet) bool { - return len(rrSet.Records) == 1 - })).Return(nil) - err := s.resolver.Present(challengeRequest) - s.NoError(err) -} -func (s *presentSuite) TestFailCreateRRSet() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, repository.ErrRRSetNotFound) - s.mockRRSetRepository.EXPECT(). - CreateRRSet(gomock.Any(), gomock.Any()). - Return(fmt.Errorf("error creating rr set")) + s.setupCommonPresentMocks() - err := s.resolver.Present(challengeRequest) - s.Error(err) - s.Contains(err.Error(), "error creating rr set") + err = s.resolver.Present(challengeRequest) + s.NoError(err) } -func (s *presentSuite) TestFailUpdateRRSet() { +func (s *presentSuite) TestAuthMethodSelectionWIF() { s.mockConfigProvider.EXPECT(). LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.RecordSet{ - Records: []stackitdnsclient_new.Record{}, + Return(resolver.StackitDnsProviderConfig{ + UseWorkloadIdentityFederation: true, }, nil) - s.mockRRSetRepository.EXPECT(). - UpdateRRSet(gomock.Any(), gomock.Any()). - Return(fmt.Errorf("error updating rr set")) - - err := s.resolver.Present(challengeRequest) - s.Error(err) - s.Contains(err.Error(), "error updating rr set") -} - -func (s *presentSuite) TestTTLPropagation() { - ttl := int32(600) - // Test Create - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{AcmeTxtRecordTTL: ttl}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil).AnyTimes() - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, repository.ErrRRSetNotFound) - s.mockRRSetRepository.EXPECT(). - CreateRRSet(gomock.Any(), matchedBy(func(rrSet stackitdnsclient_new.RecordSet) bool { - return rrSet.Ttl == ttl - })). - Return(nil) + s.setupCommonPresentMocks() err := s.resolver.Present(challengeRequest) s.NoError(err) - - // Test Update - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{AcmeTxtRecordTTL: ttl}, nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(gomock.Any()). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.RecordSet{ - Records: []stackitdnsclient_new.Record{}, - }, nil) - s.mockRRSetRepository.EXPECT(). - UpdateRRSet(gomock.Any(), matchedBy(func(rrSet stackitdnsclient_new.RecordSet) bool { - return rrSet.Ttl == ttl - })). - Return(nil) - - err = s.resolver.Present(challengeRequest) - s.NoError(err) -} - -func (s *presentSuite) TestAuthMethodSelection() { - // Test Service Account - s.Run("Service Account", func() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{ - ServiceAccountKeyPath: "/path/to/key", - }, nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(matchedBy(func(cfg repository.Config) bool { - return cfg.UseSaKey && cfg.SaKeyPath == "/path/to/key" - })). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, repository.ErrRRSetNotFound) - s.mockRRSetRepository.EXPECT(). - CreateRRSet(gomock.Any(), gomock.Any()). - Return(nil) - - err := s.resolver.Present(challengeRequest) - s.NoError(err) - }) - - // Test Auth Token - s.Run("Auth Token", func() { - s.mockConfigProvider.EXPECT(). - LoadConfig(gomock.Any()). - Return(resolver.StackitDnsProviderConfig{ - AuthTokenSecretRef: "secret", - }, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("token123", nil) - s.mockZoneRepositoryFactory.EXPECT(). - NewZoneRepository(matchedBy(func(cfg repository.Config) bool { - return !cfg.UseSaKey && cfg.AuthToken == "token123" - })). - Return(s.mockZoneRepository, nil) - s.mockZoneRepository.EXPECT(). - FetchZone(gomock.Any(), gomock.Any()). - Return(&stackitdnsclient_new.Zone{Id: testID}, nil) - s.mockRRSetRepositoryFactory.EXPECT(). - NewRRSetRepository(gomock.Any(), gomock.Any()). - Return(s.mockRRSetRepository, nil) - s.mockRRSetRepository.EXPECT(). - FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, repository.ErrRRSetNotFound) - s.mockRRSetRepository.EXPECT(). - CreateRRSet(gomock.Any(), gomock.Any()). - Return(nil) - - err := s.resolver.Present(challengeRequest) - s.NoError(err) - }) } type cleanSuite struct { - presentSuite + baseResolverSuite } +//nolint:paralleltest // manipulates global environment variables func TestCleanTestSuite(t *testing.T) { - t.Parallel() - cSuite := new(cleanSuite) cSuite.ctrl = gomock.NewController(t) - suite.Run(t, cSuite) } @@ -568,15 +249,15 @@ func (s *cleanSuite) setupCommonMocks() { s.mockConfigProvider.EXPECT(). LoadConfig(gomock.Any()). Return(resolver.StackitDnsProviderConfig{}, nil) - s.mockSecretFetcher.EXPECT(). - StringFromSecret(gomock.Any(), gomock.Any(), gomock.Any()). - Return("", nil) + s.mockZoneRepositoryFactory.EXPECT(). NewZoneRepository(gomock.Any()). Return(s.mockZoneRepository, nil) + s.mockZoneRepository.EXPECT(). FetchZone(gomock.Any(), gomock.Any()). Return(&stackitdnsclient_new.Zone{Id: testID}, nil) + s.mockRRSetRepositoryFactory.EXPECT(). NewRRSetRepository(gomock.Any(), gomock.Any()). Return(s.mockRRSetRepository, nil) @@ -584,6 +265,7 @@ func (s *cleanSuite) setupCommonMocks() { func (s *cleanSuite) TestFailFetchRRSet() { s.setupCommonMocks() + s.mockRRSetRepository.EXPECT(). FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("error fetching rr set")) @@ -599,6 +281,7 @@ func (s *cleanSuite) TestFailFetchRRSet() { func (s *cleanSuite) TestFailFetchNoRRSet() { s.setupCommonMocks() + s.mockRRSetRepository.EXPECT(). FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, repository.ErrRRSetNotFound) @@ -626,7 +309,6 @@ func (s *cleanSuite) TestCleanUp_RemovesOnlyKey_DeletesRRSet() { FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). Return(&rrset, nil) - // Because it was the only key, the slice becomes empty, so we expect a DeleteRRSet s.mockRRSetRepository.EXPECT(). DeleteRRSet(gomock.Any(), rrset.Id). Return(nil) @@ -655,11 +337,9 @@ func (s *cleanSuite) TestCleanUp_RemovesOneKey_UpdatesRRSet() { FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). Return(&rrset, nil) - // Because one key remains, we expect an UpdateRRSet, NOT a DeleteRRSet s.mockRRSetRepository.EXPECT(). UpdateRRSet(gomock.Any(), matchedBy(func(updated stackitdnsclient_new.RecordSet) bool { - return len(updated.Records) == 1 && - updated.Records[0].Content == keepKey + return len(updated.Records) == 1 && updated.Records[0].Content == keepKey })). Return(nil) @@ -686,8 +366,6 @@ func (s *cleanSuite) TestCleanUp_KeyNotFound_DoesNothing() { FetchRRSetForZone(gomock.Any(), gomock.Any(), gomock.Any()). Return(&rrset, nil) - // We do NOT expect DeleteRRSet or UpdateRRSet to be called. - err := s.resolver.CleanUp(req) s.NoError(err) } From 2ff32cccff0e4d2237ea7b4b9e9778290a767b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Tue, 25 Aug 2026 14:24:57 +0000 Subject: [PATCH 2/5] Add integration test for the multitenant auth flow. --- Makefile | 18 +++-- .../test-suite/dynamic-sa-auth/00-assert.yaml | 19 +++++ .../dynamic-sa-auth/00-issuer-cert.yaml | 71 +++++++++++++++++++ .../test-suite/dynamic-sa-auth/01-assert.yaml | 26 +++++++ .../test-suite/dynamic-sa-auth/01-delete.yaml | 11 +++ 5 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/test-suite/dynamic-sa-auth/00-assert.yaml create mode 100644 tests/e2e/test-suite/dynamic-sa-auth/00-issuer-cert.yaml create mode 100644 tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml create mode 100644 tests/e2e/test-suite/dynamic-sa-auth/01-delete.yaml diff --git a/Makefile b/Makefile index 535c7e48..f0c82fd7 100644 --- a/Makefile +++ b/Makefile @@ -134,21 +134,31 @@ e2e-cert-manager: kubectl wait --for=condition=Available --timeout=300s deployment/cert-manager -n cert-manager kubectl wait --for=condition=Available --timeout=300s deployment/cert-manager-webhook -n cert-manager +.PHONY: e2e-namespaces +e2e-namespaces: + @echo "=> Creating test namespaces and secrets..." + kubectl create namespace e2e-tenant --dry-run=client -o yaml | kubectl apply -f - + kubectl create secret generic stackit-dynamic-auth -n e2e-tenant \ + --from-file=sa.json=$(AUTH_KEY_PATH) \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl create namespace e2e-tenant-two --dry-run=client -o yaml | kubectl apply -f - + kubectl create secret generic stackit-dynamic-auth -n e2e-tenant-two \ + --from-file=sa.json=$(AUTH_KEY_PATH) \ + --dry-run=client -o yaml | kubectl apply -f - + .PHONY: e2e-deploy-webhook e2e-deploy-webhook: @echo "=> Deploying stackit-cert-manager-webhook..." kubectl create secret generic stackit-sa-authentication -n cert-manager \ --from-file=sa.json=$(AUTH_KEY_PATH) \ --dry-run=client -o yaml | kubectl apply -f - - helm upgrade --install stackit-cert-manager-webhook ./deploy/stackit \ --namespace cert-manager \ --set image.repository=stackitcloud/stackit-cert-manager-webhook \ --set image.tag=e2e \ --set image.pullPolicy=Never \ --set stackitSaAuthentication.enabled=true \ - --set stackitSaAuthentication.secretName=stackit-sa-authentication - + --set stackitSaAuthentication.secretAccessScope=issuer kubectl wait --for=condition=available --timeout=120s deployment/stackit-cert-manager-webhook -n cert-manager .PHONY: e2e-run-kuttl @@ -171,6 +181,6 @@ clean-e2e-local: # The main target chains the dependencies together .PHONY: test-e2e-local -test-e2e-local: e2e-check-env e2e-cluster e2e-cert-manager e2e-deploy-webhook +test-e2e-local: e2e-check-env e2e-cluster e2e-cert-manager e2e-namespaces e2e-deploy-webhook @$(MAKE) e2e-run-kuttl || ( $(MAKE) clean-e2e-local && exit 1 ) @$(MAKE) clean-e2e-local \ No newline at end of file diff --git a/tests/e2e/test-suite/dynamic-sa-auth/00-assert.yaml b/tests/e2e/test-suite/dynamic-sa-auth/00-assert.yaml new file mode 100644 index 00000000..090be20d --- /dev/null +++ b/tests/e2e/test-suite/dynamic-sa-auth/00-assert.yaml @@ -0,0 +1,19 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: e2e-tenant-cert + namespace: e2e-tenant +status: + conditions: + - type: Ready + status: "True" +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: e2e-tenant-cert-two + namespace: e2e-tenant-two +status: + conditions: + - type: Ready + status: "True" \ No newline at end of file diff --git a/tests/e2e/test-suite/dynamic-sa-auth/00-issuer-cert.yaml b/tests/e2e/test-suite/dynamic-sa-auth/00-issuer-cert.yaml new file mode 100644 index 00000000..713f830a --- /dev/null +++ b/tests/e2e/test-suite/dynamic-sa-auth/00-issuer-cert.yaml @@ -0,0 +1,71 @@ +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: stackit-tenant-issuer + namespace: e2e-tenant +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: e2e-tenant@stackit.de + privateKeySecretRef: + name: stackit-tenant-issuer-key + solvers: + - dns01: + webhook: + solverName: stackit + groupName: acme.stackit.de + config: + projectId: ${PROJECT_ID} + serviceAccountSecretRef: stackit-dynamic-auth + serviceAccountSecretKey: sa.json + serviceAccountSecretNamespace: e2e-tenant +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: e2e-tenant-cert + namespace: e2e-tenant +spec: + secretName: e2e-tenant-cert-tls + issuerRef: + name: stackit-tenant-issuer + kind: Issuer + commonName: "e2e-tenant.${ZONE_NAME}" + dnsNames: + - "e2e-tenant.${ZONE_NAME}" +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: stackit-tenant-issuer-two + namespace: e2e-tenant-two +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: e2e-tenant-two@stackit.de + privateKeySecretRef: + name: stackit-tenant-issuer-key-two + solvers: + - dns01: + webhook: + solverName: stackit + groupName: acme.stackit.de + config: + projectId: ${PROJECT_ID} + serviceAccountSecretRef: stackit-dynamic-auth + serviceAccountSecretKey: sa.json + serviceAccountSecretNamespace: e2e-tenant-two +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: e2e-tenant-cert-two + namespace: e2e-tenant-two +spec: + secretName: e2e-tenant-cert-tls-two + issuerRef: + name: stackit-tenant-issuer-two + kind: Issuer + commonName: "e2e-tenant-two.${ZONE_NAME}" + dnsNames: + - "e2e-tenant-two.${ZONE_NAME}" \ No newline at end of file diff --git a/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml b/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml new file mode 100644 index 00000000..f65d8baa --- /dev/null +++ b/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml @@ -0,0 +1,26 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +commands: + - script: | + ZONE="${ZONE_NAME}" + + AUTH_NS=$(dig +short NS "$ZONE" | head -n 1) + if [ -z "$AUTH_NS" ]; then + echo "ERROR: Could not determine authoritative nameserver for $ZONE" + return 1 + fi + + check_record() { + local RECORD_NAME=$1 + RESULT=$(dig "@$AUTH_NS" -t TXT +short "$RECORD_NAME") + if [ -n "$RESULT" ]; then + echo "FAILED: $RECORD_NAME still exists. Result: $RESULT" + return 1 + fi + echo "SUCCESS: TXT record $RECORD_NAME successfully deleted." + return 0 + } + + check_record "_acme-challenge.e2e-tenant.${ZONE_NAME}" && \ + check_record "_acme-challenge.e2e-tenant-two.${ZONE_NAME}" \ No newline at end of file diff --git a/tests/e2e/test-suite/dynamic-sa-auth/01-delete.yaml b/tests/e2e/test-suite/dynamic-sa-auth/01-delete.yaml new file mode 100644 index 00000000..bc324904 --- /dev/null +++ b/tests/e2e/test-suite/dynamic-sa-auth/01-delete.yaml @@ -0,0 +1,11 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: cert-manager.io/v1 + kind: Certificate + name: e2e-tenant-cert + namespace: e2e-tenant + - apiVersion: cert-manager.io/v1 + kind: Certificate + name: e2e-tenant-cert-two + namespace: e2e-tenant-two \ No newline at end of file From 28430c63ef1ad1cfc5e918c56da10482efd67415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Tue, 25 Aug 2026 15:34:02 +0000 Subject: [PATCH 3/5] Update documentation --- Makefile | 2 +- README.md | 300 ++++++++---------- deploy/stackit/README.md | 16 +- deploy/stackit/values.yaml | 16 +- e2e_test/README.md | 83 +++-- testdata/stackit/README.md | 9 +- testdata/stackit/config.json | 6 +- testdata/stackit/secret.yaml.example | 2 +- .../wildcard-certificate/00-issuer-cert.yaml | 9 +- 9 files changed, 201 insertions(+), 242 deletions(-) diff --git a/Makefile b/Makefile index f0c82fd7..6b00d868 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ out/report.json: .PHONY: test-e2e-conformance test-e2e-conformance: - @STACKIT_TOKEN=$(STACKIT_TOKEN) TEST_ZONE_NAME=$(TEST_ZONE_NAME) go test -race -tags=e2e ./... -coverprofile out/cover.out + @TEST_ZONE_NAME=$(TEST_ZONE_NAME) go test -race -tags=e2e ./... -coverprofile out/cover.out run: go run cmd/webhook/main.go diff --git a/README.md b/README.md index d10a1ffe..173a3ddb 100644 --- a/README.md +++ b/README.md @@ -9,186 +9,145 @@ [![CI](https://github.com/stackitcloud/stackit-cert-manager-webhook/actions/workflows/main.yml/badge.svg)](https://github.com/stackitcloud/stackit-cert-manager-webhook/actions/workflows/main.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/stackitcloud/stackit-cert-manager-webhook)](https://goreportcard.com/report/github.com/stackitcloud/stackit-cert-manager-webhook) -Facilitate a webhook integration for leveraging the STACKIT DNS alongside -its [API](https://docs.api.stackit.cloud/documentation/dns/version/v1) to act as a DNS01 -ACME Issuer with [cert-manager](https://cert-manager.io/docs/). +Facilitate a webhook integration for leveraging the STACKIT DNS alongside its [API](https://docs.api.stackit.cloud/documentation/dns/version/v1) to act as a DNS01 ACME Issuer with [cert-manager](https://cert-manager.io/docs/). ## Installation ```bash -helm repo add stackit-cert-manager-webhook https://stackitcloud.github.io/stackit-cert-manager-webhook +helm repo add stackit-cert-manager-webhook [https://stackitcloud.github.io/stackit-cert-manager-webhook](https://stackitcloud.github.io/stackit-cert-manager-webhook) helm install stackit-cert-manager-webhook --namespace cert-manager stackit-cert-manager-webhook/stackit-cert-manager-webhook ``` -## Usage - -1. ***Initiation of STACKIT Service Account Secret:*** - ```bash - kubectl create secret generic stackit-sa-authentication \ - -n cert-manager \ - --from-literal=sa.json='{ - "id": "4e1fe486-b463-4bcd-9210-288854268e34", - "publicKey": "-----BEGIN PUBLIC KEY-----\nPUBLIC_KEY\n-----END PUBLIC KEY-----", - "createdAt": "2024-04-02T13:12:17.678+00:00", - "validUntil": "2024-04-15T22:00:00.000+00:00", - "keyType": "USER_MANAGED", - "keyOrigin": "GENERATED", - "keyAlgorithm": "RSA_2048", - "active": true, - "credentials": { - "kid": "kid", - "iss": "iss", - "sub": "sub", - "aud": "aud", - "privateKey": "-----BEGIN PRIVATE KEY-----\nPRIVATE-KEY==\n-----END PRIVATE KEY-----" - } - }' - ``` - You now need to adjust the deployment via helm to use the secret: - ```bash - helm upgrade stackit-cert-manager-webhook \ - --namespace cert-manager \ - stackit-cert-manager-webhook/stackit-cert-manager-webhook \ +## Authentication & Usage + +The STACKIT webhook requires authentication against the STACKIT DNS API. Depending on your cluster architecture and security policies, you can authenticate using one of the three methods below. + +The webhook will explicitly fail if multiple mutually exclusive authentication methods are configured for a single Issuer. + +### Option A: Dynamic Service Account Key (Multi-Tenant) + +This method is recommended for multi-tenant clusters where different `Issuer` or `ClusterIssuer` resources manage zones across different STACKIT projects. The webhook fetches the Service Account JSON directly from a Kubernetes Secret per challenge. + +1. **Create the Secret containing the SA JSON:** + ```bash + kubectl create secret generic stackit-tenant-a-auth \ + -n default \ + --from-file=sa.json=/path/to/tenant-a-sa.json + ``` + +2. **Configure the Issuer:** + Ensure the `serviceAccountSecretNamespace` matches the namespace of your Secret. If you want the webhook to read secrets outside of its own installation namespace, you must set `stackitSaAuthentication.secretAccessScope=issuer` when installing the Helm chart. + ```yaml + apiVersion: cert-manager.io/v1 + kind: Issuer + metadata: + name: letsencrypt-prod + namespace: default + spec: + acme: + server: [https://acme-v02.api.letsencrypt.org/directory](https://acme-v02.api.letsencrypt.org/directory) + email: example@example.com + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - dns01: + webhook: + solverName: stackit + groupName: acme.stackit.de + config: + projectId: + serviceAccountSecretRef: stackit-tenant-a-auth + serviceAccountSecretKey: sa.json + serviceAccountSecretNamespace: default + ``` + +### Option B: Static Service Account Key (Single Tenant / Global Fallback) + +This method mounts a single Service Account key JSON file into the webhook Pod. It is ideal for single-tenant clusters where the webhook manages domains for a single STACKIT project or organization. + +1. **Deploy the Webhook with the Key Mounted:** + Create a secret in the `cert-manager` namespace and install the Helm chart with mounting enabled: + ```bash + kubectl create secret generic stackit-sa-authentication \ + -n cert-manager \ + --from-file=sa.json=/path/to/global-sa.json + + helm upgrade --install stackit-cert-manager-webhook stackit-cert-manager-webhook/stackit-cert-manager-webhook \ + --namespace cert-manager \ --set stackitSaAuthentication.enabled=true - ``` - -2. ***Configuration of ClusterIssuer/Issuer:*** - For scenarios wherein zones and record sets are encapsulated within a singular project, utilize a ClusterIssuer: - ```yaml - apiVersion: cert-manager.io/v1 - kind: ClusterIssuer - metadata: - name: letsencrypt-prod - spec: - acme: - server: https://acme-v02.api.letsencrypt.org/directory - email: example@example.com # Replace this with your email address - privateKeySecretRef: - name: letsencrypt-prod - solvers: - - dns01: - webhook: - solverName: stackit - groupName: acme.stackit.de - config: - projectId: - ``` - - For diverse project architectures where zones are spread across varying projects, use an Issuer (namespaces are separate): - ```yaml - apiVersion: cert-manager.io/v1 - kind: Issuer - metadata: - name: letsencrypt-prod - namespace: default - spec: - acme: - server: https://acme-v02.api.letsencrypt.org/directory - email: example@example.com # Replace this with your email address - privateKeySecretRef: - name: letsencrypt-prod - solvers: - - dns01: - webhook: - solverName: stackit - groupName: acme.stackit.de - config: - projectId: - ``` - *Note on service accounts and namespaces:* - - Issuer-per-namespace (recommended for isolation): create a STACKIT service-account key (sa.json) for each STACKIT project you need to manage and place that key in a Kubernetes Secret in the same namespace as the Issuer. This means one sa.json (one SA key) per Issuer/namespace when the Issuers target different STACKIT projects. - Example (create a secret in the Issuer namespace): - ```bash - kubectl create secret generic stackit-sa-authentication \ - -n \ - --from-literal=sa.json='{"id":"...","credentials":{...}}' - ``` - Ensure the webhook can read the secret in that namespace (create the secret where the Issuer lives). - - Alternative (single SA key for multiple projects): you can grant the service account broader permissions at folder or organization level so one sa.json can manage zones across multiple projects. This is more convenient but grants wider access — evaluate security and follow least-privilege principles. - - Tradeoffs: - - Per-namespace/per-project SA keys: better isolation and least privilege, easier to rotate keys per project. - - Folder/org-level SA key: lower operational overhead (single key), but larger blast radius if compromised. - -3. ***Demonstration of Ingress Integration with Wildcard SSL/TLS Certificate Generation*** - Given the preceding configuration, it is possible to exploit the capabilities of the Issuer or ClusterIssuer to - dynamically produce wildcard SSL/TLS certificates in the following manner: - ```yaml - apiVersion: cert-manager.io/v1 - kind: Certificate - metadata: - name: wildcard-example - namespace: default - spec: - secretName: wildcard-example-tls - issuerRef: - name: letsencrypt-prod - kind: Issuer - commonName: '*.example.runs.onstackit.cloud' # project must be the owner of this zone - duration: 8760h0m0s - dnsNames: - - example.runs.onstackit.cloud - - '*.example.runs.onstackit.cloud' - --- - apiVersion: networking.k8s.io/v1 - kind: Ingress - metadata: - name: app-ingress - namespace: default - annotations: - ingress.kubernetes.io/rewrite-target: / - kubernetes.io/ingress.class: "nginx" - spec: - rules: - - host: "app.example.runs.onstackit.cloud" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: webapp - port: - number: 80 - tls: - - hosts: - - "app.example.runs.onstackit.cloud" - secretName: wildcard-example-tls - ``` + ``` + +2. **Configure the Issuer:** + Reference the mounted file path. + ```yaml + apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + metadata: + name: letsencrypt-prod + spec: + acme: + server: [https://acme-v02.api.letsencrypt.org/directory](https://acme-v02.api.letsencrypt.org/directory) + email: example@example.com + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - dns01: + webhook: + solverName: stackit + groupName: acme.stackit.de + config: + projectId: + serviceAccountKeyPath: /var/run/secrets/stackit/sa.json + ``` + +### Option C: Workload Identity Federation (WIF) + +If your cluster supports Workload Identity Federation (e.g., SKE clusters), you can avoid managing long-lived keys entirely by projecting a short-lived token into the webhook container. + +1. **Annotate the Webhook ServiceAccount:** + Update your Helm deployment to instruct the identity webhook to inject the federated token. + ```yaml + # values.yaml + serviceAccount: + annotations: + workload-identity.stackit.cloud/service-account-email: "your-service-account@sa.stackit.cloud" + ``` + +2. **Configure the Issuer:** + Explicitly instruct the webhook to use the WIF flow. + ```yaml + apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + metadata: + name: letsencrypt-prod + spec: + acme: + # ... + solvers: + - dns01: + webhook: + solverName: stackit + groupName: acme.stackit.de + config: + projectId: + useWorkloadIdentityFederation: true + ``` ## Config Options -The following table delineates the configuration options available for the STACKIT Cert Manager Webhook: - -```yaml -apiVersion: cert-manager.io/v1 -kind: Issuer -metadata: - name: letsencrypt-prod - namespace: default -spec: - acme: - server: https://acme-v02.api.letsencrypt.org/directory - email: example@example.com # Replace this with your email address - privateKeySecretRef: - name: letsencrypt-prod - solvers: - - dns01: - webhook: - solverName: stackit - groupName: acme.stackit.de - config: - projectId: string - apiBasePath: string - serviceAccountKeyPath: string - serviceAccountBaseUrl: string - acmeTxtRecordTTL: int64 -``` +The following table delineates the configuration options available under the `config` block of the STACKIT Cert Manager Webhook solver: -- projectId: The unique identifier for the STACKIT project. -- apiBasePath: The base path for the STACKIT DNS API. (Default: https://dns.api.stackit.cloud) -- serviceAccountKeyPath: The path to the service account key file. The file must be mounted into the container. -- serviceAccountBaseUrl: The base URL for the STACKIT service account API. (Default: https://service-account.api.stackit.cloud/token) -- acmeTxtRecordTTL: The TTL for the ACME TXT record. (Default: 600) +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `projectId` | string | `""` | **Required.** The unique identifier for the STACKIT project. | +| `apiBasePath` | string | `"https://dns.api.stackit.cloud"` | The base path for the STACKIT DNS API. | +| `serviceAccountSecretRef` | string | `""` | Name of the Kubernetes Secret containing the SA JSON. | +| `serviceAccountSecretKey` | string | `""` | The key within the Secret mapped to the JSON content. | +| `serviceAccountSecretNamespace` | string | `` | The namespace where the Secret is located. | +| `serviceAccountKeyPath` | string | `""` | The absolute file path to a statically mounted SA JSON key inside the webhook container. | +| `useWorkloadIdentityFederation` | bool | `false` | Explicitly enables STACKIT Workload Identity Federation authentication. | +| `serviceAccountBaseUrl` | string | `""` | Custom URL for trading SA keys for access tokens. | +| `acmeTxtRecordTTL` | int32 | `600` | The TTL for the ACME TXT challenge record. | ## Test Procedures @@ -210,12 +169,13 @@ make lint ### Go Conformance Testing: Runs the official cert-manager Go solver test suite in memory against the STACKIT API: ```bash -STACKIT_TOKEN="" TEST_ZONE_NAME="example.com" make test-e2e-conformance +AUTH_KEY_PATH="" TEST_ZONE_NAME="example.com" make test-e2e-conformance ``` Follow the comprehensive guide available [here](e2e_test/README.md). ### Kubernetes Integration (E2E) Testing: -Spins up a local Kind cluster, installs cert-manager, builds and deploys the webhook, and executes Kuttl integration tests (testing single-record lifecycle and wildcard certificates against Let's Encrypt Staging): +Spins up a local Kind cluster, installs cert-manager, builds and deploys the webhook, and executes Kuttl integration tests covering both single-tenant (static fallback) and multi-tenant (dynamic SA fetching) flows against Let's Encrypt Staging: + ```bash make test-e2e-local \ PROJECT_ID="" \ diff --git a/deploy/stackit/README.md b/deploy/stackit/README.md index d574cb1c..41bde10d 100644 --- a/deploy/stackit/README.md +++ b/deploy/stackit/README.md @@ -1,6 +1,6 @@ # stackit-cert-manager-webhook -![Version: 0.4.1](https://img.shields.io/badge/Version-0.4.1-informational?style=flat-square) ![AppVersion: v0.4.1](https://img.shields.io/badge/AppVersion-v0.4.1-informational?style=flat-square) +![Version: 0.4.11](https://img.shields.io/badge/Version-0.4.11-informational?style=flat-square) ![AppVersion: v0.4.11](https://img.shields.io/badge/AppVersion-v0.4.11-informational?style=flat-square) A Helm chart for stackitcloud/stackit-cert-manager-webhook @@ -35,11 +35,13 @@ A Helm chart for stackitcloud/stackit-cert-manager-webhook | service | object | `{"port":443,"type":"ClusterIP"}` | Configuration for the webhook service. | | service.port | int | `443` | port of the service. | | service.type | string | `"ClusterIP"` | type of the service. | -| stackitSaAuthentication | object | `{"enabled":false,"fileName":"sa.json","mountPath":"/var/run/secrets/stackit","secretAccessScope":"webhook","secretName":"stackit-sa-authentication"}` | Configuration for the stackit service account keys. | -| stackitSaAuthentication.enabled | bool | `false` | enabled flag for the stackit service account keys. | -| stackitSaAuthentication.fileName | string | `"sa.json"` | key of the service account key in the secret. Which will be later be used to load in keys in the pod as well. | -| stackitSaAuthentication.mountPath | string | `"/var/run/secrets/stackit"` | Path where the secret will be mounted in the pod. | -| stackitSaAuthentication.secretAccessScope | string | `"webhook"` | secret access scope for auth token lookup. Use `webhook` for namespace-scoped access (recommended) or `issuer` to allow issuer-namespace secret lookup. | -| stackitSaAuthentication.secretName | string | `"stackit-sa-authentication"` | secret where the service account key is stored. Should be in the same namespace as the webhook since it will be mounted into the pod. | +| serviceAccount | object | `{"annotations":{}}` | Service Account | +| serviceAccount.annotations | object | `{}` | service account annotations. Add workload-identity annotations here for WIF authentication. | +| stackitSaAuthentication | object | `{"enabled":false,"fileName":"sa.json","mountPath":"/var/run/secrets/stackit","secretAccessScope":"webhook","secretName":"stackit-sa-authentication"}` | Configuration for the stackit service account keys (Static Global Fallback). | +| stackitSaAuthentication.enabled | bool | `false` | enabled flag for mounting the static STACKIT Service Account key JSON. | +| stackitSaAuthentication.fileName | string | `"sa.json"` | key of the Service Account JSON within the secret. | +| stackitSaAuthentication.mountPath | string | `"/var/run/secrets/stackit"` | Path where the secret will be mounted in the pod. Must be /var/run/secrets/stackit/ | +| stackitSaAuthentication.secretAccessScope | string | `"webhook"` | secret access scope for dynamic SA lookup. Use "webhook" for namespace-scoped access (recommended) or "issuer" to allow issuer-namespace secret lookup. | +| stackitSaAuthentication.secretName | string | `"stackit-sa-authentication"` | secret where the static Service Account key JSON is stored. Should be in the same namespace as the webhook since it will be mounted into the pod. | | tolerations | list | `[]` | Tolerations for the webhook. | diff --git a/deploy/stackit/values.yaml b/deploy/stackit/values.yaml index 16cc2245..8a045adf 100644 --- a/deploy/stackit/values.yaml +++ b/deploy/stackit/values.yaml @@ -19,9 +19,9 @@ certManager: # -- service account name for the cert-manager. serviceAccountName: cert-manager -# -- Service Account +# -- Service Account serviceAccount: - # -- service account annotations. + # -- service account annotations. Add workload-identity annotations here for WIF authentication. annotations: {} # -- Image information for the webhook. @@ -40,16 +40,16 @@ nameOverride: "" # -- Fullname override of the webhook. fullnameOverride: "" -# -- Configuration for the stackit service account keys. +# -- Configuration for the stackit service account keys (Static Global Fallback). stackitSaAuthentication: - # -- enabled flag for the stackit service account keys. + # -- enabled flag for mounting the static STACKIT Service Account key JSON. enabled: false # -- controls whether the webhook secret is read only from the webhook namespace or may be read from the issuer namespace. - # -- secret access scope for auth token lookup. Use "webhook" for namespace-scoped access (recommended) or "issuer" to allow issuer-namespace secret lookup. + # -- secret access scope for dynamic SA lookup. Use "webhook" for namespace-scoped access (recommended) or "issuer" to allow issuer-namespace secret lookup. secretAccessScope: webhook - # -- secret where the service account key is stored. Should be in the same namespace as the webhook since it will be mounted into the pod. + # -- secret where the static Service Account key JSON is stored. Should be in the same namespace as the webhook since it will be mounted into the pod. secretName: stackit-sa-authentication - # -- key of the service account key in the secret. Which will be later be used to load in keys in the pod as well. + # -- key of the Service Account JSON within the secret. fileName: sa.json # -- Path where the secret will be mounted in the pod. Must be /var/run/secrets/stackit/ mountPath: /var/run/secrets/stackit @@ -107,7 +107,7 @@ additionalVolumeMounts: [] # mountPath: /etc/extra-config # readOnly: true -# -- Placeholder for additional env-variables. Apply via "--set"-command or +# -- Placeholder for additional env-variables. Apply via "--set"-command or # -- delete the next line and add your variables as in the commented example below. extraEnv: [] # extraEnv: diff --git a/e2e_test/README.md b/e2e_test/README.md index 7fd38cc2..a251c864 100644 --- a/e2e_test/README.md +++ b/e2e_test/README.md @@ -1,49 +1,46 @@ -# End-to-End (E2E) Test Suite +# End-to-End (E2E) Conformance Test Suite -This repository segment encapsulates the comprehensive E2E testing procedures for this project module. It harnesses the `fixture.RunConformance` method to execute conformity verification sequences, ensuring adherence to `cert-manager` established protocols. +This directory contains the cert-manager conformance testing procedures for this project module. It harnesses the `fixture.RunConformance` method to execute conformity verification sequences, ensuring adherence to `cert-manager` established protocols. ## Test Execution Workflow -### Environmental Prerequisite Configuration: -For appropriate test initialization within the STACKIT ecosystem, it is imperative to align the environment with -the predetermined specifications. STACKIT dictates a structural model where a parent project umbrellas various -resource entities, inclusive of DNS zones. - -1. **Project Identification Parameterization**: -Configure the unique `project_id` in the [configuration manifest](../testdata/stackit/config.json). Typical configuration appears as: - ```json - { - "projectId": "c242332a-ae82-42e2-80e8-eed338fd2b2f", - "authTokenSecretNamespace": "default" - } - ``` - - This instantiation assumes the existence of the specified project, and the associated authentication - token possesses requisite privileges for project and zone access. -2. **Authentication Token Configuration**: -Establish an environment variable for the authentication token, duly vested with CRUD permissions for DNS zones: - ```bash - export STACKIT_TOKEN="" - ``` -3. **Zone Initialization**: -Declare the testing DNS zone. Ensure project_id consistency: - ```bash - export TEST_ZONE_NAME="test-zone.runs.onstackit.cloud" - ``` - Invoke the following HTTP request to either instantiate a fresh zone or validate the existing one: - ```bash - curl --location "https://dns.api.stackit.cloud/v1/projects/c242332a-ae82-42e2-80e8-eed338fd2b2f/zones" \ - --header 'Content-Type: application/json' \ - --header "Authorization: Bearer $AUTHENTICATION_TOKEN" \ - --data '{ - "name": "cert manager e2e test", - "dnsName": "$TEST_ZONE_NAME" - }' - ``` - Post successful invocation, validate zone ownership. For pre-existing zones, consider a unique zone parameter. - -### Environmental Prerequisite Configuration: -With prerequisites addressed, proceed to run the entire E2E test suite: +### Environmental Prerequisite Configuration + +For appropriate test initialization within the STACKIT ecosystem, you must configure your local environment and the mock data properly. + +1. **Project Identification Parameterization**: + Configure the unique `projectId` in the [configuration manifest](../testdata/stackit/config.json). Typical configuration appears as: + ```json + { + "projectId": "c242332a-ae82-42e2-80e8-eed338fd2b2f", + "serviceAccountSecretRef": "stackit-cert-manager-webhook", + "serviceAccountSecretKey": "sa.json", + "serviceAccountSecretNamespace": "default" + } + ``` + This assumes the specified project exists, and the Service Account key possesses the requisite DNS Admin privileges. + +2. **Authentication Key Configuration**: + The conformance test suite dynamically loads resources from `testdata/stackit/` into its mocked API server. You must create a valid Kubernetes Secret manifest containing your Service Account JSON. + + Generate `secret.yaml` from the provided example: + ```bash + export STACKIT_SA_KEY_B64=$(cat /path/to/your/sa.json | base64 -w 0) + envsubst < ../testdata/stackit/secret.yaml.example > ../testdata/stackit/secret.yaml + ``` + +3. **Zone Initialization**: + Ensure your testing DNS zone exists in your STACKIT project. You can create this via the STACKIT Portal or the STACKIT CLI. + + Declare the testing DNS zone as an environment variable before running the suite: + ```bash + export TEST_ZONE_NAME="test-zone.runs.onstackit.cloud" + ``` + +### Running the Suite + +With prerequisites addressed and the `secret.yaml` populated, proceed to run the conformance test suite: + ```bash make test-e2e-conformance -``` +``` \ No newline at end of file diff --git a/testdata/stackit/README.md b/testdata/stackit/README.md index 7d0c204a..122f41de 100644 --- a/testdata/stackit/README.md +++ b/testdata/stackit/README.md @@ -1,11 +1,10 @@ -# Cert-Manager ACME DNS01 Webhook Solver for STACKIT DNS Manager +# Cert-Manager ACME DNS01 Webhook Solver for STACKIT DNS ## testdata Directory -Copy the example Secret files, replacing $STACKIT_TOKEN with your STACKIT API -token: +Copy the example Secret file, replacing `$STACKIT_SA_KEY` with your STACKIT Service Account JSON key: ```bash -$ export STACKIT_TOKEN=$(echo -n "" | base64) +$ export STACKIT_SA_KEY_B64=$(cat /path/to/sa.json | base64 -w 0) $ envsubst < testdata/stackit/secret.yaml.example | kubectl apply -f - -``` +``` \ No newline at end of file diff --git a/testdata/stackit/config.json b/testdata/stackit/config.json index 9c83c4a6..7f53e671 100644 --- a/testdata/stackit/config.json +++ b/testdata/stackit/config.json @@ -1,4 +1,6 @@ { "projectId": "c242332a-ae82-42e2-80e8-eed338fd2b2f", - "authTokenSecretNamespace": "default" -} + "serviceAccountSecretRef": "stackit-cert-manager-webhook", + "serviceAccountSecretKey": "sa.json", + "serviceAccountSecretNamespace": "default" +} \ No newline at end of file diff --git a/testdata/stackit/secret.yaml.example b/testdata/stackit/secret.yaml.example index 8b826570..59097754 100644 --- a/testdata/stackit/secret.yaml.example +++ b/testdata/stackit/secret.yaml.example @@ -4,4 +4,4 @@ metadata: name: stackit-cert-manager-webhook type: Opaque data: - auth-token: $STACKIT_TOKEN + sa.json: $STACKIT_SA_KEY \ No newline at end of file diff --git a/tests/e2e/test-suite/wildcard-certificate/00-issuer-cert.yaml b/tests/e2e/test-suite/wildcard-certificate/00-issuer-cert.yaml index 3aa13734..924c8216 100644 --- a/tests/e2e/test-suite/wildcard-certificate/00-issuer-cert.yaml +++ b/tests/e2e/test-suite/wildcard-certificate/00-issuer-cert.yaml @@ -1,8 +1,7 @@ apiVersion: cert-manager.io/v1 -kind: Issuer +kind: ClusterIssuer metadata: - name: stackit-e2e-wildcard-issuer - namespace: default + name: stackit-e2e-wildcard-clusterissuer spec: acme: server: https://acme-staging-v02.api.letsencrypt.org/directory @@ -25,8 +24,8 @@ metadata: spec: secretName: e2e-wildcard-cert-tls issuerRef: - name: stackit-e2e-wildcard-issuer - kind: Issuer + name: stackit-e2e-wildcard-clusterissuer + kind: ClusterIssuer commonName: "*.e2e-wildcard.${ZONE_NAME}" dnsNames: - "e2e-wildcard.${ZONE_NAME}" From 75dfdc45f25a56f4f7ae7b90f62dafaa14a96c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Tue, 25 Aug 2026 16:34:22 +0000 Subject: [PATCH 4/5] fix: code review --- README.md | 8 ++--- e2e_test/README.md | 2 +- internal/resolver/config.go | 6 ++-- internal/resolver/resolver_test.go | 2 +- testdata/stackit/README.md | 2 +- .../test-suite/dynamic-sa-auth/01-assert.yaml | 32 ++++++++++--------- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 173a3ddb..7ea09ba8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Facilitate a webhook integration for leveraging the STACKIT DNS alongside its [A ## Installation ```bash -helm repo add stackit-cert-manager-webhook [https://stackitcloud.github.io/stackit-cert-manager-webhook](https://stackitcloud.github.io/stackit-cert-manager-webhook) +helm repo add stackit-cert-manager-webhook https://stackitcloud.github.io/stackit-cert-manager-webhook helm install stackit-cert-manager-webhook --namespace cert-manager stackit-cert-manager-webhook/stackit-cert-manager-webhook ``` @@ -45,7 +45,7 @@ This method is recommended for multi-tenant clusters where different `Issuer` or namespace: default spec: acme: - server: [https://acme-v02.api.letsencrypt.org/directory](https://acme-v02.api.letsencrypt.org/directory) + server: https://acme-v02.api.letsencrypt.org/directory email: example@example.com privateKeySecretRef: name: letsencrypt-prod @@ -86,7 +86,7 @@ This method mounts a single Service Account key JSON file into the webhook Pod. name: letsencrypt-prod spec: acme: - server: [https://acme-v02.api.letsencrypt.org/directory](https://acme-v02.api.letsencrypt.org/directory) + server: https://acme-v02.api.letsencrypt.org/directory email: example@example.com privateKeySecretRef: name: letsencrypt-prod @@ -169,7 +169,7 @@ make lint ### Go Conformance Testing: Runs the official cert-manager Go solver test suite in memory against the STACKIT API: ```bash -AUTH_KEY_PATH="" TEST_ZONE_NAME="example.com" make test-e2e-conformance +TEST_ZONE_NAME="example.com" make test-e2e-conformance ``` Follow the comprehensive guide available [here](e2e_test/README.md). diff --git a/e2e_test/README.md b/e2e_test/README.md index a251c864..9b632ca4 100644 --- a/e2e_test/README.md +++ b/e2e_test/README.md @@ -25,7 +25,7 @@ For appropriate test initialization within the STACKIT ecosystem, you must confi Generate `secret.yaml` from the provided example: ```bash - export STACKIT_SA_KEY_B64=$(cat /path/to/your/sa.json | base64 -w 0) + export STACKIT_SA_KEY=$(cat /path/to/your/sa.json | base64 -w 0) envsubst < ../testdata/stackit/secret.yaml.example > ../testdata/stackit/secret.yaml ``` diff --git a/internal/resolver/config.go b/internal/resolver/config.go index 61668254..21233934 100644 --- a/internal/resolver/config.go +++ b/internal/resolver/config.go @@ -94,10 +94,10 @@ func (d defaultConfigProvider) LoadConfig(cfgJSON *extapi.JSON) (StackitDnsProvi if cfg.ServiceAccountSecretNamespace == "" { cfg.ServiceAccountSecretNamespace = webhookNamespace } + } - if err := validateSecretNamespace(cfg.ServiceAccountSecretNamespace, webhookNamespace, scope); err != nil { - return cfg, err - } + if err := validateSecretNamespace(cfg.ServiceAccountSecretNamespace, webhookNamespace, scope); err != nil { + return cfg, err } if err := validateConfig(&cfg); err != nil { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 3b788743..4caa560e 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -141,7 +141,7 @@ func (s *presentSuite) TestConfigProviderError() { s.Error(err) } -func (s *presentSuite) TestFailGetAuthToken() { +func (s *presentSuite) TestFailFetchServiceAccountKeyFromSecret() { s.mockConfigProvider.EXPECT(). LoadConfig(gomock.Any()). Return(resolver.StackitDnsProviderConfig{ diff --git a/testdata/stackit/README.md b/testdata/stackit/README.md index 122f41de..37b70934 100644 --- a/testdata/stackit/README.md +++ b/testdata/stackit/README.md @@ -5,6 +5,6 @@ Copy the example Secret file, replacing `$STACKIT_SA_KEY` with your STACKIT Service Account JSON key: ```bash -$ export STACKIT_SA_KEY_B64=$(cat /path/to/sa.json | base64 -w 0) +$ export STACKIT_SA_KEY=$(cat /path/to/sa.json | base64 -w 0) $ envsubst < testdata/stackit/secret.yaml.example | kubectl apply -f - ``` \ No newline at end of file diff --git a/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml b/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml index f65d8baa..9031cc24 100644 --- a/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml +++ b/tests/e2e/test-suite/dynamic-sa-auth/01-assert.yaml @@ -2,25 +2,27 @@ apiVersion: kuttl.dev/v1beta1 kind: TestAssert timeout: 120 commands: - - script: | - ZONE="${ZONE_NAME}" - - AUTH_NS=$(dig +short NS "$ZONE" | head -n 1) - if [ -z "$AUTH_NS" ]; then + - script: | + set -e + + ZONE="${ZONE_NAME}" + + AUTH_NS=$(dig +short NS "$ZONE" | head -n 1) + if [ -z "$AUTH_NS" ]; then echo "ERROR: Could not determine authoritative nameserver for $ZONE" - return 1 - fi - - check_record() { + exit 1 + fi + + check_record() { local RECORD_NAME=$1 RESULT=$(dig "@$AUTH_NS" -t TXT +short "$RECORD_NAME") if [ -n "$RESULT" ]; then - echo "FAILED: $RECORD_NAME still exists. Result: $RESULT" - return 1 + echo "FAILED: $RECORD_NAME still exists. Result: $RESULT" + return 1 fi echo "SUCCESS: TXT record $RECORD_NAME successfully deleted." return 0 - } - - check_record "_acme-challenge.e2e-tenant.${ZONE_NAME}" && \ - check_record "_acme-challenge.e2e-tenant-two.${ZONE_NAME}" \ No newline at end of file + } + + check_record "_acme-challenge.e2e-tenant.${ZONE_NAME}" + check_record "_acme-challenge.e2e-tenant-two.${ZONE_NAME}" \ No newline at end of file From 9f56834fabc9955674e9f97b8cc745b2d25c398d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Tue, 25 Aug 2026 17:38:34 +0000 Subject: [PATCH 5/5] fix: lint and lower acme record default TTL --- README.md | 4 ++-- internal/resolver/config.go | 34 ++++++++++++++++++++------------ internal/resolver/config_test.go | 2 +- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7ea09ba8..0ebea52c 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ If your cluster supports Workload Identity Federation (e.g., SKE clusters), you The following table delineates the configuration options available under the `config` block of the STACKIT Cert Manager Webhook solver: | Key | Type | Default | Description | -|-----|------|---------|-------------| +|-----|------|--------|-------------| | `projectId` | string | `""` | **Required.** The unique identifier for the STACKIT project. | | `apiBasePath` | string | `"https://dns.api.stackit.cloud"` | The base path for the STACKIT DNS API. | | `serviceAccountSecretRef` | string | `""` | Name of the Kubernetes Secret containing the SA JSON. | @@ -147,7 +147,7 @@ The following table delineates the configuration options available under the `co | `serviceAccountKeyPath` | string | `""` | The absolute file path to a statically mounted SA JSON key inside the webhook container. | | `useWorkloadIdentityFederation` | bool | `false` | Explicitly enables STACKIT Workload Identity Federation authentication. | | `serviceAccountBaseUrl` | string | `""` | Custom URL for trading SA keys for access tokens. | -| `acmeTxtRecordTTL` | int32 | `600` | The TTL for the ACME TXT challenge record. | +| `acmeTxtRecordTTL` | int32 | `60` | The TTL for the ACME TXT challenge record. | ## Test Procedures diff --git a/internal/resolver/config.go b/internal/resolver/config.go index 21233934..1dc89a5d 100644 --- a/internal/resolver/config.go +++ b/internal/resolver/config.go @@ -84,27 +84,35 @@ func (d defaultConfigProvider) LoadConfig(cfgJSON *extapi.JSON) (StackitDnsProvi setDefaultValues(&cfg) - webhookNamespace, err := determineNamespace(d.fileNamespaceName) - if err != nil { + if err := d.resolveNamespace(&cfg); err != nil { return cfg, err } - scope := d.secretAccessScope - if scope == "" || scope == secretAccessScopeWebhook { - if cfg.ServiceAccountSecretNamespace == "" { - cfg.ServiceAccountSecretNamespace = webhookNamespace - } + if err := validateConfig(&cfg); err != nil { + return cfg, err } - if err := validateSecretNamespace(cfg.ServiceAccountSecretNamespace, webhookNamespace, scope); err != nil { - return cfg, err + return cfg, nil +} + +func (d defaultConfigProvider) resolveNamespace(cfg *StackitDnsProviderConfig) error { + webhookNamespace, err := determineNamespace(d.fileNamespaceName) + if err != nil { + return err } - if err := validateConfig(&cfg); err != nil { - return cfg, err + scope := d.secretAccessScope + if (scope == "" || scope == secretAccessScopeWebhook) && cfg.ServiceAccountSecretNamespace == "" { + cfg.ServiceAccountSecretNamespace = webhookNamespace } - return cfg, nil + if cfg.ServiceAccountSecretRef != "" { + if err := validateSecretNamespace(cfg.ServiceAccountSecretNamespace, webhookNamespace, scope); err != nil { + return err + } + } + + return nil } func validateSecretNamespace(configuredNamespace, webhookNamespace, accessScope string) error { @@ -180,7 +188,7 @@ func setDefaultValues(cfg *StackitDnsProviderConfig) { } if cfg.AcmeTxtRecordTTL == 0 { - cfg.AcmeTxtRecordTTL = 600 + cfg.AcmeTxtRecordTTL = 60 } } diff --git a/internal/resolver/config_test.go b/internal/resolver/config_test.go index 25370a72..5d26d7b3 100644 --- a/internal/resolver/config_test.go +++ b/internal/resolver/config_test.go @@ -85,7 +85,7 @@ func TestLoadConfig(t *testing.T) { require.NoError(t, err) require.Equal(t, "test", cfg.ProjectId) require.Equal(t, "https://dns.api.stackit.cloud", cfg.ApiBasePath) - require.Equal(t, int32(600), cfg.AcmeTxtRecordTTL) + require.Equal(t, int32(60), cfg.AcmeTxtRecordTTL) }) }