mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 10:49:22 +00:00
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:
+585
-42
@@ -2,31 +2,54 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuerfactory"
|
||||
"github.com/shankar0123/certctl/internal/crypto"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// sensitiveKeys are config key substrings that should be redacted in API responses.
|
||||
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
|
||||
|
||||
// IssuerService provides business logic for certificate issuer management.
|
||||
type IssuerService struct {
|
||||
issuerRepo repository.IssuerRepository
|
||||
auditService *AuditService
|
||||
issuerRepo repository.IssuerRepository
|
||||
auditService *AuditService
|
||||
registry *IssuerRegistry
|
||||
encryptionKey []byte
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewIssuerService creates a new issuer service.
|
||||
func NewIssuerService(
|
||||
issuerRepo repository.IssuerRepository,
|
||||
auditService *AuditService,
|
||||
registry *IssuerRegistry,
|
||||
encryptionKey []byte,
|
||||
logger *slog.Logger,
|
||||
) *IssuerService {
|
||||
return &IssuerService{
|
||||
issuerRepo: issuerRepo,
|
||||
auditService: auditService,
|
||||
issuerRepo: issuerRepo,
|
||||
auditService: auditService,
|
||||
registry: registry,
|
||||
encryptionKey: encryptionKey,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRegistry returns the dynamic issuer registry.
|
||||
func (s *IssuerService) GetRegistry() *IssuerRegistry {
|
||||
return s.registry
|
||||
}
|
||||
|
||||
// List returns a paginated list of issuers.
|
||||
func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) {
|
||||
if page < 1 {
|
||||
@@ -61,49 +84,112 @@ func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, err
|
||||
return issuer, nil
|
||||
}
|
||||
|
||||
// Create validates and stores a new issuer.
|
||||
func (s *IssuerService) Create(ctx context.Context, issuer *domain.Issuer, actor string) error {
|
||||
if issuer.Name == "" {
|
||||
// validIssuerTypes is the set of allowed issuer types for validation.
|
||||
var validIssuerTypes = map[domain.IssuerType]bool{
|
||||
domain.IssuerTypeACME: true,
|
||||
domain.IssuerTypeGenericCA: true,
|
||||
domain.IssuerTypeStepCA: true,
|
||||
domain.IssuerTypeOpenSSL: true,
|
||||
domain.IssuerTypeVault: true,
|
||||
domain.IssuerTypeDigiCert: true,
|
||||
domain.IssuerTypeSectigo: true,
|
||||
domain.IssuerTypeGoogleCAS: true,
|
||||
}
|
||||
|
||||
// isValidIssuerType checks if a type string is a known issuer type.
|
||||
func isValidIssuerType(t domain.IssuerType) bool {
|
||||
return validIssuerTypes[t]
|
||||
}
|
||||
|
||||
// Create validates and stores a new issuer, encrypting sensitive config.
|
||||
func (s *IssuerService) Create(ctx context.Context, iss *domain.Issuer, actor string) error {
|
||||
if iss.Name == "" {
|
||||
return fmt.Errorf("issuer name is required")
|
||||
}
|
||||
if !isValidIssuerType(iss.Type) {
|
||||
return fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
||||
}
|
||||
|
||||
if issuer.ID == "" {
|
||||
issuer.ID = generateID("issuer")
|
||||
if iss.ID == "" {
|
||||
iss.ID = generateID("issuer")
|
||||
}
|
||||
now := time.Now()
|
||||
if issuer.CreatedAt.IsZero() {
|
||||
issuer.CreatedAt = now
|
||||
if iss.CreatedAt.IsZero() {
|
||||
iss.CreatedAt = now
|
||||
}
|
||||
if issuer.UpdatedAt.IsZero() {
|
||||
issuer.UpdatedAt = now
|
||||
if iss.UpdatedAt.IsZero() {
|
||||
iss.UpdatedAt = now
|
||||
}
|
||||
if err := s.issuerRepo.Create(ctx, issuer); err != nil {
|
||||
if iss.TestStatus == "" {
|
||||
iss.TestStatus = "untested"
|
||||
}
|
||||
if iss.Source == "" {
|
||||
iss.Source = "database"
|
||||
}
|
||||
|
||||
// Encrypt the full config and store redacted version in config column
|
||||
if len(iss.Config) > 0 {
|
||||
encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt config: %w", err)
|
||||
}
|
||||
iss.EncryptedConfig = encrypted
|
||||
iss.Config = redactConfigJSON(iss.Config)
|
||||
}
|
||||
|
||||
if err := s.issuerRepo.Create(ctx, iss); err != nil {
|
||||
return fmt.Errorf("failed to create issuer: %w", err)
|
||||
}
|
||||
|
||||
// Add to dynamic registry
|
||||
if iss.Enabled {
|
||||
s.rebuildRegistryQuiet(ctx)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", issuer.ID, nil); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_issuer", "issuer", iss.ID, nil); auditErr != nil {
|
||||
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing issuer.
|
||||
func (s *IssuerService) Update(ctx context.Context, id string, issuer *domain.Issuer, actor string) error {
|
||||
if issuer.Name == "" {
|
||||
// Update modifies an existing issuer. Handles "********" preservation for sensitive fields.
|
||||
func (s *IssuerService) Update(ctx context.Context, id string, iss *domain.Issuer, actor string) error {
|
||||
if iss.Name == "" {
|
||||
return fmt.Errorf("issuer name is required")
|
||||
}
|
||||
|
||||
issuer.ID = id
|
||||
if err := s.issuerRepo.Update(ctx, issuer); err != nil {
|
||||
iss.ID = id
|
||||
iss.UpdatedAt = time.Now()
|
||||
|
||||
// If config contains "********" values, merge with existing decrypted config
|
||||
if len(iss.Config) > 0 {
|
||||
mergedConfig, err := s.mergeRedactedConfig(ctx, id, iss.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the merged config
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||
if encErr != nil {
|
||||
return fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||
}
|
||||
iss.EncryptedConfig = encrypted
|
||||
iss.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||
}
|
||||
|
||||
if err := s.issuerRepo.Update(ctx, iss); err != nil {
|
||||
return fmt.Errorf("failed to update issuer %s: %w", id, err)
|
||||
}
|
||||
|
||||
// Rebuild registry after update
|
||||
s.rebuildRegistryQuiet(ctx)
|
||||
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_issuer", "issuer", id, nil); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,27 +202,48 @@ func (s *IssuerService) Delete(ctx context.Context, id string, actor string) err
|
||||
return fmt.Errorf("failed to delete issuer %s: %w", id, err)
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
if s.registry != nil {
|
||||
s.registry.Remove(id)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_issuer", "issuer", id, nil); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnectionWithContext verifies the issuer connection with context.
|
||||
// TestConnectionWithContext tests the connection to an issuer by instantiating a throwaway
|
||||
// connector and calling ValidateConfig. Records the result in the database.
|
||||
func (s *IssuerService) TestConnectionWithContext(ctx context.Context, id string) error {
|
||||
issuer, err := s.issuerRepo.Get(ctx, id)
|
||||
iss, err := s.issuerRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuer not found: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Implement actual connection test based on issuer type
|
||||
if issuer == nil {
|
||||
return fmt.Errorf("issuer not found")
|
||||
// Get the decrypted config
|
||||
configJSON, err := s.getDecryptedConfig(iss)
|
||||
if err != nil {
|
||||
s.updateTestStatus(ctx, iss, "failed")
|
||||
return fmt.Errorf("failed to decrypt config: %w", err)
|
||||
}
|
||||
|
||||
// Instantiate a throwaway connector and validate
|
||||
connector, err := issuerfactory.NewFromConfig(string(iss.Type), configJSON, s.logger)
|
||||
if err != nil {
|
||||
s.updateTestStatus(ctx, iss, "failed")
|
||||
return fmt.Errorf("failed to create connector: %w", err)
|
||||
}
|
||||
|
||||
if err := connector.ValidateConfig(ctx, configJSON); err != nil {
|
||||
s.updateTestStatus(ctx, iss, "failed")
|
||||
return fmt.Errorf("connection test failed: %w", err)
|
||||
}
|
||||
|
||||
s.updateTestStatus(ctx, iss, "success")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -145,6 +252,241 @@ func (s *IssuerService) TestConnection(id string) error {
|
||||
return s.TestConnectionWithContext(context.Background(), id)
|
||||
}
|
||||
|
||||
// BuildRegistry loads all enabled issuers from the database and rebuilds the dynamic registry.
|
||||
// Called at server startup. Partial failures (individual issuers failing to load) are logged
|
||||
// as warnings but don't prevent the server from starting.
|
||||
func (s *IssuerService) BuildRegistry(ctx context.Context) error {
|
||||
issuers, err := s.issuerRepo.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load issuers from database: %w", err)
|
||||
}
|
||||
|
||||
if err := s.registry.Rebuild(issuers, s.encryptionKey); err != nil {
|
||||
// Log the error but don't fail — some issuers loaded successfully.
|
||||
s.logger.Warn("issuer registry rebuilt with errors", "error", err)
|
||||
}
|
||||
|
||||
s.logger.Info("issuer registry built from database", "total_issuers", len(issuers), "registry_size", s.registry.Len())
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedFromEnvVars creates issuer records from environment variables if the database is empty.
|
||||
// Uses ON CONFLICT DO NOTHING so GUI-created configs are never overwritten.
|
||||
func (s *IssuerService) SeedFromEnvVars(ctx context.Context, cfg *config.Config) {
|
||||
// Check if any issuers already exist
|
||||
existing, err := s.issuerRepo.List(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to check existing issuers for env var seeding", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(existing) > 0 {
|
||||
s.logger.Info("issuers already exist in database, skipping env var seeding", "count", len(existing))
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("no issuers in database, seeding from environment variables")
|
||||
|
||||
seeds := s.buildEnvVarSeeds(cfg)
|
||||
seeded := 0
|
||||
for _, seed := range seeds {
|
||||
// Encrypt the config if key is set
|
||||
if len(seed.Config) > 0 {
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet([]byte(seed.Config), s.encryptionKey)
|
||||
if encErr != nil {
|
||||
s.logger.Error("failed to encrypt seed config", "id", seed.ID, "error", encErr)
|
||||
continue
|
||||
}
|
||||
seed.EncryptedConfig = encrypted
|
||||
seed.Config = redactConfigJSON(seed.Config)
|
||||
}
|
||||
|
||||
if err := s.issuerRepo.Create(ctx, seed); err != nil {
|
||||
s.logger.Warn("failed to seed issuer from env var", "id", seed.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
seeded++
|
||||
s.logger.Info("seeded issuer from env vars", "id", seed.ID, "type", seed.Type)
|
||||
}
|
||||
|
||||
s.logger.Info("env var seeding complete", "seeded", seeded, "total_seeds", len(seeds))
|
||||
}
|
||||
|
||||
// buildEnvVarSeeds constructs issuer domain objects from the config's env var values.
|
||||
func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
|
||||
now := time.Now()
|
||||
var seeds []*domain.Issuer
|
||||
|
||||
// Local CA (always seeded)
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-local",
|
||||
Name: "Local CA",
|
||||
Type: domain.IssuerTypeGenericCA,
|
||||
Config: mustJSON(map[string]interface{}{"ca_cert_path": cfg.CA.CertPath, "ca_key_path": cfg.CA.KeyPath}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
|
||||
// ACME (always seeded — even with empty directory URL, for demo mode)
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-acme-staging",
|
||||
Name: "ACME Staging",
|
||||
Type: domain.IssuerTypeACME,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"directory_url": cfg.ACME.DirectoryURL,
|
||||
"email": cfg.ACME.Email,
|
||||
"challenge_type": cfg.ACME.ChallengeType,
|
||||
"insecure": cfg.ACME.Insecure,
|
||||
"ari_enabled": cfg.ACME.ARIEnabled,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
|
||||
// ACME prod (same config, different ID for backward compat)
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-acme-prod",
|
||||
Name: "ACME Production",
|
||||
Type: domain.IssuerTypeACME,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"directory_url": cfg.ACME.DirectoryURL,
|
||||
"email": cfg.ACME.Email,
|
||||
"challenge_type": cfg.ACME.ChallengeType,
|
||||
"insecure": cfg.ACME.Insecure,
|
||||
"ari_enabled": cfg.ACME.ARIEnabled,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
|
||||
// Conditional: step-ca — only seed if CERTCTL_STEPCA_URL is set
|
||||
if stepcaURL := getEnvForSeed("CERTCTL_STEPCA_URL"); stepcaURL != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-stepca",
|
||||
Name: "step-ca",
|
||||
Type: domain.IssuerTypeStepCA,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"ca_url": stepcaURL,
|
||||
"root_cert_path": getEnvForSeed("CERTCTL_STEPCA_ROOT_CERT"),
|
||||
"provisioner_name": getEnvForSeed("CERTCTL_STEPCA_PROVISIONER"),
|
||||
"provisioner_key_path": getEnvForSeed("CERTCTL_STEPCA_KEY_PATH"),
|
||||
"provisioner_password": getEnvForSeed("CERTCTL_STEPCA_PASSWORD"),
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Conditional: OpenSSL — only seed if sign script is set
|
||||
if signScript := getEnvForSeed("CERTCTL_OPENSSL_SIGN_SCRIPT"); signScript != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-openssl",
|
||||
Name: "OpenSSL/Custom CA",
|
||||
Type: domain.IssuerTypeOpenSSL,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"sign_script": signScript,
|
||||
"revoke_script": getEnvForSeed("CERTCTL_OPENSSL_REVOKE_SCRIPT"),
|
||||
"crl_script": getEnvForSeed("CERTCTL_OPENSSL_CRL_SCRIPT"),
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Conditional: Vault PKI
|
||||
if cfg.Vault.Addr != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-vault",
|
||||
Name: "Vault PKI",
|
||||
Type: domain.IssuerTypeVault,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"addr": cfg.Vault.Addr,
|
||||
"token": cfg.Vault.Token,
|
||||
"mount": cfg.Vault.Mount,
|
||||
"role": cfg.Vault.Role,
|
||||
"ttl": cfg.Vault.TTL,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Conditional: DigiCert
|
||||
if cfg.DigiCert.APIKey != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-digicert",
|
||||
Name: "DigiCert CertCentral",
|
||||
Type: domain.IssuerTypeDigiCert,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"api_key": cfg.DigiCert.APIKey,
|
||||
"org_id": cfg.DigiCert.OrgID,
|
||||
"product_type": cfg.DigiCert.ProductType,
|
||||
"base_url": cfg.DigiCert.BaseURL,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Conditional: Sectigo
|
||||
if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-sectigo",
|
||||
Name: "Sectigo SCM",
|
||||
Type: domain.IssuerTypeSectigo,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"customer_uri": cfg.Sectigo.CustomerURI,
|
||||
"login": cfg.Sectigo.Login,
|
||||
"password": cfg.Sectigo.Password,
|
||||
"org_id": cfg.Sectigo.OrgID,
|
||||
"cert_type": cfg.Sectigo.CertType,
|
||||
"term": cfg.Sectigo.Term,
|
||||
"base_url": cfg.Sectigo.BaseURL,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Conditional: Google CAS
|
||||
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
||||
seeds = append(seeds, &domain.Issuer{
|
||||
ID: "iss-googlecas",
|
||||
Name: "Google CAS",
|
||||
Type: domain.IssuerTypeGoogleCAS,
|
||||
Config: mustJSON(map[string]interface{}{
|
||||
"project": cfg.GoogleCAS.Project,
|
||||
"location": cfg.GoogleCAS.Location,
|
||||
"ca_pool": cfg.GoogleCAS.CAPool,
|
||||
"credentials": cfg.GoogleCAS.Credentials,
|
||||
"ttl": cfg.GoogleCAS.TTL,
|
||||
}),
|
||||
Enabled: true,
|
||||
Source: "env",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
// ListIssuers returns paginated issuers (handler interface method).
|
||||
func (s *IssuerService) ListIssuers(page, perPage int) ([]domain.Issuer, int64, error) {
|
||||
if page < 1 {
|
||||
@@ -176,33 +518,234 @@ func (s *IssuerService) GetIssuer(id string) (*domain.Issuer, error) {
|
||||
}
|
||||
|
||||
// CreateIssuer creates a new issuer (handler interface method).
|
||||
func (s *IssuerService) CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error) {
|
||||
if issuer.ID == "" {
|
||||
issuer.ID = generateID("issuer")
|
||||
func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) {
|
||||
if !isValidIssuerType(iss.Type) {
|
||||
return nil, fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
||||
}
|
||||
if iss.ID == "" {
|
||||
iss.ID = generateID("issuer")
|
||||
}
|
||||
now := time.Now()
|
||||
if issuer.CreatedAt.IsZero() {
|
||||
issuer.CreatedAt = now
|
||||
if iss.CreatedAt.IsZero() {
|
||||
iss.CreatedAt = now
|
||||
}
|
||||
if issuer.UpdatedAt.IsZero() {
|
||||
issuer.UpdatedAt = now
|
||||
if iss.UpdatedAt.IsZero() {
|
||||
iss.UpdatedAt = now
|
||||
}
|
||||
if err := s.issuerRepo.Create(context.Background(), &issuer); err != nil {
|
||||
if iss.TestStatus == "" {
|
||||
iss.TestStatus = "untested"
|
||||
}
|
||||
if iss.Source == "" {
|
||||
iss.Source = "database"
|
||||
}
|
||||
|
||||
// Encrypt config
|
||||
if len(iss.Config) > 0 {
|
||||
encrypted, _, err := crypto.EncryptIfKeySet([]byte(iss.Config), s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt config: %w", err)
|
||||
}
|
||||
iss.EncryptedConfig = encrypted
|
||||
iss.Config = redactConfigJSON(iss.Config)
|
||||
}
|
||||
|
||||
if err := s.issuerRepo.Create(context.Background(), &iss); err != nil {
|
||||
return nil, fmt.Errorf("failed to create issuer: %w", err)
|
||||
}
|
||||
return &issuer, nil
|
||||
|
||||
// Rebuild registry
|
||||
if iss.Enabled {
|
||||
s.rebuildRegistryQuiet(context.Background())
|
||||
}
|
||||
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// UpdateIssuer modifies an issuer (handler interface method).
|
||||
func (s *IssuerService) UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error) {
|
||||
issuer.ID = id
|
||||
if err := s.issuerRepo.Update(context.Background(), &issuer); err != nil {
|
||||
func (s *IssuerService) UpdateIssuer(id string, iss domain.Issuer) (*domain.Issuer, error) {
|
||||
iss.ID = id
|
||||
iss.UpdatedAt = time.Now()
|
||||
|
||||
// Merge redacted fields with existing config
|
||||
if len(iss.Config) > 0 {
|
||||
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, iss.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||
if encErr != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||
}
|
||||
iss.EncryptedConfig = encrypted
|
||||
iss.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||
}
|
||||
|
||||
if err := s.issuerRepo.Update(context.Background(), &iss); err != nil {
|
||||
return nil, fmt.Errorf("failed to update issuer: %w", err)
|
||||
}
|
||||
return &issuer, nil
|
||||
|
||||
s.rebuildRegistryQuiet(context.Background())
|
||||
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// DeleteIssuer removes an issuer (handler interface method).
|
||||
func (s *IssuerService) DeleteIssuer(id string) error {
|
||||
return s.issuerRepo.Delete(context.Background(), id)
|
||||
if err := s.issuerRepo.Delete(context.Background(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.registry != nil {
|
||||
s.registry.Remove(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// rebuildRegistryQuiet rebuilds the registry, logging errors instead of returning them.
|
||||
func (s *IssuerService) rebuildRegistryQuiet(ctx context.Context) {
|
||||
if s.registry == nil {
|
||||
return
|
||||
}
|
||||
if err := s.BuildRegistry(ctx); err != nil {
|
||||
s.logger.Error("failed to rebuild issuer registry after change", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getDecryptedConfig returns the decrypted config JSON for an issuer.
|
||||
func (s *IssuerService) getDecryptedConfig(iss *domain.Issuer) (json.RawMessage, error) {
|
||||
if len(iss.EncryptedConfig) > 0 {
|
||||
decrypted, err := crypto.DecryptIfKeySet(iss.EncryptedConfig, s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(decrypted), nil
|
||||
}
|
||||
if len(iss.Config) > 0 {
|
||||
return iss.Config, nil
|
||||
}
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
|
||||
// mergeRedactedConfig merges incoming config (which may have "********" values)
|
||||
// with the existing decrypted config so sensitive fields are preserved.
|
||||
func (s *IssuerService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
|
||||
// Parse incoming config
|
||||
var incomingMap map[string]interface{}
|
||||
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "issuer", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
// Check if any values are "********"
|
||||
hasRedacted := false
|
||||
for _, v := range incomingMap {
|
||||
if str, ok := v.(string); ok && str == "********" {
|
||||
hasRedacted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRedacted {
|
||||
return incoming, nil // No redacted values, use incoming as-is
|
||||
}
|
||||
|
||||
// Load existing config to get real values
|
||||
existing, err := s.issuerRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: could not load existing issuer, redacted values will be lost", "issuer", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
existingConfig, err := s.getDecryptedConfig(existing)
|
||||
if err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "issuer", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
var existingMap map[string]interface{}
|
||||
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "issuer", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
// Merge: for each "********" value in incoming, use existing value
|
||||
for k, v := range incomingMap {
|
||||
if str, ok := v.(string); ok && str == "********" {
|
||||
if existingVal, exists := existingMap[k]; exists {
|
||||
incomingMap[k] = existingVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(incomingMap)
|
||||
}
|
||||
|
||||
// updateTestStatus updates the test_status and last_tested_at fields in the database
|
||||
// and records an audit event.
|
||||
func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer, status string) {
|
||||
now := time.Now()
|
||||
iss.TestStatus = status
|
||||
iss.LastTestedAt = &now
|
||||
iss.UpdatedAt = now
|
||||
if err := s.issuerRepo.Update(ctx, iss); err != nil {
|
||||
s.logger.Error("failed to update test status", "issuer", iss.ID, "status", status, "error", err)
|
||||
}
|
||||
|
||||
// Record audit event for connection test
|
||||
if s.auditService != nil {
|
||||
action := "issuer_test_connection_" + status
|
||||
details := map[string]interface{}{"issuer_type": string(iss.Type), "result": status}
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "issuer", iss.ID, details); auditErr != nil {
|
||||
s.logger.Error("failed to record test connection audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// redactConfigJSON replaces sensitive values in a JSON config with "********".
|
||||
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(configJSON, &m); err != nil {
|
||||
return configJSON // Not a JSON object, return as-is
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
if isSensitiveConfigKey(k) {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
m[k] = "********"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redacted, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return json.RawMessage(redacted)
|
||||
}
|
||||
|
||||
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
|
||||
func isSensitiveConfigKey(key string) bool {
|
||||
lower := strings.ToLower(key)
|
||||
for _, s := range sensitiveKeys {
|
||||
if strings.Contains(lower, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getEnvForSeed reads an environment variable for seed data construction.
|
||||
func getEnvForSeed(key string) string {
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
// mustJSON marshals a value to json.RawMessage, panicking on error (for seed data only).
|
||||
func mustJSON(v interface{}) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("mustJSON: %v", err))
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user