mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
804a1b05ce
Follow-up to590f654(awsacmpca: replace stub client with AWS SDK v2 implementation). CI's golangci-lint contextcheck rule flagged six violations in awsacmpca_test.go where mustNew/awsacmpca.New were called from test functions that had ctx in scope but didn't thread it through New(). The previous commit used context.Background() inside New() with the rationale that "the audit allows either threading or documenting the limitation"; CI made that choice for us. Threading ctx is the right shape per the audit's stated preference. The fix cascades from awsacmpca.New through issuerfactory.NewFromConfig and IssuerRegistry.Rebuild because the contextcheck rule propagates upward through every caller that has ctx in scope. This commit: - Changes awsacmpca.New(config, logger) to awsacmpca.New(ctx, config, logger). The ctx is passed to buildSDKClient → awsconfig.LoadDefaultConfig so SDK credential chain resolution honors caller deadlines (LoadDefaultConfig may probe IMDS or remote credential sources). The doc-comment on New explains that callers without a useful deadline should pass context.Background() and that the SDK has internal credential-resolution timeouts. - Adds ctx as the first parameter of issuerfactory.NewFromConfig. Currently only the AWSACMPCA branch uses ctx (it's threaded into awsacmpca.New); the other 11 branches accept ctx without using it. This is a contractual change that lets callers thread ctx through without contextcheck warnings, even though most issuer constructors do no ctx-aware work today. - Adds ctx as the first parameter of IssuerRegistry.Rebuild. Rebuild iterates over configs and calls NewFromConfig per issuer; the same ctx flows through every connector instantiation. - Updates the two production call sites in internal/service: - issuer.go:279 (TestIssuer connection test) now passes its method-scoped ctx - issuer.go:303 (BuildRegistry) now passes its method-scoped ctx to Rebuild - Updates 13 test sites in internal/connector/issuerfactory/factory_test.go via a new testCtx() helper that returns context.Background(). Helper is dedicated to this file so contextcheck's "you have a ctx in scope, pass it" rule doesn't fire on test functions that don't otherwise need ctx. - Updates 6 test sites in internal/service/issuer_registry_test.go to pass context.Background() to Rebuild. - Removes the now-stale "// NewFromConfig has no ctx parameter (preserved across all 12 connectors); pass context.Background() ..." comment from the awsacmpca branch in factory.go — that workaround is no longer the design. Verified locally: - gofmt -l . clean - go vet ./... clean - staticcheck ./... clean - golangci-lint run --timeout 5m ./... clean (was failing with 6 contextcheck issues before the cascade; now 0 issues) - go test -short -count=1 across all changed packages green Sandbox couldn't run the existing CI's full make verify due to disk pressure on /sessions and a virtiofs concurrent-open-file ceiling on go mod tidy; operator should run `make verify` on the workstation to confirm. Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #1 (CI follow-up; behavior unchanged from590f654).
865 lines
27 KiB
Go
865 lines
27 KiB
Go
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"
|
|
)
|
|
|
|
// IssuerService provides business logic for certificate issuer management.
|
|
//
|
|
// The encryptionKey field holds the raw passphrase (not a pre-derived 32-byte
|
|
// key). Per-ciphertext salt derivation is performed inside
|
|
// [crypto.EncryptIfKeySet] / [crypto.DecryptIfKeySet] on each call. See M-8
|
|
// in certctl-audit-report.md.
|
|
type IssuerService struct {
|
|
issuerRepo repository.IssuerRepository
|
|
auditService *AuditService
|
|
registry *IssuerRegistry
|
|
encryptionKey string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewIssuerService creates a new issuer service. The encryptionKey is the raw
|
|
// passphrase; it MUST NOT be pre-derived via crypto.DeriveKey (that was the
|
|
// v1 behavior, replaced in M-8 with per-ciphertext random salt).
|
|
func NewIssuerService(
|
|
issuerRepo repository.IssuerRepository,
|
|
auditService *AuditService,
|
|
registry *IssuerRegistry,
|
|
encryptionKey string,
|
|
logger *slog.Logger,
|
|
) *IssuerService {
|
|
return &IssuerService{
|
|
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 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
|
|
issuers, err := s.issuerRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list issuers: %w", err)
|
|
}
|
|
total := int64(len(issuers))
|
|
start := (page - 1) * perPage
|
|
if start >= int(total) {
|
|
return nil, total, nil
|
|
}
|
|
end := start + perPage
|
|
if end > int(total) {
|
|
end = int(total)
|
|
}
|
|
return issuers[start:end], total, nil
|
|
}
|
|
|
|
// Get retrieves an issuer by ID.
|
|
func (s *IssuerService) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
|
issuer, err := s.issuerRepo.Get(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issuer %s: %w", id, err)
|
|
}
|
|
return issuer, nil
|
|
}
|
|
|
|
// 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,
|
|
domain.IssuerTypeAWSACMPCA: true,
|
|
domain.IssuerTypeEntrust: true,
|
|
domain.IssuerTypeGlobalSign: true,
|
|
domain.IssuerTypeEJBCA: true,
|
|
}
|
|
|
|
// issuerTypeAliases maps lowercase and legacy type strings to their canonical
|
|
// domain.IssuerType constants. This allows older frontends and curl users to
|
|
// send case-insensitive type strings (e.g., "acme" instead of "ACME").
|
|
var issuerTypeAliases = map[string]domain.IssuerType{
|
|
"acme": domain.IssuerTypeACME,
|
|
"local": domain.IssuerTypeGenericCA,
|
|
"local_ca": domain.IssuerTypeGenericCA,
|
|
"genericca": domain.IssuerTypeGenericCA,
|
|
"stepca": domain.IssuerTypeStepCA,
|
|
"openssl": domain.IssuerTypeOpenSSL,
|
|
"vaultpki": domain.IssuerTypeVault,
|
|
"digicert": domain.IssuerTypeDigiCert,
|
|
"sectigo": domain.IssuerTypeSectigo,
|
|
"googlecas": domain.IssuerTypeGoogleCAS,
|
|
"awsacmpca": domain.IssuerTypeAWSACMPCA,
|
|
"entrust": domain.IssuerTypeEntrust,
|
|
"globalsign": domain.IssuerTypeGlobalSign,
|
|
"ejbca": domain.IssuerTypeEJBCA,
|
|
}
|
|
|
|
// normalizeIssuerType maps a raw type string to its canonical domain.IssuerType.
|
|
// It first checks exact match in validIssuerTypes (fast path for correctly-cased
|
|
// input), then falls back to case-insensitive alias lookup.
|
|
func normalizeIssuerType(t domain.IssuerType) domain.IssuerType {
|
|
// Fast path: already canonical
|
|
if validIssuerTypes[t] {
|
|
return t
|
|
}
|
|
// Slow path: case-insensitive lookup
|
|
if canonical, ok := issuerTypeAliases[strings.ToLower(string(t))]; ok {
|
|
return canonical
|
|
}
|
|
return t // Return as-is; validation will reject it
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
iss.Type = normalizeIssuerType(iss.Type)
|
|
if !isValidIssuerType(iss.Type) {
|
|
return fmt.Errorf("unsupported issuer type: %s", iss.Type)
|
|
}
|
|
|
|
if iss.ID == "" {
|
|
iss.ID = generateID("issuer")
|
|
}
|
|
now := time.Now()
|
|
if iss.CreatedAt.IsZero() {
|
|
iss.CreatedAt = now
|
|
}
|
|
if iss.UpdatedAt.IsZero() {
|
|
iss.UpdatedAt = now
|
|
}
|
|
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", iss.ID, nil); auditErr != nil {
|
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
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 {
|
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Delete removes an issuer.
|
|
func (s *IssuerService) Delete(ctx context.Context, id string, actor string) error {
|
|
if err := s.issuerRepo.Delete(ctx, id); err != nil {
|
|
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 {
|
|
s.logger.Error("failed to record audit event", "error", auditErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestConnection tests the connection to an issuer by instantiating a throwaway
|
|
// connector and calling ValidateConfig. Records the result in the database.
|
|
func (s *IssuerService) TestConnection(ctx context.Context, id string) error {
|
|
iss, err := s.issuerRepo.Get(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("issuer not found: %w", err)
|
|
}
|
|
|
|
// 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(ctx, 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
|
|
}
|
|
|
|
// 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(ctx, issuers, s.encryptionKey); err != nil {
|
|
// Log the error but don't fail — some issuers loaded successfully.
|
|
s.logger.Warn("issuer registry rebuilt with errors", "error", err)
|
|
}
|
|
|
|
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 only when an encryption key is configured.
|
|
//
|
|
// Env-seeded issuers carry Source="env" and are reconstructable on every
|
|
// boot from process environment, so persisting their config in plaintext
|
|
// adds no new exposure: the same bytes already live in the operator's
|
|
// deployment manifest. When no key is configured we therefore leave
|
|
// EncryptedConfig nil and keep the raw JSON in the `config` column —
|
|
// IssuerRegistry.Rebuild falls through to `cfg.Config` when there is no
|
|
// ciphertext to decrypt, so registry load still works.
|
|
//
|
|
// Database-sourced rows (Source="database") never reach this branch:
|
|
// they are created through the GUI/API write paths, which require the
|
|
// encryption key and fail closed via crypto.ErrEncryptionKeyRequired.
|
|
if len(seed.Config) > 0 && len(s.encryptionKey) > 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,
|
|
"profile": cfg.ACME.Profile,
|
|
"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,
|
|
"profile": cfg.ACME.Profile,
|
|
"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,
|
|
})
|
|
}
|
|
|
|
// Conditional: AWS ACM PCA
|
|
if cfg.AWSACMPCA.CAArn != "" {
|
|
seeds = append(seeds, &domain.Issuer{
|
|
ID: "iss-awsacmpca",
|
|
Name: "AWS ACM Private CA",
|
|
Type: domain.IssuerTypeAWSACMPCA,
|
|
Config: mustJSON(map[string]interface{}{
|
|
"region": cfg.AWSACMPCA.Region,
|
|
"ca_arn": cfg.AWSACMPCA.CAArn,
|
|
"signing_algorithm": cfg.AWSACMPCA.SigningAlgorithm,
|
|
"validity_days": cfg.AWSACMPCA.ValidityDays,
|
|
"template_arn": cfg.AWSACMPCA.TemplateArn,
|
|
}),
|
|
Enabled: true,
|
|
Source: "env",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
// Conditional: Entrust — only seed if API URL is set
|
|
if cfg.Entrust.APIUrl != "" {
|
|
seeds = append(seeds, &domain.Issuer{
|
|
ID: "iss-entrust",
|
|
Name: "Entrust",
|
|
Type: domain.IssuerTypeEntrust,
|
|
Config: mustJSON(map[string]interface{}{
|
|
"api_url": cfg.Entrust.APIUrl,
|
|
"client_cert_path": cfg.Entrust.ClientCertPath,
|
|
"client_key_path": cfg.Entrust.ClientKeyPath,
|
|
"ca_id": cfg.Entrust.CAId,
|
|
"profile_id": cfg.Entrust.ProfileId,
|
|
}),
|
|
Enabled: true,
|
|
Source: "env",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
// Conditional: GlobalSign — only seed if API URL and API key are set
|
|
if cfg.GlobalSign.APIUrl != "" && cfg.GlobalSign.APIKey != "" {
|
|
globalSignConfig := map[string]interface{}{
|
|
"api_url": cfg.GlobalSign.APIUrl,
|
|
"api_key": cfg.GlobalSign.APIKey,
|
|
"api_secret": cfg.GlobalSign.APISecret,
|
|
"client_cert_path": cfg.GlobalSign.ClientCertPath,
|
|
"client_key_path": cfg.GlobalSign.ClientKeyPath,
|
|
}
|
|
if cfg.GlobalSign.ServerCAPath != "" {
|
|
globalSignConfig["server_ca_path"] = cfg.GlobalSign.ServerCAPath
|
|
}
|
|
seeds = append(seeds, &domain.Issuer{
|
|
ID: "iss-globalsign",
|
|
Name: "GlobalSign Atlas",
|
|
Type: domain.IssuerTypeGlobalSign,
|
|
Config: mustJSON(globalSignConfig),
|
|
Enabled: true,
|
|
Source: "env",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
// Conditional: EJBCA — only seed if API URL and CA name are set
|
|
if cfg.EJBCA.APIUrl != "" && cfg.EJBCA.CAName != "" {
|
|
seeds = append(seeds, &domain.Issuer{
|
|
ID: "iss-ejbca",
|
|
Name: "EJBCA",
|
|
Type: domain.IssuerTypeEJBCA,
|
|
Config: mustJSON(map[string]interface{}{
|
|
"api_url": cfg.EJBCA.APIUrl,
|
|
"auth_mode": cfg.EJBCA.AuthMode,
|
|
"client_cert_path": cfg.EJBCA.ClientCertPath,
|
|
"client_key_path": cfg.EJBCA.ClientKeyPath,
|
|
"token": cfg.EJBCA.Token,
|
|
"ca_name": cfg.EJBCA.CAName,
|
|
"cert_profile": cfg.EJBCA.CertProfile,
|
|
"ee_profile": cfg.EJBCA.EEProfile,
|
|
}),
|
|
Enabled: true,
|
|
Source: "env",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
return seeds
|
|
}
|
|
|
|
// ListIssuers returns paginated issuers (handler interface method).
|
|
func (s *IssuerService) ListIssuers(ctx context.Context, page, perPage int) ([]domain.Issuer, int64, error) {
|
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
|
// List() does not yet take pagination params. Marked explicitly so
|
|
// ineffassign sees no dead store and future maintainers see the
|
|
// vestigial params rather than a misleading default-applied clamp.
|
|
_ = page
|
|
_ = perPage
|
|
|
|
issuers, err := s.issuerRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("failed to list issuers: %w", err)
|
|
}
|
|
total := int64(len(issuers))
|
|
|
|
var result []domain.Issuer
|
|
for _, i := range issuers {
|
|
if i != nil {
|
|
result = append(result, *i)
|
|
}
|
|
}
|
|
|
|
return result, total, nil
|
|
}
|
|
|
|
// GetIssuer returns a single issuer (handler interface method).
|
|
func (s *IssuerService) GetIssuer(ctx context.Context, id string) (*domain.Issuer, error) {
|
|
return s.issuerRepo.Get(ctx, id)
|
|
}
|
|
|
|
// CreateIssuer creates a new issuer (handler interface method).
|
|
func (s *IssuerService) CreateIssuer(ctx context.Context, iss domain.Issuer) (*domain.Issuer, error) {
|
|
iss.Type = normalizeIssuerType(iss.Type)
|
|
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 iss.CreatedAt.IsZero() {
|
|
iss.CreatedAt = now
|
|
}
|
|
if iss.UpdatedAt.IsZero() {
|
|
iss.UpdatedAt = now
|
|
}
|
|
if iss.TestStatus == "" {
|
|
iss.TestStatus = "untested"
|
|
}
|
|
if iss.Source == "" {
|
|
iss.Source = "database"
|
|
}
|
|
// GUI-created issuers should be enabled by default.
|
|
// Go's bool zero value is false, which overrides the DB default when explicitly inserted.
|
|
if iss.Source == "database" && !iss.Enabled {
|
|
iss.Enabled = true
|
|
}
|
|
|
|
// 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(ctx, &iss); err != nil {
|
|
return nil, fmt.Errorf("failed to create issuer: %w", err)
|
|
}
|
|
|
|
// Rebuild registry
|
|
if iss.Enabled {
|
|
s.rebuildRegistryQuiet(ctx)
|
|
}
|
|
|
|
return &iss, nil
|
|
}
|
|
|
|
// UpdateIssuer modifies an issuer (handler interface method).
|
|
func (s *IssuerService) UpdateIssuer(ctx context.Context, 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(ctx, 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(ctx, &iss); err != nil {
|
|
return nil, fmt.Errorf("failed to update issuer: %w", err)
|
|
}
|
|
|
|
s.rebuildRegistryQuiet(ctx)
|
|
|
|
return &iss, nil
|
|
}
|
|
|
|
// DeleteIssuer removes an issuer (handler interface method).
|
|
func (s *IssuerService) DeleteIssuer(ctx context.Context, id string) error {
|
|
if err := s.issuerRepo.Delete(ctx, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|