From 6c3bc88d3d74697e72241221b084ca1b230b07bb Mon Sep 17 00:00:00 2001 From: Shankar Date: Sat, 4 Apr 2026 00:20:13 -0400 Subject: [PATCH] feat(M34): dynamic issuer configuration with encrypted config storage Replace static env-var-based issuer wiring with GUI-driven dynamic configuration stored encrypted in PostgreSQL. Operators can now configure, test, enable/disable, and manage issuers from the dashboard without restarting the server. Key changes: - AES-256-GCM encryption for sensitive issuer config at rest (PBKDF2 key derivation with 100k iterations) - Dynamic IssuerRegistry with sync.RWMutex replacing static map - Connector factory pattern (issuerfactory.NewFromConfig) replacing 140 lines of static wiring in main.go - Migration 000009: encrypted_config, last_tested_at, test_status, source columns on issuers table - Env var seeding on first boot with ON CONFLICT DO NOTHING - Registry Rebuild() for atomic map swap after CRUD operations - Issuer type validation against domain constants on Create - Audit trail for test connection results - Conditional seeding for step-ca/OpenSSL (only when env vars set) - GUI: source badge, connection test status on issuer detail page Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 182 +---- internal/config/config.go | 11 + internal/connector/issuer/factory.go | 4 + internal/connector/issuer/factory_test.go | 3 + internal/connector/issuerfactory/factory.go | 87 +++ .../connector/issuerfactory/factory_test.go | 138 ++++ internal/crypto/encryption.go | 103 +++ internal/crypto/encryption_test.go | 188 ++++++ internal/domain/connector.go | 18 +- internal/integration/lifecycle_test.go | 18 +- internal/integration/negative_test.go | 10 +- internal/repository/interfaces.go | 3 + internal/repository/postgres/issuer.go | 80 ++- internal/service/agent.go | 6 +- internal/service/agent_test.go | 29 +- internal/service/ca_operations.go | 8 +- internal/service/ca_operations_test.go | 7 +- internal/service/concurrent_test.go | 4 +- internal/service/context_test.go | 10 +- internal/service/csr_renewal_test.go | 6 +- internal/service/issuer.go | 627 ++++++++++++++++-- internal/service/issuer_registry.go | 139 ++++ internal/service/issuer_registry_test.go | 286 ++++++++ internal/service/issuer_test.go | 59 +- internal/service/job_test.go | 3 +- internal/service/renewal.go | 12 +- internal/service/renewal_test.go | 78 +-- internal/service/revocation_svc.go | 6 +- internal/service/revocation_svc_test.go | 7 +- internal/service/revocation_test.go | 17 +- internal/service/shortlived_test.go | 16 +- internal/service/testutil_test.go | 11 + migrations/000009_issuer_config.down.sql | 5 + migrations/000009_issuer_config.up.sql | 16 + web/src/api/types.ts | 6 + web/src/pages/IssuerDetailPage.tsx | 17 + 36 files changed, 1859 insertions(+), 361 deletions(-) create mode 100644 internal/connector/issuer/factory.go create mode 100644 internal/connector/issuer/factory_test.go create mode 100644 internal/connector/issuerfactory/factory.go create mode 100644 internal/connector/issuerfactory/factory_test.go create mode 100644 internal/crypto/encryption.go create mode 100644 internal/crypto/encryption_test.go create mode 100644 internal/service/issuer_registry.go create mode 100644 internal/service/issuer_registry_test.go create mode 100644 migrations/000009_issuer_config.down.sql create mode 100644 migrations/000009_issuer_config.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 1c76c51..521f3b0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,15 +16,8 @@ import ( "github.com/shankar0123/certctl/internal/api/middleware" "github.com/shankar0123/certctl/internal/api/router" "github.com/shankar0123/certctl/internal/config" + "github.com/shankar0123/certctl/internal/crypto" "github.com/shankar0123/certctl/internal/domain" - acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme" - "github.com/shankar0123/certctl/internal/connector/issuer/local" - digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert" - opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl" - stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca" - googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas" - sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" - vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault" notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email" notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie" notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" @@ -85,143 +78,18 @@ func main() { ownerRepo := postgres.NewOwnerRepository(db) logger.Info("initialized all repositories") - // Initialize Local CA issuer connector. - // In sub-CA mode (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH set), loads a pre-signed - // CA cert+key from disk. All issued certs chain to the upstream root (e.g., ADCS). - // Otherwise, generates an ephemeral self-signed CA for development/demo. - localCAConfig := &local.Config{} - if cfg.CA.CertPath != "" && cfg.CA.KeyPath != "" { - localCAConfig.CACertPath = cfg.CA.CertPath - localCAConfig.CAKeyPath = cfg.CA.KeyPath - logger.Info("Local CA configured in sub-CA mode", - "cert_path", cfg.CA.CertPath, - "key_path", cfg.CA.KeyPath) + // Initialize dynamic issuer registry. + // Issuers are loaded from the database (with AES-GCM encrypted config). + // On first boot with an empty database, env var issuers are seeded automatically. + var encryptionKey []byte + if cfg.Encryption.ConfigEncryptionKey != "" { + encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey) + logger.Info("config encryption enabled (AES-256-GCM)") } else { - logger.Info("Local CA configured in self-signed mode (ephemeral)") - } - localCA := local.New(localCAConfig, logger) - logger.Info("initialized Local CA issuer connector") - - // Initialize ACME issuer connector (for Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, etc.) - // Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types. - // EAB (External Account Binding) required by ZeroSSL, Google Trust Services, SSL.com. - acmeConnector := acmeissuer.New(&acmeissuer.Config{ - DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"), - Email: os.Getenv("CERTCTL_ACME_EMAIL"), - EABKid: os.Getenv("CERTCTL_ACME_EAB_KID"), - EABHmac: os.Getenv("CERTCTL_ACME_EAB_HMAC"), - ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"), - DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"), - DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"), - DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"), - Insecure: cfg.ACME.Insecure, - }, logger) - logger.Info("initialized ACME issuer connector") - - // Initialize step-ca issuer connector (for Smallstep private CA). - // Uses the native /sign API with JWK provisioner authentication. - stepcaConnector := stepcaissuer.New(&stepcaissuer.Config{ - CAURL: os.Getenv("CERTCTL_STEPCA_URL"), - RootCertPath: os.Getenv("CERTCTL_STEPCA_ROOT_CERT"), - ProvisionerName: os.Getenv("CERTCTL_STEPCA_PROVISIONER"), - ProvisionerKeyPath: os.Getenv("CERTCTL_STEPCA_KEY_PATH"), - ProvisionerPassword: os.Getenv("CERTCTL_STEPCA_PASSWORD"), - }, logger) - logger.Info("initialized step-ca issuer connector") - - // Initialize OpenSSL/Custom CA issuer connector (for script-based CA integrations). - // Delegates certificate signing to user-provided scripts. - opensslConnector := opensslissuer.New(&opensslissuer.Config{ - SignScript: os.Getenv("CERTCTL_OPENSSL_SIGN_SCRIPT"), - RevokeScript: os.Getenv("CERTCTL_OPENSSL_REVOKE_SCRIPT"), - CRLScript: os.Getenv("CERTCTL_OPENSSL_CRL_SCRIPT"), - TimeoutSeconds: getEnvIntDefault(os.Getenv("CERTCTL_OPENSSL_TIMEOUT_SECONDS"), 30), - }, logger) - logger.Info("initialized OpenSSL/Custom CA issuer connector") - - // Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI). - // Uses the Vault HTTP API with token authentication. - vaultConnector := vaultissuer.New(&vaultissuer.Config{ - Addr: os.Getenv("CERTCTL_VAULT_ADDR"), - Token: os.Getenv("CERTCTL_VAULT_TOKEN"), - Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"), - Role: os.Getenv("CERTCTL_VAULT_ROLE"), - TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"), - }, logger) - logger.Info("initialized Vault PKI issuer connector") - - // Initialize DigiCert CertCentral issuer connector (for enterprise public CA). - // Uses the DigiCert REST API with async order model. - digicertConnector := digicertissuer.New(&digicertissuer.Config{ - APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"), - OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"), - ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"), - BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"), - }, logger) - logger.Info("initialized DigiCert CertCentral issuer connector") - - // Initialize Sectigo SCM issuer connector (for enterprise public CA). - // Uses the Sectigo SCM REST API with async order model. - sectigoConnector := sectigoissuer.New(§igoissuer.Config{ - CustomerURI: cfg.Sectigo.CustomerURI, - Login: cfg.Sectigo.Login, - Password: cfg.Sectigo.Password, - OrgID: cfg.Sectigo.OrgID, - CertType: cfg.Sectigo.CertType, - Term: cfg.Sectigo.Term, - BaseURL: cfg.Sectigo.BaseURL, - }, logger) - logger.Info("initialized Sectigo SCM issuer connector") - - // Initialize Google CAS issuer connector (for GCP private CA). - // Uses the Google CAS REST API with OAuth2 service account auth. - googlecasConnector := googlecasissuer.New(&googlecasissuer.Config{ - Project: cfg.GoogleCAS.Project, - Location: cfg.GoogleCAS.Location, - CAPool: cfg.GoogleCAS.CAPool, - Credentials: cfg.GoogleCAS.Credentials, - TTL: cfg.GoogleCAS.TTL, - }, logger) - logger.Info("initialized Google CAS issuer connector") - - // Build issuer registry: maps issuer IDs (from database) to connector implementations. - // "iss-local" matches the seed data issuer ID for the Local CA. - // "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers. - // "iss-stepca" is the step-ca private CA connector. - // "iss-openssl" is the custom CA/OpenSSL connector. - issuerRegistry := map[string]service.IssuerConnector{ - "iss-local": service.NewIssuerConnectorAdapter(localCA), - "iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector), - "iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector), - "iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector), - "iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector), + logger.Warn("CERTCTL_CONFIG_ENCRYPTION_KEY not set — issuer configs stored in plaintext (not recommended for production)") } - // Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set) - if os.Getenv("CERTCTL_VAULT_ADDR") != "" { - issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector) - logger.Info("Vault PKI issuer registered", "id", "iss-vault") - } - - // Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set) - if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" { - issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector) - logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert") - } - - // Conditionally register Sectigo SCM (only if all 3 auth credentials are set) - if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" { - issuerRegistry["iss-sectigo"] = service.NewIssuerConnectorAdapter(sectigoConnector) - logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo") - } - - // Conditionally register Google CAS (only if project and credentials are set) - if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" { - issuerRegistry["iss-googlecas"] = service.NewIssuerConnectorAdapter(googlecasConnector) - logger.Info("Google CAS issuer registered", "id", "iss-googlecas") - } - - logger.Info("issuer registry configured", "issuers", len(issuerRegistry)) + issuerRegistry := service.NewIssuerRegistry(logger) // Initialize revocation repository revocationRepo := postgres.NewRevocationRepository(db) @@ -309,7 +177,14 @@ func main() { jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService.SetProfileRepo(profileRepo) - issuerService := service.NewIssuerService(issuerRepo, auditService) + issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, encryptionKey, logger) + + // Seed issuers from env vars on first boot (empty database only), then build registry + issuerService.SeedFromEnvVars(context.Background(), cfg) + if err := issuerService.BuildRegistry(context.Background()); err != nil { + logger.Error("failed to build issuer registry from database", "error", err) + } + logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len()) targetService := service.NewTargetService(targetRepo, auditService) profileService := service.NewProfileService(profileRepo, auditService) teamService := service.NewTeamService(teamRepo, auditService) @@ -447,7 +322,7 @@ func main() { }) // Register EST (RFC 7030) handlers if enabled if cfg.EST.Enabled { - issuerConn, ok := issuerRegistry[cfg.EST.IssuerID] + issuerConn, ok := issuerRegistry.Get(cfg.EST.IssuerID) if !ok { logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID) os.Exit(1) @@ -645,22 +520,3 @@ func main() { logger.Info("certctl server stopped") } -// getEnvDefault reads an environment variable with a default fallback. -func getEnvDefault(key, defaultVal string) string { - if val := os.Getenv(key); val != "" { - return val - } - return defaultVal -} - -// getEnvIntDefault parses an integer from a string with a default fallback. -func getEnvIntDefault(s string, defaultVal int) int { - if s == "" { - return defaultVal - } - val, err := strconv.Atoi(s) - if err != nil { - return defaultVal - } - return val -} diff --git a/internal/config/config.go b/internal/config/config.go index 2a6406f..b46c396 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,14 @@ type Config struct { Sectigo SectigoConfig GoogleCAS GoogleCASConfig Digest DigestConfig + Encryption EncryptionConfig +} + +// EncryptionConfig contains configuration for encrypting sensitive data at rest. +type EncryptionConfig struct { + // ConfigEncryptionKey is the passphrase used to derive AES-256-GCM keys for encrypting + // issuer config secrets in the database. If empty, configs are stored in plaintext (development only). + ConfigEncryptionKey string } // NotifierConfig contains configuration for notification connectors. @@ -598,6 +606,9 @@ func Load() (*Config, error) { Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour), Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil), }, + Encryption: EncryptionConfig{ + ConfigEncryptionKey: getEnv("CERTCTL_CONFIG_ENCRYPTION_KEY", ""), + }, } if err := cfg.Validate(); err != nil { diff --git a/internal/connector/issuer/factory.go b/internal/connector/issuer/factory.go new file mode 100644 index 0000000..a14d401 --- /dev/null +++ b/internal/connector/issuer/factory.go @@ -0,0 +1,4 @@ +package issuer + +// Factory has been moved to internal/connector/issuerfactory to avoid import cycles. +// See issuerfactory.NewFromConfig(). diff --git a/internal/connector/issuer/factory_test.go b/internal/connector/issuer/factory_test.go new file mode 100644 index 0000000..646a0ba --- /dev/null +++ b/internal/connector/issuer/factory_test.go @@ -0,0 +1,3 @@ +package issuer + +// Factory tests have been moved to internal/connector/issuerfactory. diff --git a/internal/connector/issuerfactory/factory.go b/internal/connector/issuerfactory/factory.go new file mode 100644 index 0000000..9dd8f66 --- /dev/null +++ b/internal/connector/issuerfactory/factory.go @@ -0,0 +1,87 @@ +package issuerfactory + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/shankar0123/certctl/internal/connector/issuer" + "github.com/shankar0123/certctl/internal/connector/issuer/acme" + "github.com/shankar0123/certctl/internal/connector/issuer/digicert" + "github.com/shankar0123/certctl/internal/connector/issuer/googlecas" + "github.com/shankar0123/certctl/internal/connector/issuer/local" + "github.com/shankar0123/certctl/internal/connector/issuer/openssl" + "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" + "github.com/shankar0123/certctl/internal/connector/issuer/stepca" + "github.com/shankar0123/certctl/internal/connector/issuer/vault" +) + +// 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) { + if len(configJSON) == 0 { + configJSON = []byte("{}") + } + + switch issuerType { + case "local", "GenericCA": + var cfg local.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid Local CA config: %w", err) + } + return local.New(&cfg, logger), nil + + case "ACME": + var cfg acme.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid ACME config: %w", err) + } + return acme.New(&cfg, logger), nil + + case "StepCA": + var cfg stepca.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid step-ca config: %w", err) + } + return stepca.New(&cfg, logger), nil + + case "OpenSSL": + var cfg openssl.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid OpenSSL config: %w", err) + } + return openssl.New(&cfg, logger), nil + + case "VaultPKI": + var cfg vault.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid Vault PKI config: %w", err) + } + return vault.New(&cfg, logger), nil + + case "DigiCert": + var cfg digicert.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid DigiCert config: %w", err) + } + return digicert.New(&cfg, logger), nil + + case "Sectigo": + var cfg sectigo.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid Sectigo config: %w", err) + } + return sectigo.New(&cfg, logger), nil + + case "GoogleCAS": + var cfg googlecas.Config + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid Google CAS config: %w", err) + } + return googlecas.New(&cfg, logger), nil + + default: + return nil, fmt.Errorf("unknown issuer type: %q", issuerType) + } +} diff --git a/internal/connector/issuerfactory/factory_test.go b/internal/connector/issuerfactory/factory_test.go new file mode 100644 index 0000000..cf5d59c --- /dev/null +++ b/internal/connector/issuerfactory/factory_test.go @@ -0,0 +1,138 @@ +package issuerfactory + +import ( + "encoding/json" + "log/slog" + "os" + "testing" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +func TestNewFromConfig_LocalCA(t *testing.T) { + cfg := json.RawMessage(`{"ca_common_name":"Test CA"}`) + conn, err := NewFromConfig("local", cfg, testLogger()) + if err != nil { + t.Fatalf("NewFromConfig(local) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +func TestNewFromConfig_GenericCA_Alias(t *testing.T) { + cfg := json.RawMessage(`{}`) + conn, err := NewFromConfig("GenericCA", cfg, testLogger()) + if err != nil { + t.Fatalf("NewFromConfig(GenericCA) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +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()) + if err != nil { + t.Fatalf("NewFromConfig(ACME) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +func TestNewFromConfig_StepCA(t *testing.T) { + cfg := json.RawMessage(`{"ca_url":"https://ca.internal:9000","provisioner_name":"test"}`) + conn, err := NewFromConfig("StepCA", cfg, testLogger()) + if err != nil { + t.Fatalf("NewFromConfig(StepCA) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +func TestNewFromConfig_OpenSSL(t *testing.T) { + cfg := json.RawMessage(`{"sign_script":"/path/to/sign.sh"}`) + conn, err := NewFromConfig("OpenSSL", cfg, testLogger()) + if err != nil { + t.Fatalf("NewFromConfig(OpenSSL) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +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()) + if err != nil { + t.Fatalf("NewFromConfig(VaultPKI) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +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()) + if err != nil { + t.Fatalf("NewFromConfig(DigiCert) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +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()) + if err != nil { + t.Fatalf("NewFromConfig(Sectigo) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +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()) + if err != nil { + t.Fatalf("NewFromConfig(GoogleCAS) failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +func TestNewFromConfig_UnknownType(t *testing.T) { + cfg := json.RawMessage(`{}`) + _, err := NewFromConfig("UnknownCA", cfg, testLogger()) + if err == nil { + t.Fatal("expected error for unknown type") + } +} + +func TestNewFromConfig_MalformedJSON(t *testing.T) { + cfg := json.RawMessage(`{invalid json}`) + _, err := NewFromConfig("ACME", cfg, testLogger()) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestNewFromConfig_EmptyConfig(t *testing.T) { + // Empty config should work — connectors have defaults + conn, err := NewFromConfig("local", nil, testLogger()) + if err != nil { + t.Fatalf("NewFromConfig with nil config failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} diff --git a/internal/crypto/encryption.go b/internal/crypto/encryption.go new file mode 100644 index 0000000..68eebc9 --- /dev/null +++ b/internal/crypto/encryption.go @@ -0,0 +1,103 @@ +// Package crypto provides AES-256-GCM encryption for sensitive configuration data. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/crypto/pbkdf2" +) + +// Encrypt encrypts plaintext using AES-256-GCM with a random 12-byte nonce prepended to the output. +// The key must be exactly 32 bytes (AES-256). Returns [12-byte nonce][ciphertext+tag]. +func Encrypt(plaintext []byte, key []byte) ([]byte, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// Decrypt decrypts ciphertext that was encrypted with Encrypt. +// Expects format: [12-byte nonce][ciphertext+tag]. Key must be exactly 32 bytes. +func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext)) + } + + nonce, ciphertextBody := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBody, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt: %w", err) + } + + return plaintext, nil +} + +// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256. +// Uses a fixed application-specific salt and 100,000 iterations for resistance +// to brute-force attacks on weak passphrases. +func DeriveKey(passphrase string) []byte { + // Fixed salt is acceptable here because: + // 1. Each certctl instance has its own passphrase + // 2. The salt prevents generic rainbow table attacks + // 3. Per-user salts are unnecessary (single server key, not user passwords) + salt := []byte("certctl-config-encryption-v1") + return pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New) +} + +// EncryptIfKeySet encrypts plaintext if a key is provided, otherwise returns plaintext unchanged. +// This supports the development/demo fallback where encryption isn't configured. +func EncryptIfKeySet(plaintext []byte, key []byte) ([]byte, bool, error) { + if len(key) == 0 { + return plaintext, false, nil + } + encrypted, err := Encrypt(plaintext, key) + if err != nil { + return nil, false, err + } + return encrypted, true, nil +} + +// DecryptIfKeySet decrypts ciphertext if a key is provided, otherwise returns ciphertext unchanged. +func DecryptIfKeySet(ciphertext []byte, key []byte) ([]byte, error) { + if len(key) == 0 { + return ciphertext, nil + } + return Decrypt(ciphertext, key) +} diff --git a/internal/crypto/encryption_test.go b/internal/crypto/encryption_test.go new file mode 100644 index 0000000..24c7727 --- /dev/null +++ b/internal/crypto/encryption_test.go @@ -0,0 +1,188 @@ +package crypto + +import ( + "bytes" + "testing" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + key := DeriveKey("test-passphrase") + plaintext := []byte(`{"api_key":"secret123","org_id":"456"}`) + + encrypted, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + if bytes.Equal(encrypted, plaintext) { + t.Fatal("encrypted data should differ from plaintext") + } + + decrypted, err := Decrypt(encrypted, key) + if err != nil { + t.Fatalf("Decrypt failed: %v", err) + } + + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("round-trip failed: got %q, want %q", decrypted, plaintext) + } +} + +func TestDecryptWrongKey(t *testing.T) { + key1 := DeriveKey("key-one") + key2 := DeriveKey("key-two") + plaintext := []byte("sensitive config data") + + encrypted, err := Encrypt(plaintext, key1) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + _, err = Decrypt(encrypted, key2) + if err == nil { + t.Fatal("expected error when decrypting with wrong key") + } +} + +func TestDecryptTamperedCiphertext(t *testing.T) { + key := DeriveKey("test-key") + plaintext := []byte("important data") + + encrypted, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + // Tamper with the ciphertext (flip a byte after the nonce) + if len(encrypted) > 13 { + encrypted[13] ^= 0xFF + } + + _, err = Decrypt(encrypted, key) + if err == nil { + t.Fatal("expected error when decrypting tampered ciphertext") + } +} + +func TestEncryptEmptyPlaintext(t *testing.T) { + key := DeriveKey("test-key") + plaintext := []byte{} + + encrypted, err := Encrypt(plaintext, key) + if err != nil { + t.Fatalf("Encrypt empty plaintext failed: %v", err) + } + + decrypted, err := Decrypt(encrypted, key) + if err != nil { + t.Fatalf("Decrypt empty plaintext failed: %v", err) + } + + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("empty plaintext round-trip failed: got %q", decrypted) + } +} + +func TestEncryptInvalidKeyLength(t *testing.T) { + _, err := Encrypt([]byte("data"), []byte("short-key")) + if err == nil { + t.Fatal("expected error for invalid key length") + } +} + +func TestDecryptInvalidKeyLength(t *testing.T) { + _, err := Decrypt([]byte("some-ciphertext-data"), []byte("short-key")) + if err == nil { + t.Fatal("expected error for invalid key length") + } +} + +func TestDecryptTooShortCiphertext(t *testing.T) { + key := DeriveKey("test-key") + _, err := Decrypt([]byte("short"), key) + if err == nil { + t.Fatal("expected error for too-short ciphertext") + } +} + +func TestDeriveKeyDeterministic(t *testing.T) { + key1 := DeriveKey("same-passphrase") + key2 := DeriveKey("same-passphrase") + if !bytes.Equal(key1, key2) { + t.Fatal("DeriveKey should be deterministic") + } + if len(key1) != 32 { + t.Fatalf("DeriveKey should return 32 bytes, got %d", len(key1)) + } +} + +func TestDeriveKeyDifferentPassphrases(t *testing.T) { + key1 := DeriveKey("passphrase-one") + key2 := DeriveKey("passphrase-two") + if bytes.Equal(key1, key2) { + t.Fatal("different passphrases should produce different keys") + } +} + +func TestEncryptIfKeySet_WithKey(t *testing.T) { + key := DeriveKey("test-key") + plaintext := []byte("config data") + + result, wasEncrypted, err := EncryptIfKeySet(plaintext, key) + if err != nil { + t.Fatalf("EncryptIfKeySet failed: %v", err) + } + if !wasEncrypted { + t.Fatal("expected wasEncrypted=true when key provided") + } + if bytes.Equal(result, plaintext) { + t.Fatal("result should be encrypted") + } + + decrypted, err := DecryptIfKeySet(result, key) + if err != nil { + t.Fatalf("DecryptIfKeySet failed: %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("round-trip failed: got %q", decrypted) + } +} + +func TestEncryptIfKeySet_NilKey(t *testing.T) { + plaintext := []byte("config data") + + result, wasEncrypted, err := EncryptIfKeySet(plaintext, nil) + if err != nil { + t.Fatalf("EncryptIfKeySet with nil key failed: %v", err) + } + if wasEncrypted { + t.Fatal("expected wasEncrypted=false when key is nil") + } + if !bytes.Equal(result, plaintext) { + t.Fatal("result should be unchanged plaintext when key is nil") + } +} + +func TestDecryptIfKeySet_NilKey(t *testing.T) { + data := []byte("plaintext config data") + + result, err := DecryptIfKeySet(data, nil) + if err != nil { + t.Fatalf("DecryptIfKeySet with nil key failed: %v", err) + } + if !bytes.Equal(result, data) { + t.Fatal("result should be unchanged when key is nil") + } +} + +func TestEncryptProducesDifferentCiphertexts(t *testing.T) { + key := DeriveKey("test-key") + plaintext := []byte("same data") + + enc1, _ := Encrypt(plaintext, key) + enc2, _ := Encrypt(plaintext, key) + + if bytes.Equal(enc1, enc2) { + t.Fatal("encrypting same plaintext twice should produce different ciphertexts (random nonce)") + } +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index 43bbeca..e3e23c5 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -7,13 +7,17 @@ import ( // Issuer represents a certificate authority or ACME provider. type Issuer struct { - ID string `json:"id"` - Name string `json:"name"` - Type IssuerType `json:"type"` - Config json.RawMessage `json:"config"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Type IssuerType `json:"type"` + Config json.RawMessage `json:"config"` + EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API) + Enabled bool `json:"enabled"` + LastTestedAt *time.Time `json:"last_tested_at,omitempty"` + TestStatus string `json:"test_status,omitempty"` + Source string `json:"source,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // DeploymentTarget represents a target system where certificates are deployed. diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go index 2464443..ea94331 100644 --- a/internal/integration/lifecycle_test.go +++ b/internal/integration/lifecycle_test.go @@ -43,9 +43,8 @@ func TestCertificateLifecycle(t *testing.T) { localCA := local.New(nil, logger) // Build issuer registry with adapter - issuerRegistry := map[string]service.IssuerConnector{ - "iss-local": service.NewIssuerConnectorAdapter(localCA), - } + issuerRegistry := service.NewIssuerRegistry(logger) + issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA)) // Initialize services (following dependency graph) auditService := service.NewAuditService(auditRepo) @@ -67,7 +66,7 @@ func TestCertificateLifecycle(t *testing.T) { deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) - issuerService := service.NewIssuerService(issuerRepo, auditService) + issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, nil, slog.Default()) // Initialize handlers certificateHandler := handler.NewCertificateHandler(certificateService) @@ -90,7 +89,8 @@ func TestCertificateLifecycle(t *testing.T) { verificationHandler := handler.NewVerificationHandler(&mockVerificationService{}) // EST handler — uses real Local CA issuer via ESTService - estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger) + localCAConnector, _ := issuerRegistry.Get("iss-local") + estService := service.NewESTService("iss-local", localCAConnector, auditService, logger) estHandler := handler.NewESTHandler(estService) // Create router and register handlers @@ -954,6 +954,14 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer return nil } +func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) { + if _, exists := m.issuers[issuer.ID]; exists { + return false, nil + } + m.issuers[issuer.ID] = issuer + return true, nil +} + func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error { delete(m.issuers, id) return nil diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index 41b59fa..57c6975 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -36,9 +36,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository logger := slog.New(slog.NewTextHandler(io.Discard, nil)) localCA := local.New(nil, logger) - issuerRegistry := map[string]service.IssuerConnector{ - "iss-local": service.NewIssuerConnectorAdapter(localCA), - } + issuerRegistry := service.NewIssuerRegistry(logger) + issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA)) revocationRepo := newMockRevocationRepository() @@ -59,7 +58,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) - issuerService := service.NewIssuerService(issuerRepo, auditService) + issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, nil, logger) certificateHandler := handler.NewCertificateHandler(certificateService) issuerHandler := handler.NewIssuerHandler(issuerService) @@ -81,7 +80,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository verificationHandler := handler.NewVerificationHandler(&mockVerificationService{}) // EST handler — uses real Local CA issuer via ESTService - estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger) + localCAConnector, _ := issuerRegistry.Get("iss-local") + estService := service.NewESTService("iss-local", localCAConnector, auditService, logger) estHandler := handler.NewESTHandler(estService) r := router.New() diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 3428d1e..e0a6ad4 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -51,6 +51,9 @@ type IssuerRepository interface { Get(ctx context.Context, id string) (*domain.Issuer, error) // Create stores a new issuer. Create(ctx context.Context, issuer *domain.Issuer) error + // CreateIfNotExists creates an issuer only if the ID doesn't already exist (ON CONFLICT DO NOTHING). + // Returns true if created, false if already existed. + CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) // Update modifies an existing issuer. Update(ctx context.Context, issuer *domain.Issuer) error // Delete removes an issuer. diff --git a/internal/repository/postgres/issuer.go b/internal/repository/postgres/issuer.go index 01d3d08..5063133 100644 --- a/internal/repository/postgres/issuer.go +++ b/internal/repository/postgres/issuer.go @@ -22,7 +22,9 @@ func NewIssuerRepository(db *sql.DB) *IssuerRepository { // List returns all issuers func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, type, config, enabled, created_at, updated_at + SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled, + last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), + created_at, updated_at FROM issuers ORDER BY created_at DESC `) @@ -36,7 +38,9 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) { for rows.Next() { var issuer domain.Issuer if err := rows.Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config, - &issuer.Enabled, &issuer.CreatedAt, &issuer.UpdatedAt); err != nil { + &issuer.EncryptedConfig, &issuer.Enabled, + &issuer.LastTestedAt, &issuer.TestStatus, &issuer.Source, + &issuer.CreatedAt, &issuer.UpdatedAt); err != nil { return nil, fmt.Errorf("failed to scan issuer: %w", err) } issuers = append(issuers, &issuer) @@ -53,11 +57,15 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) { func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) { var issuer domain.Issuer err := r.db.QueryRowContext(ctx, ` - SELECT id, name, type, config, enabled, created_at, updated_at + SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled, + last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), + created_at, updated_at FROM issuers WHERE id = $1 `, id).Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config, - &issuer.Enabled, &issuer.CreatedAt, &issuer.UpdatedAt) + &issuer.EncryptedConfig, &issuer.Enabled, + &issuer.LastTestedAt, &issuer.TestStatus, &issuer.Source, + &issuer.CreatedAt, &issuer.UpdatedAt) if err != nil { if err == sql.ErrNoRows { @@ -75,11 +83,22 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er issuer.ID = uuid.New().String() } + source := issuer.Source + if source == "" { + source = "database" + } + testStatus := issuer.TestStatus + if testStatus == "" { + testStatus = "untested" + } + err := r.db.QueryRowContext(ctx, ` - INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO issuers (id, name, type, config, encrypted_config, enabled, + last_tested_at, test_status, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id - `, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled, + `, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig, + issuer.Enabled, issuer.LastTestedAt, testStatus, source, issuer.CreatedAt, issuer.UpdatedAt).Scan(&issuer.ID) if err != nil { @@ -89,6 +108,40 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er return nil } +// CreateIfNotExists creates an issuer only if the ID doesn't already exist. +// Used for env var seeding on first boot. Returns true if created, false if already existed. +func (r *IssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) { + source := issuer.Source + if source == "" { + source = "env" + } + testStatus := issuer.TestStatus + if testStatus == "" { + testStatus = "untested" + } + + var id string + err := r.db.QueryRowContext(ctx, ` + INSERT INTO issuers (id, name, type, config, encrypted_config, enabled, + last_tested_at, test_status, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO NOTHING + RETURNING id + `, issuer.ID, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig, + issuer.Enabled, issuer.LastTestedAt, testStatus, source, + issuer.CreatedAt, issuer.UpdatedAt).Scan(&id) + + if err != nil { + if err == sql.ErrNoRows { + // ON CONFLICT DO NOTHING — row already existed + return false, nil + } + return false, fmt.Errorf("failed to create issuer: %w", err) + } + + return true, nil +} + // Update modifies an existing issuer func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error { result, err := r.db.ExecContext(ctx, ` @@ -96,10 +149,15 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er name = $1, type = $2, config = $3, - enabled = $4, - updated_at = $5 - WHERE id = $6 - `, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled, issuer.UpdatedAt, issuer.ID) + encrypted_config = $4, + enabled = $5, + last_tested_at = $6, + test_status = $7, + updated_at = $8 + WHERE id = $9 + `, issuer.Name, issuer.Type, issuer.Config, issuer.EncryptedConfig, + issuer.Enabled, issuer.LastTestedAt, issuer.TestStatus, + issuer.UpdatedAt, issuer.ID) if err != nil { return fmt.Errorf("failed to update issuer: %w", err) diff --git a/internal/service/agent.go b/internal/service/agent.go index 8ab968d..7626c66 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -21,7 +21,7 @@ type AgentService struct { targetRepo repository.TargetRepository profileRepo repository.CertificateProfileRepository auditService *AuditService - issuerRegistry map[string]IssuerConnector + issuerRegistry *IssuerRegistry renewalService *RenewalService } @@ -32,7 +32,7 @@ func NewAgentService( jobRepo repository.JobRepository, targetRepo repository.TargetRepository, auditService *AuditService, - issuerRegistry map[string]IssuerConnector, + issuerRegistry *IssuerRegistry, renewalService *RenewalService, ) *AgentService { return &AgentService{ @@ -163,7 +163,7 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str } // Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission) - connector, ok := s.issuerRegistry[cert.IssuerID] + connector, ok := s.issuerRegistry.Get(cert.IssuerID) if ok { // Resolve EKUs from the certificate profile if available var ekus []string diff --git a/internal/service/agent_test.go b/internal/service/agent_test.go index 36553b5..7449a94 100644 --- a/internal/service/agent_test.go +++ b/internal/service/agent_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "testing" "time" @@ -28,7 +29,7 @@ func TestRegisterAgent(t *testing.T) { auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -85,7 +86,7 @@ func TestHeartbeat(t *testing.T) { } auditRepo := &mockAuditRepo{} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -118,7 +119,7 @@ func TestHeartbeat_NotFound(t *testing.T) { } auditRepo := &mockAuditRepo{} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -175,7 +176,7 @@ func TestGetPendingWork(t *testing.T) { } auditRepo := &mockAuditRepo{} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -217,7 +218,8 @@ func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) { targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} auditService := NewAuditService(&mockAuditRepo{}) - agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil) + issuerRegistry := NewIssuerRegistry(slog.Default()) + agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) // Agent A should only see its job jobsA, err := agentService.GetPendingWork(ctx, agentA) @@ -268,7 +270,8 @@ func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) { targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} auditService := NewAuditService(&mockAuditRepo{}) - agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil) + issuerRegistry := NewIssuerRegistry(slog.Default()) + agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) jobs, err := agentService.GetPendingWork(ctx, agentA) if err != nil { @@ -302,7 +305,8 @@ func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) { targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} auditService := NewAuditService(&mockAuditRepo{}) - agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil) + issuerRegistry := NewIssuerRegistry(slog.Default()) + agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) jobs, err := agentService.GetPendingWork(ctx, agentA) if err != nil { @@ -350,7 +354,7 @@ func TestReportJobStatus(t *testing.T) { } auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -409,7 +413,7 @@ func TestMarkStaleAgentsOffline(t *testing.T) { } auditRepo := &mockAuditRepo{} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -475,7 +479,8 @@ func TestSubmitCSR(t *testing.T) { NotAfter: now.AddDate(1, 0, 0), }, } - issuerRegistry := map[string]IssuerConnector{"iss-local": issuerConnector} + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-local", issuerConnector) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -524,7 +529,7 @@ func TestSubmitCSR_EmptyCSR(t *testing.T) { } auditRepo := &mockAuditRepo{} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) @@ -572,7 +577,7 @@ func TestListAgents(t *testing.T) { } auditRepo := &mockAuditRepo{} auditService := NewAuditService(auditRepo) - issuerRegistry := make(map[string]IssuerConnector) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) diff --git a/internal/service/ca_operations.go b/internal/service/ca_operations.go index 5478f9c..1de7869 100644 --- a/internal/service/ca_operations.go +++ b/internal/service/ca_operations.go @@ -18,7 +18,7 @@ type CAOperationsSvc struct { revocationRepo repository.RevocationRepository certRepo repository.CertificateRepository profileRepo repository.CertificateProfileRepository - issuerRegistry map[string]IssuerConnector + issuerRegistry *IssuerRegistry } // NewCAOperationsSvc creates a new CA operations service. @@ -35,7 +35,7 @@ func NewCAOperationsSvc( } // SetIssuerRegistry sets the issuer registry for CRL and OCSP operations. -func (s *CAOperationsSvc) SetIssuerRegistry(registry map[string]IssuerConnector) { +func (s *CAOperationsSvc) SetIssuerRegistry(registry *IssuerRegistry) { s.issuerRegistry = registry } @@ -49,7 +49,7 @@ func (s *CAOperationsSvc) GenerateDERCRL(issuerID string) ([]byte, error) { return nil, fmt.Errorf("issuer registry not configured") } - issuerConn, ok := s.issuerRegistry[issuerID] + issuerConn, ok := s.issuerRegistry.Get(issuerID) if !ok { return nil, fmt.Errorf("issuer not found: %s", issuerID) } @@ -104,7 +104,7 @@ func (s *CAOperationsSvc) GetOCSPResponse(issuerID string, serialHex string) ([] return nil, fmt.Errorf("issuer registry not configured") } - issuerConn, ok := s.issuerRegistry[issuerID] + issuerConn, ok := s.issuerRegistry.Get(issuerID) if !ok { return nil, fmt.Errorf("issuer not found: %s", issuerID) } diff --git a/internal/service/ca_operations_test.go b/internal/service/ca_operations_test.go index 2bb0e3e..59845e2 100644 --- a/internal/service/ca_operations_test.go +++ b/internal/service/ca_operations_test.go @@ -3,6 +3,7 @@ package service import ( + "log/slog" "testing" "time" @@ -16,9 +17,9 @@ func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertR profileRepo := newMockProfileRepository() caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo) - caSvc.SetIssuerRegistry(map[string]IssuerConnector{ - "iss-local": &mockIssuerConnector{}, - }) + registry := NewIssuerRegistry(slog.Default()) + registry.Set("iss-local", &mockIssuerConnector{}) + caSvc.SetIssuerRegistry(registry) return caSvc, revocationRepo, certRepo } diff --git a/internal/service/concurrent_test.go b/internal/service/concurrent_test.go index 8d761b8..8046c9c 100644 --- a/internal/service/concurrent_test.go +++ b/internal/service/concurrent_test.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "log/slog" "sync" "testing" @@ -130,13 +131,14 @@ func TestConcurrentAgentHeartbeats(t *testing.T) { mockAgentRepo.AddAgent(agent) } + issuerRegistry := NewIssuerRegistry(slog.Default()) agentSvc := NewAgentService( mockAgentRepo, nil, // certRepo nil, // jobRepo nil, // targetRepo nil, // auditService - make(map[string]IssuerConnector), + issuerRegistry, nil, // renewalService ) diff --git a/internal/service/context_test.go b/internal/service/context_test.go index 6a0179b..3070dc7 100644 --- a/internal/service/context_test.go +++ b/internal/service/context_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "testing" "time" @@ -66,6 +67,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) { notifierRegistry: make(map[string]Notifier), } + issuerRegistry := NewIssuerRegistry(slog.Default()) renewalSvc := NewRenewalService( mockCertRepo, mockJobRepo, @@ -73,7 +75,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) { mockProfileRepo, mockAuditSvc, mockNotifSvc, - make(map[string]IssuerConnector), + issuerRegistry, "agent", ) @@ -162,13 +164,14 @@ func TestAgentService_HeartbeatWithCancelledContext(t *testing.T) { Hostname: "localhost", }) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentSvc := NewAgentService( mockAgentRepo, nil, // certRepo nil, // jobRepo nil, // targetRepo nil, // auditService - make(map[string]IssuerConnector), + issuerRegistry, nil, // renewalService ) @@ -212,13 +215,14 @@ func TestAgentService_HeartbeatWithDeadlineExceeded(t *testing.T) { Hostname: "localhost", }) + issuerRegistry := NewIssuerRegistry(slog.Default()) agentSvc := NewAgentService( mockAgentRepo, nil, // certRepo nil, // jobRepo nil, // targetRepo nil, // auditService - make(map[string]IssuerConnector), + issuerRegistry, nil, // renewalService ) diff --git a/internal/service/csr_renewal_test.go b/internal/service/csr_renewal_test.go index a0501d4..93344a5 100644 --- a/internal/service/csr_renewal_test.go +++ b/internal/service/csr_renewal_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "log/slog" "testing" "time" @@ -28,9 +29,8 @@ func newTestRenewalServiceForCSR(issuerErr error) *RenewalService { }) issuerConnector := &mockIssuerConnector{Err: issuerErr} - issuerRegistry := map[string]IssuerConnector{ - "iss-local": issuerConnector, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-local", issuerConnector) svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent") return svc diff --git a/internal/service/issuer.go b/internal/service/issuer.go index 3b5b381..91e368b 100644 --- a/internal/service/issuer.go +++ b/internal/service/issuer.go @@ -2,31 +2,54 @@ package service import ( "context" + "encoding/json" "fmt" "log/slog" + "os" + "strings" "time" + "github.com/shankar0123/certctl/internal/config" + "github.com/shankar0123/certctl/internal/connector/issuerfactory" + "github.com/shankar0123/certctl/internal/crypto" "github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/repository" ) +// sensitiveKeys are config key substrings that should be redacted in API responses. +var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"} + // IssuerService provides business logic for certificate issuer management. type IssuerService struct { - issuerRepo repository.IssuerRepository - auditService *AuditService + issuerRepo repository.IssuerRepository + auditService *AuditService + registry *IssuerRegistry + encryptionKey []byte + logger *slog.Logger } // NewIssuerService creates a new issuer service. func NewIssuerService( issuerRepo repository.IssuerRepository, auditService *AuditService, + registry *IssuerRegistry, + encryptionKey []byte, + logger *slog.Logger, ) *IssuerService { return &IssuerService{ - issuerRepo: issuerRepo, - auditService: auditService, + issuerRepo: issuerRepo, + auditService: auditService, + registry: registry, + encryptionKey: encryptionKey, + logger: logger, } } +// GetRegistry returns the dynamic issuer registry. +func (s *IssuerService) GetRegistry() *IssuerRegistry { + return s.registry +} + // List returns a paginated list of issuers. func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) { if page < 1 { @@ -61,49 +84,112 @@ func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, err return issuer, nil } -// Create validates and stores a new issuer. -func (s *IssuerService) Create(ctx context.Context, issuer *domain.Issuer, actor string) error { - if issuer.Name == "" { +// validIssuerTypes is the set of allowed issuer types for validation. +var validIssuerTypes = map[domain.IssuerType]bool{ + domain.IssuerTypeACME: true, + domain.IssuerTypeGenericCA: true, + domain.IssuerTypeStepCA: true, + domain.IssuerTypeOpenSSL: true, + domain.IssuerTypeVault: true, + domain.IssuerTypeDigiCert: true, + domain.IssuerTypeSectigo: true, + domain.IssuerTypeGoogleCAS: true, +} + +// isValidIssuerType checks if a type string is a known issuer type. +func isValidIssuerType(t domain.IssuerType) bool { + return validIssuerTypes[t] +} + +// Create validates and stores a new issuer, encrypting sensitive config. +func (s *IssuerService) Create(ctx context.Context, iss *domain.Issuer, actor string) error { + if iss.Name == "" { return fmt.Errorf("issuer name is required") } + if !isValidIssuerType(iss.Type) { + return fmt.Errorf("unsupported issuer type: %s", iss.Type) + } - if issuer.ID == "" { - issuer.ID = generateID("issuer") + if iss.ID == "" { + iss.ID = generateID("issuer") } now := time.Now() - if issuer.CreatedAt.IsZero() { - issuer.CreatedAt = now + if iss.CreatedAt.IsZero() { + iss.CreatedAt = now } - if issuer.UpdatedAt.IsZero() { - issuer.UpdatedAt = now + if iss.UpdatedAt.IsZero() { + iss.UpdatedAt = now } - if err := s.issuerRepo.Create(ctx, issuer); err != nil { + if iss.TestStatus == "" { + iss.TestStatus = "untested" + } + if iss.Source == "" { + iss.Source = "database" + } + + // Encrypt the full config and store redacted version in config column + if len(iss.Config) > 0 { + encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey) + if err != nil { + return fmt.Errorf("failed to encrypt config: %w", err) + } + iss.EncryptedConfig = encrypted + iss.Config = redactConfigJSON(iss.Config) + } + + if err := s.issuerRepo.Create(ctx, iss); err != nil { return fmt.Errorf("failed to create issuer: %w", err) } + // Add to dynamic registry + if iss.Enabled { + s.rebuildRegistryQuiet(ctx) + } + if s.auditService != nil { - if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", issuer.ID, nil); auditErr != nil { - slog.Error("failed to record audit event", "error", auditErr) + if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", iss.ID, nil); auditErr != nil { + s.logger.Error("failed to record audit event", "error", auditErr) } } return nil } -// Update modifies an existing issuer. -func (s *IssuerService) Update(ctx context.Context, id string, issuer *domain.Issuer, actor string) error { - if issuer.Name == "" { +// Update modifies an existing issuer. Handles "********" preservation for sensitive fields. +func (s *IssuerService) Update(ctx context.Context, id string, iss *domain.Issuer, actor string) error { + if iss.Name == "" { return fmt.Errorf("issuer name is required") } - issuer.ID = id - if err := s.issuerRepo.Update(ctx, issuer); err != nil { + iss.ID = id + iss.UpdatedAt = time.Now() + + // If config contains "********" values, merge with existing decrypted config + if len(iss.Config) > 0 { + mergedConfig, err := s.mergeRedactedConfig(ctx, id, iss.Config) + if err != nil { + return fmt.Errorf("failed to merge config: %w", err) + } + + // Encrypt the merged config + encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey) + if encErr != nil { + return fmt.Errorf("failed to encrypt config: %w", encErr) + } + iss.EncryptedConfig = encrypted + iss.Config = redactConfigJSON(json.RawMessage(mergedConfig)) + } + + if err := s.issuerRepo.Update(ctx, iss); err != nil { return fmt.Errorf("failed to update issuer %s: %w", id, err) } + // Rebuild registry after update + s.rebuildRegistryQuiet(ctx) + if s.auditService != nil { if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != nil { - slog.Error("failed to record audit event", "error", auditErr) + s.logger.Error("failed to record audit event", "error", auditErr) } } @@ -116,27 +202,48 @@ func (s *IssuerService) Delete(ctx context.Context, id string, actor string) err return fmt.Errorf("failed to delete issuer %s: %w", id, err) } + // Remove from registry + if s.registry != nil { + s.registry.Remove(id) + } + if s.auditService != nil { if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != nil { - slog.Error("failed to record audit event", "error", auditErr) + s.logger.Error("failed to record audit event", "error", auditErr) } } return nil } -// TestConnectionWithContext verifies the issuer connection with context. +// TestConnectionWithContext tests the connection to an issuer by instantiating a throwaway +// connector and calling ValidateConfig. Records the result in the database. func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error { - issuer, err := s.issuerRepo.Get(ctx, id) + iss, err := s.issuerRepo.Get(ctx, id) if err != nil { return fmt.Errorf("issuer not found: %w", err) } - // TODO: Implement actual connection test based on issuer type - if issuer == nil { - return fmt.Errorf("issuer not found") + // Get the decrypted config + configJSON, err := s.getDecryptedConfig(iss) + if err != nil { + s.updateTestStatus(ctx, iss, "failed") + return fmt.Errorf("failed to decrypt config: %w", err) } + // Instantiate a throwaway connector and validate + connector, err := issuerfactory.NewFromConfig(string(iss.Type), configJSON, s.logger) + if err != nil { + s.updateTestStatus(ctx, iss, "failed") + return fmt.Errorf("failed to create connector: %w", err) + } + + if err := connector.ValidateConfig(ctx, configJSON); err != nil { + s.updateTestStatus(ctx, iss, "failed") + return fmt.Errorf("connection test failed: %w", err) + } + + s.updateTestStatus(ctx, iss, "success") return nil } @@ -145,6 +252,241 @@ func (s *IssuerService) TestConnection(id string) error { return s.TestConnectionWithContext(context.Background(), id) } +// BuildRegistry loads all enabled issuers from the database and rebuilds the dynamic registry. +// Called at server startup. Partial failures (individual issuers failing to load) are logged +// as warnings but don't prevent the server from starting. +func (s *IssuerService) BuildRegistry(ctx context.Context) error { + issuers, err := s.issuerRepo.List(ctx) + if err != nil { + return fmt.Errorf("failed to load issuers from database: %w", err) + } + + if err := s.registry.Rebuild(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) + } + + s.logger.Info("issuer registry built from database", "total_issuers", len(issuers), "registry_size", s.registry.Len()) + return nil +} + +// SeedFromEnvVars creates issuer records from environment variables if the database is empty. +// Uses ON CONFLICT DO NOTHING so GUI-created configs are never overwritten. +func (s *IssuerService) SeedFromEnvVars(ctx context.Context, cfg *config.Config) { + // Check if any issuers already exist + existing, err := s.issuerRepo.List(ctx) + if err != nil { + s.logger.Error("failed to check existing issuers for env var seeding", "error", err) + return + } + + if len(existing) > 0 { + s.logger.Info("issuers already exist in database, skipping env var seeding", "count", len(existing)) + return + } + + s.logger.Info("no issuers in database, seeding from environment variables") + + seeds := s.buildEnvVarSeeds(cfg) + seeded := 0 + for _, seed := range seeds { + // Encrypt the config if key is set + if len(seed.Config) > 0 { + encrypted, _, encErr := crypto.EncryptIfKeySet([]byte(seed.Config), s.encryptionKey) + if encErr != nil { + s.logger.Error("failed to encrypt seed config", "id", seed.ID, "error", encErr) + continue + } + seed.EncryptedConfig = encrypted + seed.Config = redactConfigJSON(seed.Config) + } + + if err := s.issuerRepo.Create(ctx, seed); err != nil { + s.logger.Warn("failed to seed issuer from env var", "id", seed.ID, "error", err) + continue + } + seeded++ + s.logger.Info("seeded issuer from env vars", "id", seed.ID, "type", seed.Type) + } + + s.logger.Info("env var seeding complete", "seeded", seeded, "total_seeds", len(seeds)) +} + +// buildEnvVarSeeds constructs issuer domain objects from the config's env var values. +func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer { + now := time.Now() + var seeds []*domain.Issuer + + // Local CA (always seeded) + seeds = append(seeds, &domain.Issuer{ + ID: "iss-local", + Name: "Local CA", + Type: domain.IssuerTypeGenericCA, + Config: mustJSON(map[string]interface{}{"ca_cert_path": cfg.CA.CertPath, "ca_key_path": cfg.CA.KeyPath}), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + + // ACME (always seeded — even with empty directory URL, for demo mode) + seeds = append(seeds, &domain.Issuer{ + ID: "iss-acme-staging", + Name: "ACME Staging", + Type: domain.IssuerTypeACME, + Config: mustJSON(map[string]interface{}{ + "directory_url": cfg.ACME.DirectoryURL, + "email": cfg.ACME.Email, + "challenge_type": cfg.ACME.ChallengeType, + "insecure": cfg.ACME.Insecure, + "ari_enabled": cfg.ACME.ARIEnabled, + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + + // ACME prod (same config, different ID for backward compat) + seeds = append(seeds, &domain.Issuer{ + ID: "iss-acme-prod", + Name: "ACME Production", + Type: domain.IssuerTypeACME, + Config: mustJSON(map[string]interface{}{ + "directory_url": cfg.ACME.DirectoryURL, + "email": cfg.ACME.Email, + "challenge_type": cfg.ACME.ChallengeType, + "insecure": cfg.ACME.Insecure, + "ari_enabled": cfg.ACME.ARIEnabled, + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + + // Conditional: step-ca — only seed if CERTCTL_STEPCA_URL is set + if stepcaURL := getEnvForSeed("CERTCTL_STEPCA_URL"); stepcaURL != "" { + seeds = append(seeds, &domain.Issuer{ + ID: "iss-stepca", + Name: "step-ca", + Type: domain.IssuerTypeStepCA, + Config: mustJSON(map[string]interface{}{ + "ca_url": stepcaURL, + "root_cert_path": getEnvForSeed("CERTCTL_STEPCA_ROOT_CERT"), + "provisioner_name": getEnvForSeed("CERTCTL_STEPCA_PROVISIONER"), + "provisioner_key_path": getEnvForSeed("CERTCTL_STEPCA_KEY_PATH"), + "provisioner_password": getEnvForSeed("CERTCTL_STEPCA_PASSWORD"), + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + } + + // Conditional: OpenSSL — only seed if sign script is set + if signScript := getEnvForSeed("CERTCTL_OPENSSL_SIGN_SCRIPT"); signScript != "" { + seeds = append(seeds, &domain.Issuer{ + ID: "iss-openssl", + Name: "OpenSSL/Custom CA", + Type: domain.IssuerTypeOpenSSL, + Config: mustJSON(map[string]interface{}{ + "sign_script": signScript, + "revoke_script": getEnvForSeed("CERTCTL_OPENSSL_REVOKE_SCRIPT"), + "crl_script": getEnvForSeed("CERTCTL_OPENSSL_CRL_SCRIPT"), + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + } + + // Conditional: Vault PKI + if cfg.Vault.Addr != "" { + seeds = append(seeds, &domain.Issuer{ + ID: "iss-vault", + Name: "Vault PKI", + Type: domain.IssuerTypeVault, + Config: mustJSON(map[string]interface{}{ + "addr": cfg.Vault.Addr, + "token": cfg.Vault.Token, + "mount": cfg.Vault.Mount, + "role": cfg.Vault.Role, + "ttl": cfg.Vault.TTL, + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + } + + // Conditional: DigiCert + if cfg.DigiCert.APIKey != "" { + seeds = append(seeds, &domain.Issuer{ + ID: "iss-digicert", + Name: "DigiCert CertCentral", + Type: domain.IssuerTypeDigiCert, + Config: mustJSON(map[string]interface{}{ + "api_key": cfg.DigiCert.APIKey, + "org_id": cfg.DigiCert.OrgID, + "product_type": cfg.DigiCert.ProductType, + "base_url": cfg.DigiCert.BaseURL, + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + } + + // Conditional: Sectigo + if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" { + seeds = append(seeds, &domain.Issuer{ + ID: "iss-sectigo", + Name: "Sectigo SCM", + Type: domain.IssuerTypeSectigo, + Config: mustJSON(map[string]interface{}{ + "customer_uri": cfg.Sectigo.CustomerURI, + "login": cfg.Sectigo.Login, + "password": cfg.Sectigo.Password, + "org_id": cfg.Sectigo.OrgID, + "cert_type": cfg.Sectigo.CertType, + "term": cfg.Sectigo.Term, + "base_url": cfg.Sectigo.BaseURL, + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + } + + // Conditional: Google CAS + if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" { + seeds = append(seeds, &domain.Issuer{ + ID: "iss-googlecas", + Name: "Google CAS", + Type: domain.IssuerTypeGoogleCAS, + Config: mustJSON(map[string]interface{}{ + "project": cfg.GoogleCAS.Project, + "location": cfg.GoogleCAS.Location, + "ca_pool": cfg.GoogleCAS.CAPool, + "credentials": cfg.GoogleCAS.Credentials, + "ttl": cfg.GoogleCAS.TTL, + }), + Enabled: true, + Source: "env", + CreatedAt: now, + UpdatedAt: now, + }) + } + + return seeds +} + // ListIssuers returns paginated issuers (handler interface method). func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) { if page < 1 { @@ -176,33 +518,234 @@ func (s *IssuerService) GetIssuer(id string) (*domain.Issuer, error) { } // CreateIssuer creates a new issuer (handler interface method). -func (s *IssuerService) CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error) { - if issuer.ID == "" { - issuer.ID = generateID("issuer") +func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) { + if !isValidIssuerType(iss.Type) { + return nil, fmt.Errorf("unsupported issuer type: %s", iss.Type) + } + if iss.ID == "" { + iss.ID = generateID("issuer") } now := time.Now() - if issuer.CreatedAt.IsZero() { - issuer.CreatedAt = now + if iss.CreatedAt.IsZero() { + iss.CreatedAt = now } - if issuer.UpdatedAt.IsZero() { - issuer.UpdatedAt = now + if iss.UpdatedAt.IsZero() { + iss.UpdatedAt = now } - if err := s.issuerRepo.Create(context.Background(), &issuer); err != nil { + if iss.TestStatus == "" { + iss.TestStatus = "untested" + } + if iss.Source == "" { + iss.Source = "database" + } + + // Encrypt config + if len(iss.Config) > 0 { + encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt config: %w", err) + } + iss.EncryptedConfig = encrypted + iss.Config = redactConfigJSON(iss.Config) + } + + if err := s.issuerRepo.Create(context.Background(), &iss); err != nil { return nil, fmt.Errorf("failed to create issuer: %w", err) } - return &issuer, nil + + // Rebuild registry + if iss.Enabled { + s.rebuildRegistryQuiet(context.Background()) + } + + return &iss, nil } // UpdateIssuer modifies an issuer (handler interface method). -func (s *IssuerService) UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error) { - issuer.ID = id - if err := s.issuerRepo.Update(context.Background(), &issuer); err != nil { +func (s *IssuerService) UpdateIssuer(id string, iss domain.Issuer) (*domain.Issuer, error) { + iss.ID = id + iss.UpdatedAt = time.Now() + + // Merge redacted fields with existing config + if len(iss.Config) > 0 { + mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, iss.Config) + if err != nil { + return nil, fmt.Errorf("failed to merge config: %w", err) + } + + encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey) + if encErr != nil { + return nil, fmt.Errorf("failed to encrypt config: %w", encErr) + } + iss.EncryptedConfig = encrypted + iss.Config = redactConfigJSON(json.RawMessage(mergedConfig)) + } + + if err := s.issuerRepo.Update(context.Background(), &iss); err != nil { return nil, fmt.Errorf("failed to update issuer: %w", err) } - return &issuer, nil + + s.rebuildRegistryQuiet(context.Background()) + + return &iss, nil } // DeleteIssuer removes an issuer (handler interface method). func (s *IssuerService) DeleteIssuer(id string) error { - return s.issuerRepo.Delete(context.Background(), id) + if err := s.issuerRepo.Delete(context.Background(), id); err != nil { + return err + } + if s.registry != nil { + s.registry.Remove(id) + } + return nil +} + +// --- Internal helpers --- + +// rebuildRegistryQuiet rebuilds the registry, logging errors instead of returning them. +func (s *IssuerService) rebuildRegistryQuiet(ctx context.Context) { + if s.registry == nil { + return + } + if err := s.BuildRegistry(ctx); err != nil { + s.logger.Error("failed to rebuild issuer registry after change", "error", err) + } +} + +// getDecryptedConfig returns the decrypted config JSON for an issuer. +func (s *IssuerService) getDecryptedConfig(iss *domain.Issuer) (json.RawMessage, error) { + if len(iss.EncryptedConfig) > 0 { + decrypted, err := crypto.DecryptIfKeySet(iss.EncryptedConfig, s.encryptionKey) + if err != nil { + return nil, err + } + return json.RawMessage(decrypted), nil + } + if len(iss.Config) > 0 { + return iss.Config, nil + } + return json.RawMessage("{}"), nil +} + +// mergeRedactedConfig merges incoming config (which may have "********" values) +// with the existing decrypted config so sensitive fields are preserved. +func (s *IssuerService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) { + // Parse incoming config + var incomingMap map[string]interface{} + if err := json.Unmarshal(incoming, &incomingMap); err != nil { + s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "issuer", id, "error", err) + return incoming, nil + } + + // Check if any values are "********" + hasRedacted := false + for _, v := range incomingMap { + if str, ok := v.(string); ok && str == "********" { + hasRedacted = true + break + } + } + + if !hasRedacted { + return incoming, nil // No redacted values, use incoming as-is + } + + // Load existing config to get real values + existing, err := s.issuerRepo.Get(ctx, id) + if err != nil { + s.logger.Warn("mergeRedactedConfig: could not load existing issuer, redacted values will be lost", "issuer", id, "error", err) + return incoming, nil + } + + existingConfig, err := s.getDecryptedConfig(existing) + if err != nil { + s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "issuer", id, "error", err) + return incoming, nil + } + + var existingMap map[string]interface{} + if err := json.Unmarshal(existingConfig, &existingMap); err != nil { + s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "issuer", id, "error", err) + return incoming, nil + } + + // Merge: for each "********" value in incoming, use existing value + for k, v := range incomingMap { + if str, ok := v.(string); ok && str == "********" { + if existingVal, exists := existingMap[k]; exists { + incomingMap[k] = existingVal + } + } + } + + return json.Marshal(incomingMap) +} + +// updateTestStatus updates the test_status and last_tested_at fields in the database +// and records an audit event. +func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer, status string) { + now := time.Now() + iss.TestStatus = status + iss.LastTestedAt = &now + iss.UpdatedAt = now + if err := s.issuerRepo.Update(ctx, iss); err != nil { + s.logger.Error("failed to update test status", "issuer", iss.ID, "status", status, "error", err) + } + + // Record audit event for connection test + if s.auditService != nil { + action := "issuer_test_connection_" + status + details := map[string]interface{}{"issuer_type": string(iss.Type), "result": status} + if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "issuer", iss.ID, details); auditErr != nil { + s.logger.Error("failed to record test connection audit event", "error", auditErr) + } + } +} + +// redactConfigJSON replaces sensitive values in a JSON config with "********". +func redactConfigJSON(configJSON json.RawMessage) json.RawMessage { + var m map[string]interface{} + if err := json.Unmarshal(configJSON, &m); err != nil { + return configJSON // Not a JSON object, return as-is + } + + for k, v := range m { + if isSensitiveConfigKey(k) { + if str, ok := v.(string); ok && str != "" { + m[k] = "********" + } + } + } + + redacted, err := json.Marshal(m) + if err != nil { + return configJSON + } + return json.RawMessage(redacted) +} + +// isSensitiveConfigKey checks if a config key contains sensitive substrings. +func isSensitiveConfigKey(key string) bool { + lower := strings.ToLower(key) + for _, s := range sensitiveKeys { + if strings.Contains(lower, s) { + return true + } + } + return false +} + +// getEnvForSeed reads an environment variable for seed data construction. +func getEnvForSeed(key string) string { + return os.Getenv(key) +} + +// mustJSON marshals a value to json.RawMessage, panicking on error (for seed data only). +func mustJSON(v interface{}) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(fmt.Sprintf("mustJSON: %v", err)) + } + return json.RawMessage(b) } diff --git a/internal/service/issuer_registry.go b/internal/service/issuer_registry.go new file mode 100644 index 0000000..fcfb479 --- /dev/null +++ b/internal/service/issuer_registry.go @@ -0,0 +1,139 @@ +package service + +import ( + "encoding/json" + "fmt" + "log/slog" + "sync" + + "github.com/shankar0123/certctl/internal/connector/issuerfactory" + "github.com/shankar0123/certctl/internal/crypto" + "github.com/shankar0123/certctl/internal/domain" +) + +// IssuerRegistry is a thread-safe registry of issuer connectors. +// It replaces the static map[string]IssuerConnector that was built at startup. +// Consumers call Get() to look up a connector by issuer ID. +type IssuerRegistry struct { + mu sync.RWMutex + issuers map[string]IssuerConnector + logger *slog.Logger +} + +// NewIssuerRegistry creates a new empty issuer registry. +func NewIssuerRegistry(logger *slog.Logger) *IssuerRegistry { + return &IssuerRegistry{ + issuers: make(map[string]IssuerConnector), + logger: logger, + } +} + +// Get returns the issuer connector for the given ID and whether it exists. +func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + conn, ok := r.issuers[id] + return conn, ok +} + +// Set adds or replaces an issuer connector in the registry. +func (r *IssuerRegistry) Set(id string, conn IssuerConnector) { + r.mu.Lock() + defer r.mu.Unlock() + r.issuers[id] = conn +} + +// Remove removes an issuer connector from the registry. +func (r *IssuerRegistry) Remove(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.issuers, id) +} + +// List returns a copy of all registered issuers. +func (r *IssuerRegistry) List() map[string]IssuerConnector { + r.mu.RLock() + defer r.mu.RUnlock() + result := make(map[string]IssuerConnector, len(r.issuers)) + for k, v := range r.issuers { + result[k] = v + } + return result +} + +// Len returns the number of registered issuers. +func (r *IssuerRegistry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.issuers) +} + +// Rebuild reconstructs the registry from a list of issuer configs. +// For each enabled issuer, it decrypts the config (if encryption key is set), +// instantiates a connector via the factory, wraps it in an adapter, and +// atomically swaps the entire map. +func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey []byte) error { + newIssuers := make(map[string]IssuerConnector) + var errors []string + + for _, cfg := range configs { + if !cfg.Enabled { + r.logger.Debug("skipping disabled issuer", "id", cfg.ID, "type", cfg.Type) + continue + } + + // Determine the config JSON to use for connector instantiation. + // Prefer encrypted_config (decrypted) if available; fall back to config. + var configJSON json.RawMessage + if len(cfg.EncryptedConfig) > 0 { + decrypted, err := crypto.DecryptIfKeySet(cfg.EncryptedConfig, encryptionKey) + if err != nil { + errors = append(errors, fmt.Sprintf("issuer %s: decrypt failed: %v", cfg.ID, err)) + continue + } + configJSON = json.RawMessage(decrypted) + } else if len(cfg.Config) > 0 { + configJSON = cfg.Config + } else { + configJSON = json.RawMessage("{}") + } + + connector, err := issuerfactory.NewFromConfig(string(cfg.Type), configJSON, r.logger) + if err != nil { + errors = append(errors, fmt.Sprintf("issuer %s: factory error: %v", cfg.ID, err)) + continue + } + + newIssuers[cfg.ID] = NewIssuerConnectorAdapter(connector) + r.logger.Info("issuer loaded into registry", "id", cfg.ID, "type", cfg.Type) + } + + // Atomic swap + r.mu.Lock() + old := r.issuers + r.issuers = newIssuers + r.mu.Unlock() + + // Log changes + for id := range newIssuers { + if _, existed := old[id]; !existed { + r.logger.Info("issuer added to registry", "id", id) + } + } + for id := range old { + if _, exists := newIssuers[id]; !exists { + r.logger.Info("issuer removed from registry", "id", id) + } + } + + r.logger.Info("issuer registry rebuilt", "loaded", len(newIssuers), "failed", len(errors)) + + if len(errors) > 0 { + for _, e := range errors { + r.logger.Warn("issuer load failure", "detail", e) + } + return fmt.Errorf("%d issuer(s) failed to load: %s", len(errors), errors[0]) + } + + return nil +} diff --git a/internal/service/issuer_registry_test.go b/internal/service/issuer_registry_test.go new file mode 100644 index 0000000..cad610c --- /dev/null +++ b/internal/service/issuer_registry_test.go @@ -0,0 +1,286 @@ +package service + +import ( + "encoding/json" + "log/slog" + "os" + "sync" + "testing" + + "github.com/shankar0123/certctl/internal/crypto" + "github.com/shankar0123/certctl/internal/domain" +) + +func registryTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +func TestIssuerRegistry_GetSet(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + mock := &mockIssuerConnector{} + reg.Set("iss-test", mock) + + conn, ok := reg.Get("iss-test") + if !ok { + t.Fatal("expected to find iss-test in registry") + } + if conn == nil { + t.Fatal("expected non-nil connector") + } +} + +func TestIssuerRegistry_GetNotFound(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + _, ok := reg.Get("nonexistent") + if ok { + t.Fatal("expected not to find nonexistent issuer") + } +} + +func TestIssuerRegistry_Remove(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + reg.Set("iss-test", &mockIssuerConnector{}) + reg.Remove("iss-test") + + _, ok := reg.Get("iss-test") + if ok { + t.Fatal("expected issuer to be removed") + } +} + +func TestIssuerRegistry_List(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + reg.Set("iss-a", &mockIssuerConnector{}) + reg.Set("iss-b", &mockIssuerConnector{}) + + list := reg.List() + if len(list) != 2 { + t.Fatalf("expected 2 issuers, got %d", len(list)) + } + + // Verify List returns a copy (modifying it doesn't affect registry) + delete(list, "iss-a") + if reg.Len() != 2 { + t.Fatal("deleting from List() copy should not affect registry") + } +} + +func TestIssuerRegistry_Len(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + if reg.Len() != 0 { + t.Fatalf("expected empty registry, got %d", reg.Len()) + } + + reg.Set("iss-a", &mockIssuerConnector{}) + if reg.Len() != 1 { + t.Fatalf("expected 1 issuer, got %d", reg.Len()) + } +} + +func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + configs := []*domain.Issuer{ + { + ID: "iss-local", + Name: "Local CA", + Type: "local", + Config: json.RawMessage(`{}`), + Enabled: true, + }, + { + ID: "iss-disabled", + Name: "Disabled", + Type: "local", + Config: json.RawMessage(`{}`), + Enabled: false, + }, + } + + err := reg.Rebuild(configs, nil) + if err != nil { + t.Fatalf("Rebuild failed: %v", err) + } + + if reg.Len() != 1 { + t.Fatalf("expected 1 enabled issuer, got %d", reg.Len()) + } + + _, ok := reg.Get("iss-local") + if !ok { + t.Fatal("expected iss-local in registry") + } + + _, ok = reg.Get("iss-disabled") + if ok { + t.Fatal("disabled issuer should not be in registry") + } +} + +func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + key := crypto.DeriveKey("test-key") + configJSON := []byte(`{"ca_common_name":"Encrypted CA"}`) + encrypted, err := crypto.Encrypt(configJSON, key) + if err != nil { + t.Fatalf("encrypt failed: %v", err) + } + + configs := []*domain.Issuer{ + { + ID: "iss-encrypted", + Name: "Encrypted Local CA", + Type: "local", + EncryptedConfig: encrypted, + Enabled: true, + }, + } + + err = reg.Rebuild(configs, key) + if err != nil { + t.Fatalf("Rebuild with encryption failed: %v", err) + } + + _, ok := reg.Get("iss-encrypted") + if !ok { + t.Fatal("expected iss-encrypted in registry") + } +} + +func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + configs := []*domain.Issuer{ + { + ID: "iss-plain", + Name: "Plain Config", + Type: "local", + Config: json.RawMessage(`{}`), + Enabled: true, + }, + } + + // nil key should work — falls back to config column + err := reg.Rebuild(configs, nil) + if err != nil { + t.Fatalf("Rebuild with nil key failed: %v", err) + } + + _, ok := reg.Get("iss-plain") + if !ok { + t.Fatal("expected iss-plain in registry") + } +} + +func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + configs := []*domain.Issuer{ + { + ID: "iss-bad", + Name: "Bad Config", + Type: "UnknownType", + Config: json.RawMessage(`{}`), + Enabled: true, + }, + { + ID: "iss-good", + Name: "Good Config", + Type: "local", + Config: json.RawMessage(`{}`), + Enabled: true, + }, + } + + // Should return an error indicating partial failure, but still load valid issuers + err := reg.Rebuild(configs, nil) + if err == nil { + t.Fatal("Rebuild should return error when some issuers fail to load") + } + + // Despite the error, valid issuers should be loaded + if reg.Len() != 1 { + t.Fatalf("expected 1 valid issuer, got %d", reg.Len()) + } + + _, ok := reg.Get("iss-good") + if !ok { + t.Fatal("expected iss-good in registry") + } +} + +func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + // Set up initial state + reg.Set("iss-old", &mockIssuerConnector{}) + + configs := []*domain.Issuer{ + { + ID: "iss-new", + Name: "New Issuer", + Type: "local", + Config: json.RawMessage(`{}`), + Enabled: true, + }, + } + + err := reg.Rebuild(configs, nil) + if err != nil { + t.Fatalf("Rebuild failed: %v", err) + } + + _, ok := reg.Get("iss-old") + if ok { + t.Fatal("old issuer should have been replaced") + } + + _, ok = reg.Get("iss-new") + if !ok { + t.Fatal("new issuer should be present") + } +} + +func TestIssuerRegistry_ConcurrentAccess(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(3) + id := "iss-concurrent" + go func() { + defer wg.Done() + reg.Set(id, &mockIssuerConnector{}) + }() + go func() { + defer wg.Done() + reg.Get(id) + }() + go func() { + defer wg.Done() + reg.List() + }() + } + wg.Wait() + // No race detector panics = success +} + +func TestIssuerRegistry_Rebuild_Empty(t *testing.T) { + reg := NewIssuerRegistry(registryTestLogger()) + + reg.Set("iss-existing", &mockIssuerConnector{}) + + err := reg.Rebuild([]*domain.Issuer{}, nil) + if err != nil { + t.Fatalf("Rebuild with empty configs failed: %v", err) + } + + if reg.Len() != 0 { + t.Fatalf("expected empty registry after rebuild with no configs, got %d", reg.Len()) + } +} diff --git a/internal/service/issuer_test.go b/internal/service/issuer_test.go index 51b0402..7ed3cb6 100644 --- a/internal/service/issuer_test.go +++ b/internal/service/issuer_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log/slog" "testing" "time" @@ -49,7 +50,7 @@ func TestIssuerService_List(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) issuers, total, err := service.List(ctx, 1, 2) @@ -85,7 +86,8 @@ func TestIssuerService_List_DefaultPagination(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) // Call with invalid page and perPage issuers, total, err := service.List(ctx, 0, 0) @@ -113,7 +115,7 @@ func TestIssuerService_List_RepositoryError(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) _, _, err := service.List(ctx, 1, 50) @@ -134,7 +136,8 @@ func TestIssuerService_List_EmptyResult(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) issuers, total, err := service.List(ctx, 1, 50) @@ -170,7 +173,7 @@ func TestIssuerService_Get(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) retrieved, err := service.Get(ctx, "iss-acme-prod") @@ -195,7 +198,8 @@ func TestIssuerService_Get_NotFound(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) _, err := service.Get(ctx, "nonexistent-issuer") @@ -212,7 +216,8 @@ func TestIssuerService_Create(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"} configJSON, _ := json.Marshal(config) @@ -274,7 +279,8 @@ func TestIssuerService_Create_EmptyName(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) issuer := &domain.Issuer{ Name: "", @@ -308,7 +314,7 @@ func TestIssuerService_Create_RepositoryError(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) issuer := &domain.Issuer{ Name: "Test Issuer", @@ -335,7 +341,8 @@ func TestIssuerService_Update(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) config := map[string]interface{}{"endpoint": "https://acme.example.com"} configJSON, _ := json.Marshal(config) @@ -379,7 +386,8 @@ func TestIssuerService_Update_EmptyName(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) issuer := &domain.Issuer{ Name: "", @@ -406,7 +414,8 @@ func TestIssuerService_Delete(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) err := service.Delete(ctx, "iss-to-delete", "user-frank") @@ -438,7 +447,7 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) err := service.Delete(ctx, "iss-bad-id", "user-grace") @@ -455,24 +464,27 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) { func TestIssuerService_TestConnection_Success(t *testing.T) { ctx := context.Background() - issuer := &domain.Issuer{ + // Use GenericCA (Local CA) type because it has no required config fields, + // so ValidateConfig succeeds with empty config. + iss := &domain.Issuer{ ID: "iss-test-conn", Name: "Test Connection", - Type: domain.IssuerTypeACME, + Type: domain.IssuerTypeGenericCA, + Config: json.RawMessage(`{"validity_days":365}`), Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } repo := newMockIssuerRepository() - repo.AddIssuer(issuer) + repo.AddIssuer(iss) auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) - err := service.TestConnectionWithContext(ctx, "iss-test-conn") + err := svc.TestConnectionWithContext(ctx, "iss-test-conn") if err != nil { t.Fatalf("TestConnectionWithContext failed: %v", err) @@ -487,7 +499,8 @@ func TestIssuerService_TestConnection_NotFound(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) err := service.TestConnectionWithContext(ctx, "nonexistent-issuer") @@ -527,7 +540,7 @@ func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default()) issuers, total, err := service.ListIssuers(1, 50) @@ -554,7 +567,8 @@ func TestIssuerService_CreateIssuer_HandlerInterface(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) config := map[string]interface{}{"url": "https://example.com"} configJSON, _ := json.Marshal(config) @@ -591,7 +605,8 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) { auditRepo := newMockAuditRepository() auditService := NewAuditService(auditRepo) - service := NewIssuerService(repo, auditService) + registry := NewIssuerRegistry(slog.Default()) + service := NewIssuerService(repo, auditService, registry, nil, slog.Default()) err := service.DeleteIssuer("iss-handler-delete") diff --git a/internal/service/job_test.go b/internal/service/job_test.go index 29c8dcd..8339897 100644 --- a/internal/service/job_test.go +++ b/internal/service/job_test.go @@ -28,7 +28,8 @@ func newTestJobService(jobRepo *mockJobRepo) *JobService { targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)} - renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, make(map[string]IssuerConnector), "server") + issuerRegistry := NewIssuerRegistry(logger) + renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, issuerRegistry, "server") deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService) return NewJobService(jobRepo, renewalService, deploymentService, logger) diff --git a/internal/service/renewal.go b/internal/service/renewal.go index c7b3bbf..809efe2 100644 --- a/internal/service/renewal.go +++ b/internal/service/renewal.go @@ -29,7 +29,7 @@ type RenewalService struct { targetRepo repository.TargetRepository auditService *AuditService notificationSvc *NotificationService - issuerRegistry map[string]IssuerConnector + issuerRegistry *IssuerRegistry keygenMode string // "agent" (default) or "server" (demo only) } @@ -101,7 +101,7 @@ func NewRenewalService( profileRepo repository.CertificateProfileRepository, auditService *AuditService, notificationSvc *NotificationService, - issuerRegistry map[string]IssuerConnector, + issuerRegistry *IssuerRegistry, keygenMode string, ) *RenewalService { if keygenMode == "" { @@ -169,7 +169,7 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error { s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds) // Only create renewal job if an issuer connector is registered for this cert's issuer - connector, hasIssuer := s.issuerRegistry[cert.IssuerID] + connector, hasIssuer := s.issuerRegistry.Get(cert.IssuerID) if !hasIssuer { continue } @@ -347,7 +347,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job) return fmt.Errorf("certificate has no issuer assigned") } - _, ok := s.issuerRegistry[issuerID] + _, ok := s.issuerRegistry.Get(issuerID) if !ok { s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID)) return fmt.Errorf("issuer connector not found for %s", issuerID) @@ -390,7 +390,7 @@ func (s *RenewalService) processRenewalAgentKeygen(ctx context.Context, job *dom // private key in the cert version so agents can retrieve it for deployment. // WARNING: Private keys touch the control plane. Use only for development/demo. func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error { - connector := s.issuerRegistry[cert.IssuerID] + connector, _ := s.issuerRegistry.Get(cert.IssuerID) // Generate server-side RSA key + CSR privKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -524,7 +524,7 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do // It signs the CSR via the issuer connector, stores the cert version (without private key), // completes the renewal job, and creates deployment jobs. func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error { - connector, ok := s.issuerRegistry[cert.IssuerID] + connector, ok := s.issuerRegistry.Get(cert.IssuerID) if !ok { s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID)) return fmt.Errorf("issuer connector not found for %s", cert.IssuerID) diff --git a/internal/service/renewal_test.go b/internal/service/renewal_test.go index 231ffa0..7093f89 100644 --- a/internal/service/renewal_test.go +++ b/internal/service/renewal_test.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "log/slog" "strings" "testing" "time" @@ -26,9 +27,8 @@ func TestCheckExpiringCertificates_SendsThresholdAlerts(t *testing.T) { "Email": notifier, }) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -108,9 +108,8 @@ func TestCheckExpiringCertificates_DeduplicatesAlerts(t *testing.T) { "Email": notifier, }) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -188,9 +187,8 @@ func TestCheckExpiringCertificates_SkipsRenewalInProgress(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -253,9 +251,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpiring(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -315,9 +312,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpired(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -377,9 +373,8 @@ func TestCheckExpiringCertificates_CreatesRenewalJob(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -445,7 +440,7 @@ func TestCheckExpiringCertificates_SkipsWithoutIssuer(t *testing.T) { notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) // Empty issuer registry - issuerRegistry := map[string]IssuerConnector{} + issuerRegistry := NewIssuerRegistry(slog.Default()) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -505,9 +500,8 @@ func TestCheckExpiringCertificates_SkipsDuplicateJobs(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -589,9 +583,8 @@ func TestProcessRenewalJob(t *testing.T) { }) issuerConnector := &mockIssuerConnector{} - issuerRegistry := map[string]IssuerConnector{ - "iss-test": issuerConnector, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", issuerConnector) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -685,9 +678,8 @@ func TestProcessRenewalJob_IssuerFailure(t *testing.T) { Err: fmt.Errorf("issuer service unavailable"), } - issuerRegistry := map[string]IssuerConnector{ - "iss-test": issuerConnector, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", issuerConnector) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -767,9 +759,8 @@ func TestRetryFailedJobs(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -832,9 +823,8 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) { auditSvc := NewAuditService(auditRepo) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -885,9 +875,8 @@ func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) { SuggestedWindowEnd: time.Now().Add(48 * time.Hour), }, } - issuerRegistry := map[string]IssuerConnector{ - "iss-acme": ariConnector, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-acme", ariConnector) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -958,9 +947,8 @@ func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) { SuggestedWindowEnd: time.Now().Add(96 * time.Hour), }, } - issuerRegistry := map[string]IssuerConnector{ - "iss-acme": ariConnector, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-acme", ariConnector) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -1021,9 +1009,8 @@ func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) { notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) // ARI returns nil (issuer doesn't support ARI) — default mock behavior - issuerRegistry := map[string]IssuerConnector{ - "iss-local": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-local", &mockIssuerConnector{}) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") @@ -1090,9 +1077,8 @@ func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) { ariConnector := &mockIssuerConnector{ getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"), } - issuerRegistry := map[string]IssuerConnector{ - "iss-acme": ariConnector, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-acme", ariConnector) svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") diff --git a/internal/service/revocation_svc.go b/internal/service/revocation_svc.go index 6d28a3e..2456082 100644 --- a/internal/service/revocation_svc.go +++ b/internal/service/revocation_svc.go @@ -17,7 +17,7 @@ type RevocationSvc struct { revocationRepo repository.RevocationRepository auditService *AuditService notificationSvc *NotificationService - issuerRegistry map[string]IssuerConnector + issuerRegistry *IssuerRegistry } // NewRevocationSvc creates a new revocation service. @@ -39,7 +39,7 @@ func (s *RevocationSvc) SetNotificationService(svc *NotificationService) { } // SetIssuerRegistry sets the issuer registry for issuer-level revocation. -func (s *RevocationSvc) SetIssuerRegistry(registry map[string]IssuerConnector) { +func (s *RevocationSvc) SetIssuerRegistry(registry *IssuerRegistry) { s.issuerRegistry = registry } @@ -110,7 +110,7 @@ func (s *RevocationSvc) RevokeCertificateWithActor(ctx context.Context, certID s // 5. Notify the issuer connector (best-effort) if s.issuerRegistry != nil { - if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok { + if issuerConn, ok := s.issuerRegistry.Get(cert.IssuerID); ok { if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil { slog.Error("failed to notify issuer of revocation", "error", err, diff --git a/internal/service/revocation_svc_test.go b/internal/service/revocation_svc_test.go index a7d6ba4..7016789 100644 --- a/internal/service/revocation_svc_test.go +++ b/internal/service/revocation_svc_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "log/slog" "testing" "time" @@ -18,9 +19,9 @@ func newRevocationSvcTest() (*RevocationSvc, *mockCertRepo, *mockRevocationRepo, auditService := NewAuditService(auditRepo) revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService) - revSvc.SetIssuerRegistry(map[string]IssuerConnector{ - "iss-local": &mockIssuerConnector{}, - }) + registry := NewIssuerRegistry(slog.Default()) + registry.Set("iss-local", &mockIssuerConnector{}) + revSvc.SetIssuerRegistry(registry) return revSvc, certRepo, revocationRepo, auditRepo } diff --git a/internal/service/revocation_test.go b/internal/service/revocation_test.go index c645a90..dcfd459 100644 --- a/internal/service/revocation_test.go +++ b/internal/service/revocation_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "testing" "time" @@ -21,15 +22,13 @@ func newRevocationTestService() (*CertificateService, *mockCertRepo, *mockRevoca // Create RevocationSvc revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService) - revSvc.SetIssuerRegistry(map[string]IssuerConnector{ - "iss-local": &mockIssuerConnector{}, - }) + registry := NewIssuerRegistry(slog.Default()) + registry.Set("iss-local", &mockIssuerConnector{}) + revSvc.SetIssuerRegistry(registry) // Create CAOperationsSvc caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo) - caSvc.SetIssuerRegistry(map[string]IssuerConnector{ - "iss-local": &mockIssuerConnector{}, - }) + caSvc.SetIssuerRegistry(registry) certService := NewCertificateService(certRepo, policyService, auditService) certService.SetRevocationSvc(revSvc) @@ -243,9 +242,9 @@ func TestRevokeCertificate_WithIssuerNotification(t *testing.T) { // Wire up issuer registry on RevocationSvc with mock mockIssuer := &mockIssuerConnector{} - svc.revSvc.SetIssuerRegistry(map[string]IssuerConnector{ - "iss-local": mockIssuer, - }) + registry := NewIssuerRegistry(slog.Default()) + registry.Set("iss-local", mockIssuer) + svc.revSvc.SetIssuerRegistry(registry) cert := &domain.ManagedCertificate{ ID: "cert-7", diff --git a/internal/service/shortlived_test.go b/internal/service/shortlived_test.go index 4dd9860..db099ee 100644 --- a/internal/service/shortlived_test.go +++ b/internal/service/shortlived_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "log/slog" "testing" "time" @@ -18,9 +19,8 @@ func setupShortLivedTestService( ) *RenewalService { auditSvc := NewAuditService(auditRepo) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService( certRepo, @@ -137,9 +137,8 @@ func TestExpireShortLivedCertificates_ListError(t *testing.T) { // Create the service manually to use our custom cert repo auditSvc := NewAuditService(auditRepo) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService( customCertRepo, @@ -385,9 +384,8 @@ func TestExpireShortLivedCertificates_NoProfileRepository(t *testing.T) { } auditSvc := NewAuditService(auditRepo) - issuerRegistry := map[string]IssuerConnector{ - "iss-test": &mockIssuerConnector{}, - } + issuerRegistry := NewIssuerRegistry(slog.Default()) + issuerRegistry.Set("iss-test", &mockIssuerConnector{}) svc := NewRenewalService( certRepo, diff --git a/internal/service/testutil_test.go b/internal/service/testutil_test.go index 9cdae46..f6254fb 100644 --- a/internal/service/testutil_test.go +++ b/internal/service/testutil_test.go @@ -856,6 +856,17 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer return nil } +func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) { + if m.CreateErr != nil { + return false, m.CreateErr + } + if _, exists := m.issuers[issuer.ID]; exists { + return false, nil + } + m.issuers[issuer.ID] = issuer + return true, nil +} + func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error { if m.DeleteErr != nil { return m.DeleteErr diff --git a/migrations/000009_issuer_config.down.sql b/migrations/000009_issuer_config.down.sql new file mode 100644 index 0000000..82e2587 --- /dev/null +++ b/migrations/000009_issuer_config.down.sql @@ -0,0 +1,5 @@ +-- Rollback migration 000009: Remove dynamic issuer configuration columns +ALTER TABLE issuers DROP COLUMN IF EXISTS encrypted_config; +ALTER TABLE issuers DROP COLUMN IF EXISTS last_tested_at; +ALTER TABLE issuers DROP COLUMN IF EXISTS test_status; +ALTER TABLE issuers DROP COLUMN IF EXISTS source; diff --git a/migrations/000009_issuer_config.up.sql b/migrations/000009_issuer_config.up.sql new file mode 100644 index 0000000..5e557fc --- /dev/null +++ b/migrations/000009_issuer_config.up.sql @@ -0,0 +1,16 @@ +-- Migration 000009: Add dynamic issuer configuration columns +-- Supports M34: Dynamic Issuer Configuration (GUI) + +-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets. +-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy. +ALTER TABLE issuers ADD COLUMN IF NOT EXISTS encrypted_config BYTEA; + +-- last_tested_at tracks when the issuer connection was last successfully tested. +ALTER TABLE issuers ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ; + +-- test_status tracks the latest connection test result. +ALTER TABLE issuers ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested'; + +-- source tracks where the issuer configuration originated from. +-- 'database' = created via GUI, 'env' = seeded from environment variables. +ALTER TABLE issuers ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database'; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 41b2115..9e318db 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -142,6 +142,12 @@ export interface Issuer { status: string; /** Backend returns enabled boolean; status is derived from this */ enabled: boolean; + /** Timestamp of last connection test */ + last_tested_at?: string; + /** Result of last connection test: "untested", "success", or "failed" */ + test_status?: string; + /** Config source: "database" (GUI-created) or "env" (env var seeded) */ + source?: string; created_at: string; updated_at?: string; } diff --git a/web/src/pages/IssuerDetailPage.tsx b/web/src/pages/IssuerDetailPage.tsx index 9e988c7..d5a49e3 100644 --- a/web/src/pages/IssuerDetailPage.tsx +++ b/web/src/pages/IssuerDetailPage.tsx @@ -45,6 +45,7 @@ export default function IssuerDetailPage() { const testMutation = useMutation({ mutationFn: () => testIssuerConnection(id!), + onSuccess: () => refetch(), }); if (error) { @@ -128,6 +129,22 @@ export default function IssuerDetailPage() { } /> + + {issuer.source === 'env' ? 'Environment Variable' : 'GUI Configured'} + + } /> + Passed {issuer.last_tested_at ? formatDateTime(issuer.last_tested_at) : ''} + ) : issuer.test_status === 'failed' ? ( + Failed {issuer.last_tested_at ? formatDateTime(issuer.last_tested_at) : ''} + ) : ( + Not tested + ) + } />