mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
02438ad9e1
Twelve findings from the architecture diligence audit's Phase 3 bundle
closed in one PR. All touch the CI workflows + small doc-drift fixes
across the production Go tree + migration headers.
CI workflow changes
====================
TEST-H1 — Race detection on ./... -short
.github/workflows/ci.yml:106 was a 9-package explicit list. Audit
finding TEST-H1 flagged that 25+ packages (internal/auth/*,
internal/repository/*, internal/mcp, internal/scep, internal/pkcs7,
internal/api/router, internal/api/acme, internal/cli, internal/cms,
internal/config, internal/deploy, internal/integration,
internal/ratelimit, internal/secret, internal/trustanchor, all of
cmd/) silently dropped off race coverage.
Post-fix: 'go test -race -short ./... -count=1 -timeout 600s'.
76 testing.Short() guards already cover testcontainers + live-DB
integration suites, so -short keeps the long-running tests out.
TEST-H2 — Cross-platform build matrix
New 'cross-platform-build' job in ci.yml. Matrix:
ubuntu-latest + windows-latest + macos-latest, fail-fast: false.
Builds cmd/server + cmd/agent + cmd/cli + cmd/mcp-server on each.
Catches Windows-specific regressions (path separators, file
permissions, exec.Command semantics) the pre-Phase-3 Ubuntu-only
CI missed.
TEST-L1 — actions/setup-go cache: true (explicit)
setup-go v5 defaults cache: true; making it explicit so a future
setup-go upgrade can't silently flip it. Re-runs hit the Go module
+ build cache instead of recompiling cold.
TEST-M1 — Mutation-testing floor at 55%
security-deep-scan.yml::go-mutesting step rewritten. Removed
continue-on-error + per-package '|| true'. New post-loop check
extracts every 'The mutation score is X.YZ' line and fails the
step if any package drops below 0.55. Floor rationale: starter
ratio catches major regressions without rejecting the audit's
'this is OK' steady state; raise quarterly.
TEST-M2 — 3 advisory deep-scan gates promoted to blocking
Removed continue-on-error: true from:
- gosec (filtered to G201/G202/G304/G108 high-signal rules:
SQL-injection + path-traversal + pprof-exposed)
- osv-scanner (multi-ecosystem CVE; complements govulncheck
which is already blocking in ci.yml)
- trivy image scan (--severity HIGH,CRITICAL --exit-code 1)
continue-on-error count: 15 → 11.
ZAP / schemathesis / nuclei / testssl stay advisory because their
false-positive rates on https://localhost:8443-targeted DAST runs
are high.
TEST-M3 — Playwright harness stub
web/package.json adds '@playwright/test' devDep + 'e2e' / 'e2e:install'
npm scripts. web/playwright.config.ts ships single chromium project
with webServer block pointing at 'npm run dev'. web/src/__tests__/
e2e/smoke.spec.ts proves the harness wires through. The full 15-flow
suite ships in frontend-design-audit Phase 8 (TEST-H1 in THAT audit);
this is the wiring + a single smoke test as the regression floor.
New Makefile target: 'make e2e-test'.
Doc/code drift fixes
====================
TEST-M4 + ARCH-L2 — Skip inventory artifact + CI guard
scripts/skip-inventory.sh walks every t.Skip site under cmd/ +
internal/ + deploy/test/ and emits docs/testing/skip-inventory.md
grouped by package with file:line:expression triples. Current
inventory: 142 t.Skip sites, 76 testing.Short() guards.
scripts/ci-guards/skip-inventory-drift.sh regenerates and fails on
diff (excluding the 'Last reviewed' timestamp line which drifts
daily). The Markdown is the canonical acquisition-diligence artifact
for 'what tests are being skipped and why.'
ARCH-H3 — MCP catalogue floor reconciliation
Audit framing was '121 vs floor 150 — doc/code drift.' Live count
via the test's actual regex over all 5 tool files (tools.go +
tools_audit_fix.go + tools_auth.go + tools_auth_bundle2.go +
tools_est.go): 155 unique 'Name: "certctl_*"' declarations.
Pre-Phase-3 audit measured tools.go in isolation (121) and missed
the other 4 files (+34 unique names). The test at
internal/ciparity/surface_parity_test.go::TestSurfaceParity_MCP
passes today (155 ≥ 150). Added a clarifying comment near
mcpBaselineFloor explaining the measurement scope so future
reviewers don't repeat the audit's framing error.
STATUS: stale — no code drift, just a measurement scoping error in
the audit.
ARCH-L1 — panic() rationale comments
5 panic sites in production Go (excluding _test.go):
- internal/repository/postgres/tx.go:84
- internal/service/issuer.go:861 (mustJSON)
- internal/service/est.go:728 (mustParseTime)
- internal/service/acme.go:1288 (rand source failure — already documented)
- internal/pkcs7/certrep.go:270 (OID marshal — already documented)
Added ARCH-L1 rationale comments to the 3 sites that didn't have
them. All 5 are defensible impossible-path / rethrow / hardcoded-
constant guards.
ARCH-L3 — Migration IF-NOT-EXISTS carve-outs
4 migrations skip the literal 'IF NOT EXISTS' token but ARE
idempotent via different Postgres patterns:
- 000014_policy_violation_severity_check.up.sql: ALTER TABLE
ADD CONSTRAINT CHECK doesn't accept IF NOT EXISTS; idempotency
via DROP CONSTRAINT IF EXISTS preamble.
- 000018_audit_events_worm.up.sql: CREATE OR REPLACE FUNCTION
+ DROP TRIGGER IF EXISTS + CREATE TRIGGER + DO $$ pg_roles
existence check. CREATE TRIGGER doesn't take IF NOT EXISTS.
- 000030_rbac_admin_perms.up.sql: INSERT ... ON CONFLICT DO NOTHING.
- 000039_audit_crit1_perms.up.sql: same INSERT + ON CONFLICT pattern.
Added ARCH-L3 header comments to each explaining the carve-out so
reviewers don't flag the missing literal token.
STATUS: largely stale — migrations are already idempotent.
ARCH-L4 — TODO/FIXME → see #<descriptor>
5 TODOs rewritten to the allowed 'see #<descriptor>' pattern:
- internal/repository/postgres/auth.go:220 → see #bundle-2-scope-fk
- internal/connector/discovery/gcpsm/gcpsm.go:547 → see #gcpsm-pagination
- internal/service/audit.go:244 → see #audit-pagination-count
- internal/service/job.go:295, 299 → see #validation-job-impl
New CI guard scripts/ci-guards/no-todo-in-prod.sh grep-fails any
new TODO/FIXME in cmd/ + internal/ (excluding _test.go); allows
'see #N' / 'see #<descriptor>' patterns.
Sandbox limitation
==================
The 6.1 GB certctl working tree fills the sandbox volume; go1.25.10
toolchain download fails with 'no space left on device' (sandbox has
1.25.9; go.mod requires 1.25.10). Local 'go test' / 'go build' NOT
run in this commit. Operator must run 'make verify' on their
workstation before push per CLAUDE.md operating rules.
The smoke.spec.ts NOT executed in the sandbox (no chromium installed).
Operator runs 'cd web && npm install && npx playwright install
--with-deps chromium && npm run e2e' on first wire-up.
All CI guards (no-todo-in-prod, skip-inventory-drift, G-3
env-docs-drift, doc-rot-detector, and every existing guard) verified
clean by running each individually.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-TEST-H1,
cowork/certctl-architecture-diligence-audit.html#fix-TEST-H2,
cowork/certctl-architecture-diligence-audit.html#fix-TEST-M1,
cowork/certctl-architecture-diligence-audit.html#fix-TEST-M2,
cowork/certctl-architecture-diligence-audit.html#fix-TEST-M3,
cowork/certctl-architecture-diligence-audit.html#fix-TEST-M4,
cowork/certctl-architecture-diligence-audit.html#fix-TEST-L1,
cowork/certctl-architecture-diligence-audit.html#fix-ARCH-H3,
cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L1,
cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L2,
cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L3,
cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L4
872 lines
27 KiB
Go
872 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.RecordEventWithCategory(ctx, actor, domain.ActorTypeUser, "create_issuer", domain.EventCategoryConfig, "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.RecordEventWithCategory(ctx, actor, domain.ActorTypeUser, "update_issuer", domain.EventCategoryConfig, "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.RecordEventWithCategory(ctx, actor, domain.ActorTypeUser, "delete_issuer", domain.EventCategoryConfig, "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).
|
|
//
|
|
// ARCH-L1: panic is correct because mustJSON is invoked only on
|
|
// compile-time-known seed structs — json.Marshal never returns an
|
|
// error for plain struct shapes. A failure here means an upstream
|
|
// type-system mismatch the caller couldn't have caught at build time,
|
|
// which is a programmer bug worth surfacing immediately rather than
|
|
// silently producing malformed seed JSON.
|
|
func mustJSON(v interface{}) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("mustJSON: %v", err))
|
|
}
|
|
return json.RawMessage(b)
|
|
}
|