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
+ )
+ } />