mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
865 lines
27 KiB
Go
865 lines
27 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/config"
|
|
"github.com/certctl-io/certctl/internal/connector/issuerfactory"
|
|
"github.com/certctl-io/certctl/internal/crypto"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/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)
|
|
}
|