feat(M34): dynamic issuer configuration with encrypted config storage

Replace static env-var-based issuer wiring with GUI-driven dynamic
configuration stored encrypted in PostgreSQL. Operators can now
configure, test, enable/disable, and manage issuers from the dashboard
without restarting the server.

Key changes:
- AES-256-GCM encryption for sensitive issuer config at rest (PBKDF2
  key derivation with 100k iterations)
- Dynamic IssuerRegistry with sync.RWMutex replacing static map
- Connector factory pattern (issuerfactory.NewFromConfig) replacing
  140 lines of static wiring in main.go
- Migration 000009: encrypted_config, last_tested_at, test_status,
  source columns on issuers table
- Env var seeding on first boot with ON CONFLICT DO NOTHING
- Registry Rebuild() for atomic map swap after CRUD operations
- Issuer type validation against domain constants on Create
- Audit trail for test connection results
- Conditional seeding for step-ca/OpenSSL (only when env vars set)
- GUI: source badge, connection test status on issuer detail page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-04-04 00:20:13 -04:00
parent 9954fd1100
commit 995b72df05
36 changed files with 1859 additions and 361 deletions
+19 -163
View File
@@ -16,15 +16,8 @@ import (
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/api/router" "github.com/shankar0123/certctl/internal/api/router"
"github.com/shankar0123/certctl/internal/config" "github.com/shankar0123/certctl/internal/config"
"github.com/shankar0123/certctl/internal/crypto"
"github.com/shankar0123/certctl/internal/domain" "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" notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie" notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
@@ -85,143 +78,18 @@ func main() {
ownerRepo := postgres.NewOwnerRepository(db) ownerRepo := postgres.NewOwnerRepository(db)
logger.Info("initialized all repositories") logger.Info("initialized all repositories")
// Initialize Local CA issuer connector. // Initialize dynamic issuer registry.
// In sub-CA mode (CERTCTL_CA_CERT_PATH + CERTCTL_CA_KEY_PATH set), loads a pre-signed // Issuers are loaded from the database (with AES-GCM encrypted config).
// CA cert+key from disk. All issued certs chain to the upstream root (e.g., ADCS). // On first boot with an empty database, env var issuers are seeded automatically.
// Otherwise, generates an ephemeral self-signed CA for development/demo. var encryptionKey []byte
localCAConfig := &local.Config{} if cfg.Encryption.ConfigEncryptionKey != "" {
if cfg.CA.CertPath != "" && cfg.CA.KeyPath != "" { encryptionKey = crypto.DeriveKey(cfg.Encryption.ConfigEncryptionKey)
localCAConfig.CACertPath = cfg.CA.CertPath logger.Info("config encryption enabled (AES-256-GCM)")
localCAConfig.CAKeyPath = cfg.CA.KeyPath
logger.Info("Local CA configured in sub-CA mode",
"cert_path", cfg.CA.CertPath,
"key_path", cfg.CA.KeyPath)
} else { } else {
logger.Info("Local CA configured in self-signed mode (ephemeral)") logger.Warn("CERTCTL_CONFIG_ENCRYPTION_KEY not set — issuer configs stored in plaintext (not recommended for production)")
}
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(&sectigoissuer.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),
} }
// Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set) issuerRegistry := service.NewIssuerRegistry(logger)
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))
// Initialize revocation repository // Initialize revocation repository
revocationRepo := postgres.NewRevocationRepository(db) revocationRepo := postgres.NewRevocationRepository(db)
@@ -309,7 +177,14 @@ func main() {
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
agentService.SetProfileRepo(profileRepo) 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) targetService := service.NewTargetService(targetRepo, auditService)
profileService := service.NewProfileService(profileRepo, auditService) profileService := service.NewProfileService(profileRepo, auditService)
teamService := service.NewTeamService(teamRepo, auditService) teamService := service.NewTeamService(teamRepo, auditService)
@@ -447,7 +322,7 @@ func main() {
}) })
// Register EST (RFC 7030) handlers if enabled // Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled { if cfg.EST.Enabled {
issuerConn, ok := issuerRegistry[cfg.EST.IssuerID] issuerConn, ok := issuerRegistry.Get(cfg.EST.IssuerID)
if !ok { if !ok {
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID) logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
os.Exit(1) os.Exit(1)
@@ -645,22 +520,3 @@ func main() {
logger.Info("certctl server stopped") 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
}
+11
View File
@@ -30,6 +30,14 @@ type Config struct {
Sectigo SectigoConfig Sectigo SectigoConfig
GoogleCAS GoogleCASConfig GoogleCAS GoogleCASConfig
Digest DigestConfig 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. // NotifierConfig contains configuration for notification connectors.
@@ -598,6 +606,9 @@ func Load() (*Config, error) {
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour), Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil), Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
}, },
Encryption: EncryptionConfig{
ConfigEncryptionKey: getEnv("CERTCTL_CONFIG_ENCRYPTION_KEY", ""),
},
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
+4
View File
@@ -0,0 +1,4 @@
package issuer
// Factory has been moved to internal/connector/issuerfactory to avoid import cycles.
// See issuerfactory.NewFromConfig().
@@ -0,0 +1,3 @@
package issuer
// Factory tests have been moved to internal/connector/issuerfactory.
@@ -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)
}
}
@@ -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")
}
}
+103
View File
@@ -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)
}
+188
View File
@@ -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)")
}
}
+4
View File
@@ -11,7 +11,11 @@ type Issuer struct {
Name string `json:"name"` Name string `json:"name"`
Type IssuerType `json:"type"` Type IssuerType `json:"type"`
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
Enabled bool `json:"enabled"` 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
+13 -5
View File
@@ -43,9 +43,8 @@ func TestCertificateLifecycle(t *testing.T) {
localCA := local.New(nil, logger) localCA := local.New(nil, logger)
// Build issuer registry with adapter // Build issuer registry with adapter
issuerRegistry := map[string]service.IssuerConnector{ issuerRegistry := service.NewIssuerRegistry(logger)
"iss-local": service.NewIssuerConnectorAdapter(localCA), issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
}
// Initialize services (following dependency graph) // Initialize services (following dependency graph)
auditService := service.NewAuditService(auditRepo) auditService := service.NewAuditService(auditRepo)
@@ -67,7 +66,7 @@ func TestCertificateLifecycle(t *testing.T) {
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) 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 // Initialize handlers
certificateHandler := handler.NewCertificateHandler(certificateService) certificateHandler := handler.NewCertificateHandler(certificateService)
@@ -90,7 +89,8 @@ func TestCertificateLifecycle(t *testing.T) {
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{}) verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
// EST handler — uses real Local CA issuer via ESTService // 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) estHandler := handler.NewESTHandler(estService)
// Create router and register handlers // Create router and register handlers
@@ -954,6 +954,14 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer
return nil 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 { func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
delete(m.issuers, id) delete(m.issuers, id)
return nil return nil
+5 -5
View File
@@ -36,9 +36,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
localCA := local.New(nil, logger) localCA := local.New(nil, logger)
issuerRegistry := map[string]service.IssuerConnector{ issuerRegistry := service.NewIssuerRegistry(logger)
"iss-local": service.NewIssuerConnectorAdapter(localCA), issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
}
revocationRepo := newMockRevocationRepository() revocationRepo := newMockRevocationRepository()
@@ -59,7 +58,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) 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) certificateHandler := handler.NewCertificateHandler(certificateService)
issuerHandler := handler.NewIssuerHandler(issuerService) issuerHandler := handler.NewIssuerHandler(issuerService)
@@ -81,7 +80,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{}) verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
// EST handler — uses real Local CA issuer via ESTService // 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) estHandler := handler.NewESTHandler(estService)
r := router.New() r := router.New()
+3
View File
@@ -51,6 +51,9 @@ type IssuerRepository interface {
Get(ctx context.Context, id string) (*domain.Issuer, error) Get(ctx context.Context, id string) (*domain.Issuer, error)
// Create stores a new issuer. // Create stores a new issuer.
Create(ctx context.Context, issuer *domain.Issuer) error 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 modifies an existing issuer.
Update(ctx context.Context, issuer *domain.Issuer) error Update(ctx context.Context, issuer *domain.Issuer) error
// Delete removes an issuer. // Delete removes an issuer.
+69 -11
View File
@@ -22,7 +22,9 @@ func NewIssuerRepository(db *sql.DB) *IssuerRepository {
// List returns all issuers // List returns all issuers
func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) { func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
rows, err := r.db.QueryContext(ctx, ` 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 FROM issuers
ORDER BY created_at DESC ORDER BY created_at DESC
`) `)
@@ -36,7 +38,9 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
for rows.Next() { for rows.Next() {
var issuer domain.Issuer var issuer domain.Issuer
if err := rows.Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config, 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) return nil, fmt.Errorf("failed to scan issuer: %w", err)
} }
issuers = append(issuers, &issuer) 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) { func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
var issuer domain.Issuer var issuer domain.Issuer
err := r.db.QueryRowContext(ctx, ` 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 FROM issuers
WHERE id = $1 WHERE id = $1
`, id).Scan(&issuer.ID, &issuer.Name, &issuer.Type, &issuer.Config, `, 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 != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -75,11 +83,22 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er
issuer.ID = uuid.New().String() issuer.ID = uuid.New().String()
} }
source := issuer.Source
if source == "" {
source = "database"
}
testStatus := issuer.TestStatus
if testStatus == "" {
testStatus = "untested"
}
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) INSERT INTO issuers (id, name, type, config, encrypted_config, enabled,
VALUES ($1, $2, $3, $4, $5, $6, $7) last_tested_at, test_status, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id 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) issuer.CreatedAt, issuer.UpdatedAt).Scan(&issuer.ID)
if err != nil { if err != nil {
@@ -89,6 +108,40 @@ func (r *IssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) er
return nil 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 // Update modifies an existing issuer
func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error { func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
result, err := r.db.ExecContext(ctx, ` result, err := r.db.ExecContext(ctx, `
@@ -96,10 +149,15 @@ func (r *IssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) er
name = $1, name = $1,
type = $2, type = $2,
config = $3, config = $3,
enabled = $4, encrypted_config = $4,
updated_at = $5 enabled = $5,
WHERE id = $6 last_tested_at = $6,
`, issuer.Name, issuer.Type, issuer.Config, issuer.Enabled, issuer.UpdatedAt, issuer.ID) 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 { if err != nil {
return fmt.Errorf("failed to update issuer: %w", err) return fmt.Errorf("failed to update issuer: %w", err)
+3 -3
View File
@@ -21,7 +21,7 @@ type AgentService struct {
targetRepo repository.TargetRepository targetRepo repository.TargetRepository
profileRepo repository.CertificateProfileRepository profileRepo repository.CertificateProfileRepository
auditService *AuditService auditService *AuditService
issuerRegistry map[string]IssuerConnector issuerRegistry *IssuerRegistry
renewalService *RenewalService renewalService *RenewalService
} }
@@ -32,7 +32,7 @@ func NewAgentService(
jobRepo repository.JobRepository, jobRepo repository.JobRepository,
targetRepo repository.TargetRepository, targetRepo repository.TargetRepository,
auditService *AuditService, auditService *AuditService,
issuerRegistry map[string]IssuerConnector, issuerRegistry *IssuerRegistry,
renewalService *RenewalService, renewalService *RenewalService,
) *AgentService { ) *AgentService {
return &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) // 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 { if ok {
// Resolve EKUs from the certificate profile if available // Resolve EKUs from the certificate profile if available
var ekus []string var ekus []string
+17 -12
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"log/slog"
"testing" "testing"
"time" "time"
@@ -28,7 +29,7 @@ func TestRegisterAgent(t *testing.T) {
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}} auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -85,7 +86,7 @@ func TestHeartbeat(t *testing.T) {
} }
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -118,7 +119,7 @@ func TestHeartbeat_NotFound(t *testing.T) {
} }
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -175,7 +176,7 @@ func TestGetPendingWork(t *testing.T) {
} }
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) 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)} targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{}) 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 // Agent A should only see its job
jobsA, err := agentService.GetPendingWork(ctx, agentA) jobsA, err := agentService.GetPendingWork(ctx, agentA)
@@ -268,7 +270,8 @@ func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{}) 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) jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil { if err != nil {
@@ -302,7 +305,8 @@ func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{}) 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) jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil { if err != nil {
@@ -350,7 +354,7 @@ func TestReportJobStatus(t *testing.T) {
} }
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}} auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -409,7 +413,7 @@ func TestMarkStaleAgentsOffline(t *testing.T) {
} }
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -475,7 +479,8 @@ func TestSubmitCSR(t *testing.T) {
NotAfter: now.AddDate(1, 0, 0), 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) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -524,7 +529,7 @@ func TestSubmitCSR_EmptyCSR(t *testing.T) {
} }
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
@@ -572,7 +577,7 @@ func TestListAgents(t *testing.T) {
} }
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
issuerRegistry := make(map[string]IssuerConnector) issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
+4 -4
View File
@@ -18,7 +18,7 @@ type CAOperationsSvc struct {
revocationRepo repository.RevocationRepository revocationRepo repository.RevocationRepository
certRepo repository.CertificateRepository certRepo repository.CertificateRepository
profileRepo repository.CertificateProfileRepository profileRepo repository.CertificateProfileRepository
issuerRegistry map[string]IssuerConnector issuerRegistry *IssuerRegistry
} }
// NewCAOperationsSvc creates a new CA operations service. // NewCAOperationsSvc creates a new CA operations service.
@@ -35,7 +35,7 @@ func NewCAOperationsSvc(
} }
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations. // 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 s.issuerRegistry = registry
} }
@@ -49,7 +49,7 @@ func (s *CAOperationsSvc) GenerateDERCRL(issuerID string) ([]byte, error) {
return nil, fmt.Errorf("issuer registry not configured") return nil, fmt.Errorf("issuer registry not configured")
} }
issuerConn, ok := s.issuerRegistry[issuerID] issuerConn, ok := s.issuerRegistry.Get(issuerID)
if !ok { if !ok {
return nil, fmt.Errorf("issuer not found: %s", issuerID) 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") return nil, fmt.Errorf("issuer registry not configured")
} }
issuerConn, ok := s.issuerRegistry[issuerID] issuerConn, ok := s.issuerRegistry.Get(issuerID)
if !ok { if !ok {
return nil, fmt.Errorf("issuer not found: %s", issuerID) return nil, fmt.Errorf("issuer not found: %s", issuerID)
} }
+4 -3
View File
@@ -3,6 +3,7 @@
package service package service
import ( import (
"log/slog"
"testing" "testing"
"time" "time"
@@ -16,9 +17,9 @@ func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertR
profileRepo := newMockProfileRepository() profileRepo := newMockProfileRepository()
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo) caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
caSvc.SetIssuerRegistry(map[string]IssuerConnector{ registry := NewIssuerRegistry(slog.Default())
"iss-local": &mockIssuerConnector{}, registry.Set("iss-local", &mockIssuerConnector{})
}) caSvc.SetIssuerRegistry(registry)
return caSvc, revocationRepo, certRepo return caSvc, revocationRepo, certRepo
} }
+3 -1
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"testing" "testing"
@@ -130,13 +131,14 @@ func TestConcurrentAgentHeartbeats(t *testing.T) {
mockAgentRepo.AddAgent(agent) mockAgentRepo.AddAgent(agent)
} }
issuerRegistry := NewIssuerRegistry(slog.Default())
agentSvc := NewAgentService( agentSvc := NewAgentService(
mockAgentRepo, mockAgentRepo,
nil, // certRepo nil, // certRepo
nil, // jobRepo nil, // jobRepo
nil, // targetRepo nil, // targetRepo
nil, // auditService nil, // auditService
make(map[string]IssuerConnector), issuerRegistry,
nil, // renewalService nil, // renewalService
) )
+7 -3
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"log/slog"
"testing" "testing"
"time" "time"
@@ -66,6 +67,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
notifierRegistry: make(map[string]Notifier), notifierRegistry: make(map[string]Notifier),
} }
issuerRegistry := NewIssuerRegistry(slog.Default())
renewalSvc := NewRenewalService( renewalSvc := NewRenewalService(
mockCertRepo, mockCertRepo,
mockJobRepo, mockJobRepo,
@@ -73,7 +75,7 @@ func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
mockProfileRepo, mockProfileRepo,
mockAuditSvc, mockAuditSvc,
mockNotifSvc, mockNotifSvc,
make(map[string]IssuerConnector), issuerRegistry,
"agent", "agent",
) )
@@ -162,13 +164,14 @@ func TestAgentService_HeartbeatWithCancelledContext(t *testing.T) {
Hostname: "localhost", Hostname: "localhost",
}) })
issuerRegistry := NewIssuerRegistry(slog.Default())
agentSvc := NewAgentService( agentSvc := NewAgentService(
mockAgentRepo, mockAgentRepo,
nil, // certRepo nil, // certRepo
nil, // jobRepo nil, // jobRepo
nil, // targetRepo nil, // targetRepo
nil, // auditService nil, // auditService
make(map[string]IssuerConnector), issuerRegistry,
nil, // renewalService nil, // renewalService
) )
@@ -212,13 +215,14 @@ func TestAgentService_HeartbeatWithDeadlineExceeded(t *testing.T) {
Hostname: "localhost", Hostname: "localhost",
}) })
issuerRegistry := NewIssuerRegistry(slog.Default())
agentSvc := NewAgentService( agentSvc := NewAgentService(
mockAgentRepo, mockAgentRepo,
nil, // certRepo nil, // certRepo
nil, // jobRepo nil, // jobRepo
nil, // targetRepo nil, // targetRepo
nil, // auditService nil, // auditService
make(map[string]IssuerConnector), issuerRegistry,
nil, // renewalService nil, // renewalService
) )
+3 -3
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"testing" "testing"
"time" "time"
@@ -28,9 +29,8 @@ func newTestRenewalServiceForCSR(issuerErr error) *RenewalService {
}) })
issuerConnector := &mockIssuerConnector{Err: issuerErr} issuerConnector := &mockIssuerConnector{Err: issuerErr}
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-local": issuerConnector, issuerRegistry.Set("iss-local", issuerConnector)
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent") svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
return svc return svc
+582 -39
View File
@@ -2,31 +2,54 @@ package service
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"strings"
"time" "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/domain"
"github.com/shankar0123/certctl/internal/repository" "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. // IssuerService provides business logic for certificate issuer management.
type IssuerService struct { type IssuerService struct {
issuerRepo repository.IssuerRepository issuerRepo repository.IssuerRepository
auditService *AuditService auditService *AuditService
registry *IssuerRegistry
encryptionKey []byte
logger *slog.Logger
} }
// NewIssuerService creates a new issuer service. // NewIssuerService creates a new issuer service.
func NewIssuerService( func NewIssuerService(
issuerRepo repository.IssuerRepository, issuerRepo repository.IssuerRepository,
auditService *AuditService, auditService *AuditService,
registry *IssuerRegistry,
encryptionKey []byte,
logger *slog.Logger,
) *IssuerService { ) *IssuerService {
return &IssuerService{ return &IssuerService{
issuerRepo: issuerRepo, issuerRepo: issuerRepo,
auditService: auditService, 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. // List returns a paginated list of issuers.
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) { func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
if page < 1 { if page < 1 {
@@ -61,49 +84,112 @@ func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, err
return issuer, nil return issuer, nil
} }
// Create validates and stores a new issuer. // validIssuerTypes is the set of allowed issuer types for validation.
func (s *IssuerService) Create(ctx context.Context, issuer *domain.Issuer, actor string) error { var validIssuerTypes = map[domain.IssuerType]bool{
if issuer.Name == "" { domain.IssuerTypeACME: true,
return fmt.Errorf("issuer name is required") domain.IssuerTypeGenericCA: true,
domain.IssuerTypeStepCA: true,
domain.IssuerTypeOpenSSL: true,
domain.IssuerTypeVault: true,
domain.IssuerTypeDigiCert: true,
domain.IssuerTypeSectigo: true,
domain.IssuerTypeGoogleCAS: true,
} }
if issuer.ID == "" { // isValidIssuerType checks if a type string is a known issuer type.
issuer.ID = generateID("issuer") 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 iss.ID == "" {
iss.ID = generateID("issuer")
} }
now := time.Now() now := time.Now()
if issuer.CreatedAt.IsZero() { if iss.CreatedAt.IsZero() {
issuer.CreatedAt = now iss.CreatedAt = now
} }
if issuer.UpdatedAt.IsZero() { if iss.UpdatedAt.IsZero() {
issuer.UpdatedAt = now 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) return fmt.Errorf("failed to create issuer: %w", err)
} }
// Add to dynamic registry
if iss.Enabled {
s.rebuildRegistryQuiet(ctx)
}
if s.auditService != nil { if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", issuer.ID, nil); auditErr != nil { if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", iss.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 return nil
} }
// Update modifies an existing issuer. // Update modifies an existing issuer. Handles "********" preservation for sensitive fields.
func (s *IssuerService) Update(ctx context.Context, id string, issuer *domain.Issuer, actor string) error { func (s *IssuerService) Update(ctx context.Context, id string, iss *domain.Issuer, actor string) error {
if issuer.Name == "" { if iss.Name == "" {
return fmt.Errorf("issuer name is required") return fmt.Errorf("issuer name is required")
} }
issuer.ID = id iss.ID = id
if err := s.issuerRepo.Update(ctx, issuer); err != nil { 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) return fmt.Errorf("failed to update issuer %s: %w", id, err)
} }
// Rebuild registry after update
s.rebuildRegistryQuiet(ctx)
if s.auditService != nil { if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != 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) 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 s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != 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 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 { 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 { if err != nil {
return fmt.Errorf("issuer not found: %w", err) return fmt.Errorf("issuer not found: %w", err)
} }
// TODO: Implement actual connection test based on issuer type // Get the decrypted config
if issuer == nil { configJSON, err := s.getDecryptedConfig(iss)
return fmt.Errorf("issuer not found") 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 return nil
} }
@@ -145,6 +252,241 @@ func (s *IssuerService) TestConnection(id string) error {
return s.TestConnectionWithContext(context.Background(), id) 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). // ListIssuers returns paginated issuers (handler interface method).
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) { func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
if page < 1 { if page < 1 {
@@ -176,33 +518,234 @@ func (s *IssuerService) GetIssuer(id string) (*domain.Issuer, error) {
} }
// CreateIssuer creates a new issuer (handler interface method). // CreateIssuer creates a new issuer (handler interface method).
func (s *IssuerService) CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error) { func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) {
if issuer.ID == "" { if !isValidIssuerType(iss.Type) {
issuer.ID = generateID("issuer") return nil, fmt.Errorf("unsupported issuer type: %s", iss.Type)
}
if iss.ID == "" {
iss.ID = generateID("issuer")
} }
now := time.Now() now := time.Now()
if issuer.CreatedAt.IsZero() { if iss.CreatedAt.IsZero() {
issuer.CreatedAt = now iss.CreatedAt = now
} }
if issuer.UpdatedAt.IsZero() { if iss.UpdatedAt.IsZero() {
issuer.UpdatedAt = now 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 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). // UpdateIssuer modifies an issuer (handler interface method).
func (s *IssuerService) UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error) { func (s *IssuerService) UpdateIssuer(id string, iss domain.Issuer) (*domain.Issuer, error) {
issuer.ID = id iss.ID = id
if err := s.issuerRepo.Update(context.Background(), &issuer); err != nil { 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 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). // DeleteIssuer removes an issuer (handler interface method).
func (s *IssuerService) DeleteIssuer(id string) error { 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)
} }
+139
View File
@@ -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
}
+286
View File
@@ -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())
}
}
+37 -22
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog"
"testing" "testing"
"time" "time"
@@ -49,7 +50,7 @@ func TestIssuerService_List(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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) issuers, total, err := service.List(ctx, 1, 2)
@@ -85,7 +86,8 @@ func TestIssuerService_List_DefaultPagination(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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 // Call with invalid page and perPage
issuers, total, err := service.List(ctx, 0, 0) issuers, total, err := service.List(ctx, 0, 0)
@@ -113,7 +115,7 @@ func TestIssuerService_List_RepositoryError(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService) service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
_, _, err := service.List(ctx, 1, 50) _, _, err := service.List(ctx, 1, 50)
@@ -134,7 +136,8 @@ func TestIssuerService_List_EmptyResult(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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) issuers, total, err := service.List(ctx, 1, 50)
@@ -170,7 +173,7 @@ func TestIssuerService_Get(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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") retrieved, err := service.Get(ctx, "iss-acme-prod")
@@ -195,7 +198,8 @@ func TestIssuerService_Get_NotFound(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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") _, err := service.Get(ctx, "nonexistent-issuer")
@@ -212,7 +216,8 @@ func TestIssuerService_Create(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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"} config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"}
configJSON, _ := json.Marshal(config) configJSON, _ := json.Marshal(config)
@@ -274,7 +279,8 @@ func TestIssuerService_Create_EmptyName(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService) registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
issuer := &domain.Issuer{ issuer := &domain.Issuer{
Name: "", Name: "",
@@ -308,7 +314,7 @@ func TestIssuerService_Create_RepositoryError(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService) service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
issuer := &domain.Issuer{ issuer := &domain.Issuer{
Name: "Test Issuer", Name: "Test Issuer",
@@ -335,7 +341,8 @@ func TestIssuerService_Update(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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"} config := map[string]interface{}{"endpoint": "https://acme.example.com"}
configJSON, _ := json.Marshal(config) configJSON, _ := json.Marshal(config)
@@ -379,7 +386,8 @@ func TestIssuerService_Update_EmptyName(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService) registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
issuer := &domain.Issuer{ issuer := &domain.Issuer{
Name: "", Name: "",
@@ -406,7 +414,8 @@ func TestIssuerService_Delete(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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") err := service.Delete(ctx, "iss-to-delete", "user-frank")
@@ -438,7 +447,7 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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") 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) { func TestIssuerService_TestConnection_Success(t *testing.T) {
ctx := context.Background() 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", ID: "iss-test-conn",
Name: "Test Connection", Name: "Test Connection",
Type: domain.IssuerTypeACME, Type: domain.IssuerTypeGenericCA,
Config: json.RawMessage(`{"validity_days":365}`),
Enabled: true, Enabled: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
repo := newMockIssuerRepository() repo := newMockIssuerRepository()
repo.AddIssuer(issuer) repo.AddIssuer(iss)
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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 { if err != nil {
t.Fatalf("TestConnectionWithContext failed: %v", err) t.Fatalf("TestConnectionWithContext failed: %v", err)
@@ -487,7 +499,8 @@ func TestIssuerService_TestConnection_NotFound(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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") err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
@@ -527,7 +540,7 @@ func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService) service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
issuers, total, err := service.ListIssuers(1, 50) issuers, total, err := service.ListIssuers(1, 50)
@@ -554,7 +567,8 @@ func TestIssuerService_CreateIssuer_HandlerInterface(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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"} config := map[string]interface{}{"url": "https://example.com"}
configJSON, _ := json.Marshal(config) configJSON, _ := json.Marshal(config)
@@ -591,7 +605,8 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) {
auditRepo := newMockAuditRepository() auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo) 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") err := service.DeleteIssuer("iss-handler-delete")
+2 -1
View File
@@ -28,7 +28,8 @@ func newTestJobService(jobRepo *mockJobRepo) *JobService {
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)} 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) deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
return NewJobService(jobRepo, renewalService, deploymentService, logger) return NewJobService(jobRepo, renewalService, deploymentService, logger)
+6 -6
View File
@@ -29,7 +29,7 @@ type RenewalService struct {
targetRepo repository.TargetRepository targetRepo repository.TargetRepository
auditService *AuditService auditService *AuditService
notificationSvc *NotificationService notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector issuerRegistry *IssuerRegistry
keygenMode string // "agent" (default) or "server" (demo only) keygenMode string // "agent" (default) or "server" (demo only)
} }
@@ -101,7 +101,7 @@ func NewRenewalService(
profileRepo repository.CertificateProfileRepository, profileRepo repository.CertificateProfileRepository,
auditService *AuditService, auditService *AuditService,
notificationSvc *NotificationService, notificationSvc *NotificationService,
issuerRegistry map[string]IssuerConnector, issuerRegistry *IssuerRegistry,
keygenMode string, keygenMode string,
) *RenewalService { ) *RenewalService {
if keygenMode == "" { if keygenMode == "" {
@@ -169,7 +169,7 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds) s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
// Only create renewal job if an issuer connector is registered for this cert's issuer // 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 { if !hasIssuer {
continue continue
} }
@@ -347,7 +347,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
return fmt.Errorf("certificate has no issuer assigned") return fmt.Errorf("certificate has no issuer assigned")
} }
_, ok := s.issuerRegistry[issuerID] _, ok := s.issuerRegistry.Get(issuerID)
if !ok { if !ok {
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID)) s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
return fmt.Errorf("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. // 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. // 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 { 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 // Generate server-side RSA key + CSR
privKey, err := rsa.GenerateKey(rand.Reader, 2048) 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), // It signs the CSR via the issuer connector, stores the cert version (without private key),
// completes the renewal job, and creates deployment jobs. // completes the renewal job, and creates deployment jobs.
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error { 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 { if !ok {
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID)) 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) return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
+32 -46
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,9 +27,8 @@ func TestCheckExpiringCertificates_SendsThresholdAlerts(t *testing.T) {
"Email": notifier, "Email": notifier,
}) })
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -108,9 +108,8 @@ func TestCheckExpiringCertificates_DeduplicatesAlerts(t *testing.T) {
"Email": notifier, "Email": notifier,
}) })
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -188,9 +187,8 @@ func TestCheckExpiringCertificates_SkipsRenewalInProgress(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -253,9 +251,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpiring(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -315,9 +312,8 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpired(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -377,9 +373,8 @@ func TestCheckExpiringCertificates_CreatesRenewalJob(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") 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{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// Empty issuer registry // Empty issuer registry
issuerRegistry := map[string]IssuerConnector{} issuerRegistry := NewIssuerRegistry(slog.Default())
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -505,9 +500,8 @@ func TestCheckExpiringCertificates_SkipsDuplicateJobs(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -589,9 +583,8 @@ func TestProcessRenewalJob(t *testing.T) {
}) })
issuerConnector := &mockIssuerConnector{} issuerConnector := &mockIssuerConnector{}
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": issuerConnector, issuerRegistry.Set("iss-test", issuerConnector)
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") 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"), Err: fmt.Errorf("issuer service unavailable"),
} }
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": issuerConnector, issuerRegistry.Set("iss-test", issuerConnector)
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -767,9 +759,8 @@ func TestRetryFailedJobs(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -832,9 +823,8 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") 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), SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
}, },
} }
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-acme": ariConnector, issuerRegistry.Set("iss-acme", ariConnector)
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") 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), SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
}, },
} }
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-acme": ariConnector, issuerRegistry.Set("iss-acme", ariConnector)
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") 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{}) notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI returns nil (issuer doesn't support ARI) — default mock behavior // ARI returns nil (issuer doesn't support ARI) — default mock behavior
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-local": &mockIssuerConnector{}, issuerRegistry.Set("iss-local", &mockIssuerConnector{})
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
@@ -1090,9 +1077,8 @@ func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
ariConnector := &mockIssuerConnector{ ariConnector := &mockIssuerConnector{
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"), getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
} }
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-acme": ariConnector, issuerRegistry.Set("iss-acme", ariConnector)
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
+3 -3
View File
@@ -17,7 +17,7 @@ type RevocationSvc struct {
revocationRepo repository.RevocationRepository revocationRepo repository.RevocationRepository
auditService *AuditService auditService *AuditService
notificationSvc *NotificationService notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector issuerRegistry *IssuerRegistry
} }
// NewRevocationSvc creates a new revocation service. // 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. // 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 s.issuerRegistry = registry
} }
@@ -110,7 +110,7 @@ func (s *RevocationSvc) RevokeCertificateWithActor(ctx context.Context, certID s
// 5. Notify the issuer connector (best-effort) // 5. Notify the issuer connector (best-effort)
if s.issuerRegistry != nil { 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 { if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
slog.Error("failed to notify issuer of revocation", slog.Error("failed to notify issuer of revocation",
"error", err, "error", err,
+4 -3
View File
@@ -4,6 +4,7 @@ package service
import ( import (
"context" "context"
"log/slog"
"testing" "testing"
"time" "time"
@@ -18,9 +19,9 @@ func newRevocationSvcTest() (*RevocationSvc, *mockCertRepo, *mockRevocationRepo,
auditService := NewAuditService(auditRepo) auditService := NewAuditService(auditRepo)
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService) revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
revSvc.SetIssuerRegistry(map[string]IssuerConnector{ registry := NewIssuerRegistry(slog.Default())
"iss-local": &mockIssuerConnector{}, registry.Set("iss-local", &mockIssuerConnector{})
}) revSvc.SetIssuerRegistry(registry)
return revSvc, certRepo, revocationRepo, auditRepo return revSvc, certRepo, revocationRepo, auditRepo
} }
+8 -9
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"log/slog"
"testing" "testing"
"time" "time"
@@ -21,15 +22,13 @@ func newRevocationTestService() (*CertificateService, *mockCertRepo, *mockRevoca
// Create RevocationSvc // Create RevocationSvc
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService) revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
revSvc.SetIssuerRegistry(map[string]IssuerConnector{ registry := NewIssuerRegistry(slog.Default())
"iss-local": &mockIssuerConnector{}, registry.Set("iss-local", &mockIssuerConnector{})
}) revSvc.SetIssuerRegistry(registry)
// Create CAOperationsSvc // Create CAOperationsSvc
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo) caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
caSvc.SetIssuerRegistry(map[string]IssuerConnector{ caSvc.SetIssuerRegistry(registry)
"iss-local": &mockIssuerConnector{},
})
certService := NewCertificateService(certRepo, policyService, auditService) certService := NewCertificateService(certRepo, policyService, auditService)
certService.SetRevocationSvc(revSvc) certService.SetRevocationSvc(revSvc)
@@ -243,9 +242,9 @@ func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
// Wire up issuer registry on RevocationSvc with mock // Wire up issuer registry on RevocationSvc with mock
mockIssuer := &mockIssuerConnector{} mockIssuer := &mockIssuerConnector{}
svc.revSvc.SetIssuerRegistry(map[string]IssuerConnector{ registry := NewIssuerRegistry(slog.Default())
"iss-local": mockIssuer, registry.Set("iss-local", mockIssuer)
}) svc.revSvc.SetIssuerRegistry(registry)
cert := &domain.ManagedCertificate{ cert := &domain.ManagedCertificate{
ID: "cert-7", ID: "cert-7",
+7 -9
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"testing" "testing"
"time" "time"
@@ -18,9 +19,8 @@ func setupShortLivedTestService(
) *RenewalService { ) *RenewalService {
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService( svc := NewRenewalService(
certRepo, certRepo,
@@ -137,9 +137,8 @@ func TestExpireShortLivedCertificates_ListError(t *testing.T) {
// Create the service manually to use our custom cert repo // Create the service manually to use our custom cert repo
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService( svc := NewRenewalService(
customCertRepo, customCertRepo,
@@ -385,9 +384,8 @@ func TestExpireShortLivedCertificates_NoProfileRepository(t *testing.T) {
} }
auditSvc := NewAuditService(auditRepo) auditSvc := NewAuditService(auditRepo)
issuerRegistry := map[string]IssuerConnector{ issuerRegistry := NewIssuerRegistry(slog.Default())
"iss-test": &mockIssuerConnector{}, issuerRegistry.Set("iss-test", &mockIssuerConnector{})
}
svc := NewRenewalService( svc := NewRenewalService(
certRepo, certRepo,
+11
View File
@@ -856,6 +856,17 @@ func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer
return nil 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 { func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
if m.DeleteErr != nil { if m.DeleteErr != nil {
return m.DeleteErr return m.DeleteErr
+5
View File
@@ -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;
+16
View File
@@ -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';
+6
View File
@@ -142,6 +142,12 @@ export interface Issuer {
status: string; status: string;
/** Backend returns enabled boolean; status is derived from this */ /** Backend returns enabled boolean; status is derived from this */
enabled: boolean; 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; created_at: string;
updated_at?: string; updated_at?: string;
} }
+17
View File
@@ -45,6 +45,7 @@ export default function IssuerDetailPage() {
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: () => testIssuerConnection(id!), mutationFn: () => testIssuerConnection(id!),
onSuccess: () => refetch(),
}); });
if (error) { if (error) {
@@ -128,6 +129,22 @@ export default function IssuerDetailPage() {
<InfoRow label="Name" value={issuer.name} /> <InfoRow label="Name" value={issuer.name} />
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} /> <InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} /> <InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
<InfoRow label="Source" value={
<span className={`text-xs px-2 py-0.5 rounded-full ${
issuer.source === 'env' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
}`}>
{issuer.source === 'env' ? 'Environment Variable' : 'GUI Configured'}
</span>
} />
<InfoRow label="Connection Test" value={
issuer.test_status === 'success' ? (
<span className="text-xs text-emerald-600 font-medium">Passed {issuer.last_tested_at ? formatDateTime(issuer.last_tested_at) : ''}</span>
) : issuer.test_status === 'failed' ? (
<span className="text-xs text-red-600 font-medium">Failed {issuer.last_tested_at ? formatDateTime(issuer.last_tested_at) : ''}</span>
) : (
<span className="text-xs text-ink-faint">Not tested</span>
)
} />
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} /> <InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
</div> </div>