diff --git a/internal/connector/issuer/awsacmpca/awsacmpca.go b/internal/connector/issuer/awsacmpca/awsacmpca.go index 5821476..f47d08f 100644 --- a/internal/connector/issuer/awsacmpca/awsacmpca.go +++ b/internal/connector/issuer/awsacmpca/awsacmpca.go @@ -176,7 +176,12 @@ type Connector struct { // // Callers wanting to inject a mock client (tests, fake CAs) should use // NewWithClient instead, which bypasses the SDK loading path entirely. -func New(config *Config, logger *slog.Logger) (*Connector, error) { +// +// ctx is used only for the SDK config load (LoadDefaultConfig may probe IMDS +// or remote credential sources). Callers that don't have a useful deadline +// should pass context.Background(); the SDK has its own internal timeouts +// for credential resolution. +func New(ctx context.Context, config *Config, logger *slog.Logger) (*Connector, error) { if config != nil { if config.SigningAlgorithm == "" { config.SigningAlgorithm = "SHA256WITHRSA" @@ -192,7 +197,7 @@ func New(config *Config, logger *slog.Logger) (*Connector, error) { } if config != nil && config.Region != "" { - client, err := buildSDKClient(context.Background(), config.Region) + client, err := buildSDKClient(ctx, config.Region) if err != nil { return nil, fmt.Errorf("AWS ACM PCA SDK init: %w", err) } diff --git a/internal/connector/issuer/awsacmpca/awsacmpca_test.go b/internal/connector/issuer/awsacmpca/awsacmpca_test.go index 58fc531..913dcfb 100644 --- a/internal/connector/issuer/awsacmpca/awsacmpca_test.go +++ b/internal/connector/issuer/awsacmpca/awsacmpca_test.go @@ -72,11 +72,14 @@ func (m *mockACMPCAClient) GetCACertificate(ctx context.Context, input *awsacmpc // mustNew is a test helper that calls awsacmpca.New and fails the test if // New returns an error. Use this for the ValidateConfig-only test sites -// where config is nil; New(nil, ...) skips SDK loading and never errors, -// so this helper is just to keep the call sites terse. -func mustNew(t *testing.T, config *awsacmpca.Config, logger *slog.Logger) *awsacmpca.Connector { +// where config is nil; New(ctx, nil, ...) skips SDK loading and never +// errors, so this helper is just to keep the call sites terse. The ctx +// parameter exists for contextcheck-lint cleanliness — when callers have +// ctx in scope, they should pass it through to New, which threads it +// into the SDK config load. +func mustNew(t *testing.T, ctx context.Context, config *awsacmpca.Config, logger *slog.Logger) *awsacmpca.Connector { t.Helper() - c, err := awsacmpca.New(config, logger) + c, err := awsacmpca.New(ctx, config, logger) if err != nil { t.Fatalf("awsacmpca.New: %v", err) } @@ -155,7 +158,7 @@ func TestAWSACMPCAConnector(t *testing.T) { ValidityDays: 365, } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -172,7 +175,7 @@ func TestAWSACMPCAConnector(t *testing.T) { TemplateArn: "arn:aws:acm-pca:eu-west-1:123456789012:template/WebServer", } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -181,7 +184,7 @@ func TestAWSACMPCAConnector(t *testing.T) { }) t.Run("ValidateConfig_InvalidJSON", func(t *testing.T) { - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) err := connector.ValidateConfig(ctx, []byte(`{invalid json}`)) if err == nil { t.Fatal("Expected error for invalid JSON") @@ -196,7 +199,7 @@ func TestAWSACMPCAConnector(t *testing.T) { CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -212,7 +215,7 @@ func TestAWSACMPCAConnector(t *testing.T) { Region: "us-east-1", } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -229,7 +232,7 @@ func TestAWSACMPCAConnector(t *testing.T) { CAArn: "not-an-arn", } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -247,7 +250,7 @@ func TestAWSACMPCAConnector(t *testing.T) { SigningAlgorithm: "INVALID_ALGO", } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -265,7 +268,7 @@ func TestAWSACMPCAConnector(t *testing.T) { ValidityDays: -1, } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -595,7 +598,7 @@ func TestAWSACMPCAConnector(t *testing.T) { // SigningAlgorithm and ValidityDays not set } - connector := mustNew(t, nil, logger) + connector := mustNew(t, ctx, nil, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -668,7 +671,7 @@ func TestNew_ProductionPath(t *testing.T) { Region: "us-east-1", CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", } - c, err := awsacmpca.New(cfg, logger) + c, err := awsacmpca.New(ctx, cfg, logger) if err != nil { t.Fatalf("New with valid config returned error: %v", err) } @@ -702,7 +705,7 @@ func TestNew_ProductionPath(t *testing.T) { // the connector is constructed with no client and ValidateConfig // must be called before any operation. This documents the lazy // initialization contract. - c, err := awsacmpca.New(nil, logger) + c, err := awsacmpca.New(ctx, nil, logger) if err != nil { t.Fatalf("New(nil, logger) returned error: %v", err) } @@ -728,7 +731,7 @@ func TestNew_ProductionPath(t *testing.T) { // New(nil, ...) leaves client nil; ValidateConfig with a valid // config should build it. After ValidateConfig succeeds, client- // using methods should work end-to-end (modulo network errors). - c, err := awsacmpca.New(nil, logger) + c, err := awsacmpca.New(ctx, nil, logger) if err != nil { t.Fatalf("New(nil, logger): %v", err) } @@ -760,7 +763,7 @@ func TestNew_ProductionPath(t *testing.T) { t.Run("RevokeBeforeInitFailsFast", func(t *testing.T) { // The audit also flagged RevokeCertificate as part of the stub // blocker. Verify the nil-client guard fires for revoke too. - c, err := awsacmpca.New(nil, logger) + c, err := awsacmpca.New(ctx, nil, logger) if err != nil { t.Fatalf("New(nil, logger): %v", err) } @@ -776,7 +779,7 @@ func TestNew_ProductionPath(t *testing.T) { }) t.Run("GetCAPEMBeforeInitFailsFast", func(t *testing.T) { - c, err := awsacmpca.New(nil, logger) + c, err := awsacmpca.New(ctx, nil, logger) if err != nil { t.Fatalf("New(nil, logger): %v", err) } diff --git a/internal/connector/issuerfactory/factory.go b/internal/connector/issuerfactory/factory.go index 3f959f0..f16e64b 100644 --- a/internal/connector/issuerfactory/factory.go +++ b/internal/connector/issuerfactory/factory.go @@ -1,6 +1,7 @@ package issuerfactory import ( + "context" "encoding/json" "fmt" "log/slog" @@ -23,7 +24,13 @@ import ( // NewFromConfig instantiates an issuer connector from its type string and config JSON. // The config JSON keys use snake_case matching the connector Config struct json tags. // This replaces the manual wiring in cmd/server/main.go. -func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.Logger) (issuer.Connector, error) { +// +// ctx is currently used only by the AWSACMPCA branch (passed to +// awsconfig.LoadDefaultConfig for SDK credential chain resolution). Other +// connectors take no context at construction; the parameter is kept on the +// signature so callers that have a ctx in scope thread it through cleanly +// (contextcheck linter). +func NewFromConfig(ctx context.Context, issuerType string, configJSON json.RawMessage, logger *slog.Logger) (issuer.Connector, error) { if len(configJSON) == 0 { configJSON = []byte("{}") } @@ -90,7 +97,7 @@ func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.L if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid AWS ACM PCA config: %w", err) } - conn, err := awsacmpca.New(&cfg, logger) + conn, err := awsacmpca.New(ctx, &cfg, logger) if err != nil { return nil, fmt.Errorf("AWS ACM PCA init: %w", err) } diff --git a/internal/connector/issuerfactory/factory_test.go b/internal/connector/issuerfactory/factory_test.go index 30e5a42..01be5d1 100644 --- a/internal/connector/issuerfactory/factory_test.go +++ b/internal/connector/issuerfactory/factory_test.go @@ -1,6 +1,7 @@ package issuerfactory import ( + "context" "encoding/json" "log/slog" "os" @@ -11,9 +12,14 @@ func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) } +// testCtx is a fresh background context per test. The factory takes ctx +// for the AWSACMPCA SDK config load; other connectors ignore it. Tests +// use a dedicated helper so contextcheck doesn't cascade. +func testCtx() context.Context { return context.Background() } + func TestNewFromConfig_LocalCA(t *testing.T) { cfg := json.RawMessage(`{"ca_common_name":"Test CA"}`) - conn, err := NewFromConfig("local", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "local", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(local) failed: %v", err) } @@ -24,7 +30,7 @@ func TestNewFromConfig_LocalCA(t *testing.T) { func TestNewFromConfig_GenericCA_Alias(t *testing.T) { cfg := json.RawMessage(`{}`) - conn, err := NewFromConfig("GenericCA", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "GenericCA", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(GenericCA) failed: %v", err) } @@ -35,7 +41,7 @@ func TestNewFromConfig_GenericCA_Alias(t *testing.T) { func TestNewFromConfig_ACME(t *testing.T) { cfg := json.RawMessage(`{"directory_url":"https://acme-staging-v02.api.letsencrypt.org/directory","email":"test@example.com"}`) - conn, err := NewFromConfig("ACME", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "ACME", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(ACME) failed: %v", err) } @@ -46,7 +52,7 @@ func TestNewFromConfig_ACME(t *testing.T) { func TestNewFromConfig_StepCA(t *testing.T) { cfg := json.RawMessage(`{"ca_url":"https://ca.internal:9000","provisioner_name":"test"}`) - conn, err := NewFromConfig("StepCA", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "StepCA", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(StepCA) failed: %v", err) } @@ -57,7 +63,7 @@ func TestNewFromConfig_StepCA(t *testing.T) { func TestNewFromConfig_OpenSSL(t *testing.T) { cfg := json.RawMessage(`{"sign_script":"/path/to/sign.sh"}`) - conn, err := NewFromConfig("OpenSSL", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "OpenSSL", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(OpenSSL) failed: %v", err) } @@ -68,7 +74,7 @@ func TestNewFromConfig_OpenSSL(t *testing.T) { func TestNewFromConfig_VaultPKI(t *testing.T) { cfg := json.RawMessage(`{"addr":"https://vault:8200","token":"hvs.test","mount":"pki","role":"web","ttl":"8760h"}`) - conn, err := NewFromConfig("VaultPKI", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "VaultPKI", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(VaultPKI) failed: %v", err) } @@ -79,7 +85,7 @@ func TestNewFromConfig_VaultPKI(t *testing.T) { func TestNewFromConfig_DigiCert(t *testing.T) { cfg := json.RawMessage(`{"api_key":"test-key","org_id":"123","product_type":"ssl_basic"}`) - conn, err := NewFromConfig("DigiCert", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "DigiCert", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(DigiCert) failed: %v", err) } @@ -90,7 +96,7 @@ func TestNewFromConfig_DigiCert(t *testing.T) { func TestNewFromConfig_Sectigo(t *testing.T) { cfg := json.RawMessage(`{"customer_uri":"test-org","login":"api-user","password":"secret","org_id":1}`) - conn, err := NewFromConfig("Sectigo", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "Sectigo", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(Sectigo) failed: %v", err) } @@ -101,7 +107,7 @@ func TestNewFromConfig_Sectigo(t *testing.T) { func TestNewFromConfig_GoogleCAS(t *testing.T) { cfg := json.RawMessage(`{"project":"my-project","location":"us-central1","ca_pool":"my-pool","credentials":"/path/to/creds.json"}`) - conn, err := NewFromConfig("GoogleCAS", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "GoogleCAS", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(GoogleCAS) failed: %v", err) } @@ -112,7 +118,7 @@ func TestNewFromConfig_GoogleCAS(t *testing.T) { func TestNewFromConfig_UnknownType(t *testing.T) { cfg := json.RawMessage(`{}`) - _, err := NewFromConfig("UnknownCA", cfg, testLogger()) + _, err := NewFromConfig(testCtx(), "UnknownCA", cfg, testLogger()) if err == nil { t.Fatal("expected error for unknown type") } @@ -120,7 +126,7 @@ func TestNewFromConfig_UnknownType(t *testing.T) { func TestNewFromConfig_MalformedJSON(t *testing.T) { cfg := json.RawMessage(`{invalid json}`) - _, err := NewFromConfig("ACME", cfg, testLogger()) + _, err := NewFromConfig(testCtx(), "ACME", cfg, testLogger()) if err == nil { t.Fatal("expected error for malformed JSON") } @@ -128,7 +134,7 @@ func TestNewFromConfig_MalformedJSON(t *testing.T) { func TestNewFromConfig_EmptyConfig(t *testing.T) { // Empty config should work — connectors have defaults - conn, err := NewFromConfig("local", nil, testLogger()) + conn, err := NewFromConfig(testCtx(), "local", nil, testLogger()) if err != nil { t.Fatalf("NewFromConfig with nil config failed: %v", err) } @@ -139,7 +145,7 @@ func TestNewFromConfig_EmptyConfig(t *testing.T) { func TestNewFromConfig_AWSACMPCA(t *testing.T) { cfg := json.RawMessage(`{"project":"my-project","location":"us-central1","ca_pool":"my-pool","credentials":"/path/to/creds.json"}`) - conn, err := NewFromConfig("AWSACMPCA", cfg, testLogger()) + conn, err := NewFromConfig(testCtx(), "AWSACMPCA", cfg, testLogger()) if err != nil { t.Fatalf("NewFromConfig(AWSACMPCA) failed: %v", err) } diff --git a/internal/service/issuer.go b/internal/service/issuer.go index 7fc4885..5cf768a 100644 --- a/internal/service/issuer.go +++ b/internal/service/issuer.go @@ -276,7 +276,7 @@ func (s *IssuerService) TestConnection(ctx context.Context, id string) error { } // Instantiate a throwaway connector and validate - connector, err := issuerfactory.NewFromConfig(string(iss.Type), configJSON, s.logger) + connector, err := issuerfactory.NewFromConfig(ctx, string(iss.Type), configJSON, s.logger) if err != nil { s.updateTestStatus(ctx, iss, "failed") return fmt.Errorf("failed to create connector: %w", err) @@ -300,7 +300,7 @@ func (s *IssuerService) BuildRegistry(ctx context.Context) error { return fmt.Errorf("failed to load issuers from database: %w", err) } - if err := s.registry.Rebuild(issuers, s.encryptionKey); err != nil { + if err := s.registry.Rebuild(ctx, issuers, s.encryptionKey); err != nil { // Log the error but don't fail — some issuers loaded successfully. s.logger.Warn("issuer registry rebuilt with errors", "error", err) } diff --git a/internal/service/issuer_registry.go b/internal/service/issuer_registry.go index f7f5e8c..edd9aa4 100644 --- a/internal/service/issuer_registry.go +++ b/internal/service/issuer_registry.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "fmt" "log/slog" @@ -115,7 +116,7 @@ func (r *IssuerRegistry) Len() int { // for v2 blobs is performed inside [crypto.DecryptIfKeySet]. Empty passphrase // fails closed via [crypto.ErrEncryptionKeyRequired] when encrypted configs // are encountered. See M-8 in certctl-audit-report.md. -func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string) error { +func (r *IssuerRegistry) Rebuild(ctx context.Context, configs []*domain.Issuer, encryptionKey string) error { newIssuers := make(map[string]IssuerConnector) var errors []string @@ -141,7 +142,7 @@ func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string) configJSON = json.RawMessage("{}") } - connector, err := issuerfactory.NewFromConfig(string(cfg.Type), configJSON, r.logger) + connector, err := issuerfactory.NewFromConfig(ctx, string(cfg.Type), configJSON, r.logger) if err != nil { errors = append(errors, fmt.Sprintf("issuer %s: factory error: %v", cfg.ID, err)) continue diff --git a/internal/service/issuer_registry_test.go b/internal/service/issuer_registry_test.go index 0c15ec1..274b2e5 100644 --- a/internal/service/issuer_registry_test.go +++ b/internal/service/issuer_registry_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "log/slog" "os" @@ -101,7 +102,7 @@ func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) { }, } - err := reg.Rebuild(configs, "") + err := reg.Rebuild(context.Background(), configs, "") if err != nil { t.Fatalf("Rebuild failed: %v", err) } @@ -142,7 +143,7 @@ func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) { }, } - err = reg.Rebuild(configs, "test-key") + err = reg.Rebuild(context.Background(), configs, "test-key") if err != nil { t.Fatalf("Rebuild with encryption failed: %v", err) } @@ -168,7 +169,7 @@ func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) { // Empty passphrase is safe when no EncryptedConfig is present — falls back to config column. // The C-2 fail-closed sentinel only fires when EncryptedConfig is non-empty. - err := reg.Rebuild(configs, "") + err := reg.Rebuild(context.Background(), configs, "") if err != nil { t.Fatalf("Rebuild with empty key failed: %v", err) } @@ -200,7 +201,7 @@ func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) { } // Should return an error indicating partial failure, but still load valid issuers - err := reg.Rebuild(configs, "") + err := reg.Rebuild(context.Background(), configs, "") if err == nil { t.Fatal("Rebuild should return error when some issuers fail to load") } @@ -232,7 +233,7 @@ func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) { }, } - err := reg.Rebuild(configs, "") + err := reg.Rebuild(context.Background(), configs, "") if err != nil { t.Fatalf("Rebuild failed: %v", err) } @@ -277,7 +278,7 @@ func TestIssuerRegistry_Rebuild_Empty(t *testing.T) { reg.Set("iss-existing", &mockIssuerConnector{}) - err := reg.Rebuild([]*domain.Issuer{}, "") + err := reg.Rebuild(context.Background(), []*domain.Issuer{}, "") if err != nil { t.Fatalf("Rebuild with empty configs failed: %v", err) }