feat: M18b Filesystem Certificate Discovery — agent scanning, server dedup, triage API

Agent-side:
- Filesystem scanner walks configured directories (CERTCTL_DISCOVERY_DIRS)
- Parses PEM (.pem, .crt, .cer, .cert) and DER (.der) certificate files
- Extracts CN, SANs, serial, issuer/subject DN, validity, key info, SHA-256 fingerprint
- Reports discoveries to control plane on startup + every 6 hours
- Skips files >1MB and private key files

Server-side:
- Migration 000006: discovered_certificates + discovery_scans tables
- Domain model: DiscoveredCertificate, DiscoveryScan, DiscoveryReport
- Three triage states: Unmanaged, Managed (claimed), Dismissed
- Repository with upsert dedup (fingerprint + agent + path)
- Service layer: process reports, claim, dismiss, list, summary
- 7 new API endpoints (84 total):
  POST /agents/{id}/discoveries, GET /discovered-certificates,
  GET /discovered-certificates/{id}, POST .../claim, POST .../dismiss,
  GET /discovery-scans, GET /discovery-summary
- Audit trail: scan_completed, cert_claimed, cert_dismissed events

Tests: 28 new test functions (domain, handler, service layers)
Docs: README, quickstart, demo-guide, demo-advanced, architecture,
      concepts, connectors, features.md all updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-24 00:25:00 -04:00
parent 8768a7b3ef
commit 667a30870d
23 changed files with 2916 additions and 24 deletions
+15 -3
View File
@@ -10,7 +10,7 @@ certctl is a self-hosted platform for **end-to-end certificate lifecycle automat
## What It Does ## What It Does
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (77 endpoints under `/api/v1/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally and submit CSRs — private keys never leave your servers. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement. certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (84 endpoints under `/api/v1/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates in your infrastructure, and submit CSRs — private keys never leave your servers. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -213,6 +213,7 @@ Agent environment variables:
| `CERTCTL_AGENT_NAME` | `certctl-agent` | Agent display name | | `CERTCTL_AGENT_NAME` | `certctl-agent` | Agent display name |
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) | | `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for storing private keys (agent keygen mode) | | `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for storing private keys (agent keygen mode) |
| `CERTCTL_DISCOVERY_DIRS` | — | Comma-separated directories to scan for existing certificates (e.g., `/etc/nginx/certs,/etc/ssl/certs`) |
Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container. Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container.
@@ -247,7 +248,7 @@ mcp-server
} }
``` ```
76 tools organized by resource: certificates (9), CRL/OCSP (3), issuers (6), targets (5), agents (8), jobs (5), policies (6), profiles (5), teams (5), owners (5), agent groups (6), audit (2), notifications (3), stats (5), metrics (1), health (4). 83 tools organized by resource: certificates (9), CRL/OCSP (3), issuers (6), targets (5), agents (8), discoveries (7), jobs (5), policies (6), profiles (5), teams (5), owners (5), agent groups (6), audit (2), notifications (3), stats (5), metrics (1), health (4).
## CLI ## CLI
@@ -307,6 +308,17 @@ POST /api/v1/agents/{id}/csr Submit CSR for issuance
GET /api/v1/agents/{id}/certificates/{certId} Retrieve signed certificate GET /api/v1/agents/{id}/certificates/{certId} Retrieve signed certificate
GET /api/v1/agents/{id}/work Poll for pending deployment jobs GET /api/v1/agents/{id}/work Poll for pending deployment jobs
POST /api/v1/agents/{id}/jobs/{jobId}/status Report job completion/failure POST /api/v1/agents/{id}/jobs/{jobId}/status Report job completion/failure
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
```
### Certificate Discovery
```
GET /api/v1/discovered-certificates List discovered certificates (?agent_id, ?status)
GET /api/v1/discovered-certificates/{id} Get discovery detail
POST /api/v1/discovered-certificates/{id}/claim Link discovered cert to managed cert
POST /api/v1/discovered-certificates/{id}/dismiss Dismiss discovery
GET /api/v1/discovery-scans List discovery scan history
GET /api/v1/discovery-summary Aggregated discovery status (new, claimed, dismissed counts)
``` ```
### Infrastructure ### Infrastructure
@@ -495,7 +507,7 @@ All nine development milestones (M1M9) are complete. The backend covers the f
- **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout) - **M17: Additional Connectors** ✅ — OpenSSL/Custom CA issuer connector (script-based signing with configurable timeout)
- **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 10 subcommands (list/get/renew/revoke certs, list agents/jobs, health, metrics, PEM bulk import), stdlib-only, JSON/table output - **M16b: CLI + Bulk Import** ✅ — `certctl-cli` with 10 subcommands (list/get/renew/revoke certs, list agents/jobs, health, metrics, PEM bulk import), stdlib-only, JSON/table output
- **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`) - **M20: Enhanced Query API** ✅ — sparse field selection (`?fields=`), sort with direction (`?sort=-notAfter`), time-range filters (`expires_before`, `created_after`, etc.), cursor-based pagination (`?cursor=&page_size=`), `GET /certificates/{id}/deployments`, additional filters (`agent_id`, `profile_id`)
- **M18b: Filesystem Cert Discovery** — agents walk directories, parse PEM/DER/PFX/JKS, report unmanaged certs to control plane - **M18b: Filesystem Cert Discovery** — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation - **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation
### V3: Team & Enterprise ### V3: Team & Enterprise
+275 -2
View File
@@ -6,6 +6,8 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/json" "encoding/json"
@@ -40,10 +42,12 @@ type AgentConfig struct {
AgentID string // Agent ID for API calls (set after registration or from env) AgentID string // Agent ID for API calls (set after registration or from env)
Hostname string // Server hostname Hostname string // Server hostname
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys) KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
DiscoveryDirs []string // Directories to scan for certificates (comma-separated via env)
} }
// Agent represents the local agent that runs on target servers. // Agent represents the local agent that runs on target servers.
// It periodically sends heartbeats, polls for work, and executes deployment and CSR jobs. // It periodically sends heartbeats, polls for work, executes deployment and CSR jobs,
// and scans configured directories for existing certificates.
// In agent keygen mode, private keys are generated and stored locally — they never leave // In agent keygen mode, private keys are generated and stored locally — they never leave
// this process or filesystem. // this process or filesystem.
type Agent struct { type Agent struct {
@@ -54,6 +58,7 @@ type Agent struct {
// Configuration // Configuration
heartbeatInterval time.Duration heartbeatInterval time.Duration
pollInterval time.Duration pollInterval time.Duration
discoveryInterval time.Duration
consecutiveFailures int consecutiveFailures int
} }
@@ -84,6 +89,7 @@ func NewAgent(cfg *AgentConfig, logger *slog.Logger) *Agent {
client: &http.Client{Timeout: 30 * time.Second}, client: &http.Client{Timeout: 30 * time.Second},
heartbeatInterval: 60 * time.Second, heartbeatInterval: 60 * time.Second,
pollInterval: 30 * time.Second, pollInterval: 30 * time.Second,
discoveryInterval: 6 * time.Hour, // scan for certs every 6 hours
} }
} }
@@ -106,7 +112,7 @@ func (a *Agent) Run(ctx context.Context) error {
a.logger.Warn("failed to enforce key directory permissions", "path", a.config.KeyDir, "error", err) a.logger.Warn("failed to enforce key directory permissions", "path", a.config.KeyDir, "error", err)
} }
// Create ticker channels for heartbeat and polling // Create ticker channels for heartbeat, polling, and discovery
heartbeatTicker := time.NewTicker(a.heartbeatInterval) heartbeatTicker := time.NewTicker(a.heartbeatInterval)
defer heartbeatTicker.Stop() defer heartbeatTicker.Stop()
@@ -117,6 +123,22 @@ func (a *Agent) Run(ctx context.Context) error {
a.sendHeartbeat(ctx) a.sendHeartbeat(ctx)
a.pollForWork(ctx) a.pollForWork(ctx)
// Discovery: run initial scan if directories configured, then on interval
var discoveryTicker *time.Ticker
if len(a.config.DiscoveryDirs) > 0 {
a.logger.Info("certificate discovery enabled",
"directories", a.config.DiscoveryDirs,
"interval", a.discoveryInterval.String())
a.runDiscoveryScan(ctx)
discoveryTicker = time.NewTicker(a.discoveryInterval)
defer discoveryTicker.Stop()
} else {
a.logger.Info("certificate discovery disabled (no CERTCTL_DISCOVERY_DIRS configured)")
// Create a stopped ticker so the select compiles
discoveryTicker = time.NewTicker(24 * time.Hour)
discoveryTicker.Stop()
}
// Main event loop // Main event loop
for { for {
select { select {
@@ -139,6 +161,11 @@ func (a *Agent) Run(ctx context.Context) error {
time.Sleep(backoff) time.Sleep(backoff)
} }
a.pollForWork(ctx) a.pollForWork(ctx)
case <-discoveryTicker.C:
if len(a.config.DiscoveryDirs) > 0 {
a.runDiscoveryScan(ctx)
}
} }
} }
} }
@@ -652,6 +679,239 @@ func (a *Agent) makeRequest(ctx context.Context, method, path string, body inter
return resp, nil return resp, nil
} }
// runDiscoveryScan walks configured directories, parses certificate files, and reports
// discovered certificates to the control plane.
// Supports PEM and DER encoded X.509 certificates.
func (a *Agent) runDiscoveryScan(ctx context.Context) {
a.logger.Info("starting filesystem certificate discovery scan",
"directories", a.config.DiscoveryDirs)
startTime := time.Now()
var certs []discoveredCertEntry
var scanErrors []string
for _, dir := range a.config.DiscoveryDirs {
a.logger.Debug("scanning directory", "path", dir)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
scanErrors = append(scanErrors, fmt.Sprintf("walk error at %s: %v", path, err))
return nil // continue walking
}
if info.IsDir() {
return nil
}
// Skip files larger than 1MB (unlikely to be a certificate)
if info.Size() > 1*1024*1024 {
return nil
}
// Check file extension
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".pem", ".crt", ".cer", ".cert":
found := a.parsePEMFile(path)
certs = append(certs, found...)
case ".der":
if entry, err := a.parseDERFile(path); err == nil {
certs = append(certs, entry)
} else {
a.logger.Debug("skipping non-cert DER file", "path", path, "error", err)
}
default:
// Try PEM parsing for extensionless files or unknown extensions
if ext == "" || ext == ".key" {
return nil // skip key files and extensionless
}
found := a.parsePEMFile(path)
if len(found) > 0 {
certs = append(certs, found...)
}
}
return nil
})
if err != nil {
scanErrors = append(scanErrors, fmt.Sprintf("failed to walk %s: %v", dir, err))
}
}
scanDuration := time.Since(startTime)
a.logger.Info("discovery scan completed",
"certificates_found", len(certs),
"errors", len(scanErrors),
"duration_ms", scanDuration.Milliseconds())
if len(certs) == 0 && len(scanErrors) == 0 {
a.logger.Debug("no certificates found and no errors, skipping report")
return
}
// Build report payload
entries := make([]map[string]interface{}, len(certs))
for i, c := range certs {
entries[i] = map[string]interface{}{
"fingerprint_sha256": c.FingerprintSHA256,
"common_name": c.CommonName,
"sans": c.SANs,
"serial_number": c.SerialNumber,
"issuer_dn": c.IssuerDN,
"subject_dn": c.SubjectDN,
"not_before": c.NotBefore,
"not_after": c.NotAfter,
"key_algorithm": c.KeyAlgorithm,
"key_size": c.KeySize,
"is_ca": c.IsCA,
"pem_data": c.PEMData,
"source_path": c.SourcePath,
"source_format": c.SourceFormat,
}
}
report := map[string]interface{}{
"agent_id": a.config.AgentID,
"directories": a.config.DiscoveryDirs,
"certificates": entries,
"errors": scanErrors,
"scan_duration_ms": int(scanDuration.Milliseconds()),
}
// Submit to control plane
path := fmt.Sprintf("/api/v1/agents/%s/discoveries", a.config.AgentID)
resp, err := a.makeRequest(ctx, http.MethodPost, path, report)
if err != nil {
a.logger.Error("failed to submit discovery report", "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(resp.Body)
a.logger.Error("discovery report rejected",
"status", resp.StatusCode,
"body", string(body))
return
}
a.logger.Info("discovery report submitted successfully",
"certificates", len(certs),
"errors", len(scanErrors))
}
// discoveredCertEntry holds parsed certificate metadata for reporting.
type discoveredCertEntry struct {
FingerprintSHA256 string `json:"fingerprint_sha256"`
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
SerialNumber string `json:"serial_number"`
IssuerDN string `json:"issuer_dn"`
SubjectDN string `json:"subject_dn"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
KeyAlgorithm string `json:"key_algorithm"`
KeySize int `json:"key_size"`
IsCA bool `json:"is_ca"`
PEMData string `json:"pem_data"`
SourcePath string `json:"source_path"`
SourceFormat string `json:"source_format"`
}
// parsePEMFile reads a file and extracts all X.509 certificates from PEM blocks.
func (a *Agent) parsePEMFile(path string) []discoveredCertEntry {
data, err := os.ReadFile(path)
if err != nil {
a.logger.Debug("failed to read file", "path", path, "error", err)
return nil
}
var entries []discoveredCertEntry
rest := data
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
a.logger.Debug("failed to parse certificate in PEM", "path", path, "error", err)
continue
}
pemStr := string(pem.EncodeToMemory(block))
entries = append(entries, certToEntry(cert, path, "PEM", pemStr))
}
return entries
}
// parseDERFile reads a DER-encoded certificate file.
func (a *Agent) parseDERFile(path string) (discoveredCertEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
return discoveredCertEntry{}, fmt.Errorf("read failed: %w", err)
}
cert, err := x509.ParseCertificate(data)
if err != nil {
return discoveredCertEntry{}, fmt.Errorf("parse failed: %w", err)
}
// Convert to PEM for storage
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: data}))
return certToEntry(cert, path, "DER", pemStr), nil
}
// certToEntry converts a parsed x509.Certificate into a discoveredCertEntry.
func certToEntry(cert *x509.Certificate, path, format, pemData string) discoveredCertEntry {
// Compute SHA-256 fingerprint
fingerprint := fmt.Sprintf("%x", sha256Sum(cert.Raw))
// Determine key algorithm and size
keyAlg, keySize := certKeyInfo(cert)
return discoveredCertEntry{
FingerprintSHA256: fingerprint,
CommonName: cert.Subject.CommonName,
SANs: cert.DNSNames,
SerialNumber: cert.SerialNumber.Text(16),
IssuerDN: cert.Issuer.String(),
SubjectDN: cert.Subject.String(),
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
KeyAlgorithm: keyAlg,
KeySize: keySize,
IsCA: cert.IsCA,
PEMData: pemData,
SourcePath: path,
SourceFormat: format,
}
}
// sha256Sum returns the SHA-256 hash of data.
func sha256Sum(data []byte) [32]byte {
return sha256.Sum256(data)
}
// certKeyInfo extracts key algorithm name and size from a certificate.
func certKeyInfo(cert *x509.Certificate) (string, int) {
switch pub := cert.PublicKey.(type) {
case *ecdsa.PublicKey:
return "ECDSA", pub.Curve.Params().BitSize
case *rsa.PublicKey:
return "RSA", pub.N.BitLen()
default:
switch cert.PublicKeyAlgorithm {
case x509.Ed25519:
return "Ed25519", 256
default:
return cert.PublicKeyAlgorithm.String(), 0
}
}
}
func main() { func main() {
// Parse command-line flags (with env var fallbacks for Docker deployment) // Parse command-line flags (with env var fallbacks for Docker deployment)
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "http://localhost:8443"), "Control plane server URL") serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "http://localhost:8443"), "Control plane server URL")
@@ -659,6 +919,7 @@ func main() {
agentName := flag.String("name", getEnvDefault("CERTCTL_AGENT_NAME", "certctl-agent"), "Agent name") agentName := flag.String("name", getEnvDefault("CERTCTL_AGENT_NAME", "certctl-agent"), "Agent name")
agentID := flag.String("agent-id", getEnvDefault("CERTCTL_AGENT_ID", ""), "Agent ID (from registration)") agentID := flag.String("agent-id", getEnvDefault("CERTCTL_AGENT_ID", ""), "Agent ID (from registration)")
keyDir := flag.String("key-dir", getEnvDefault("CERTCTL_KEY_DIR", "/var/lib/certctl/keys"), "Directory for storing private keys") keyDir := flag.String("key-dir", getEnvDefault("CERTCTL_KEY_DIR", "/var/lib/certctl/keys"), "Directory for storing private keys")
discoveryDirsStr := flag.String("discovery-dirs", getEnvDefault("CERTCTL_DISCOVERY_DIRS", ""), "Comma-separated directories to scan for certificates")
flag.Parse() flag.Parse()
if *apiKey == "" { if *apiKey == "" {
@@ -687,6 +948,17 @@ func main() {
hostname = "unknown" hostname = "unknown"
} }
// Parse discovery directories
var discoveryDirs []string
if *discoveryDirsStr != "" {
for _, d := range strings.Split(*discoveryDirsStr, ",") {
d = strings.TrimSpace(d)
if d != "" {
discoveryDirs = append(discoveryDirs, d)
}
}
}
// Create agent configuration // Create agent configuration
agentCfg := &AgentConfig{ agentCfg := &AgentConfig{
ServerURL: *serverURL, ServerURL: *serverURL,
@@ -695,6 +967,7 @@ func main() {
AgentID: *agentID, AgentID: *agentID,
Hostname: hostname, Hostname: hostname,
KeyDir: *keyDir, KeyDir: *keyDir,
DiscoveryDirs: discoveryDirs,
} }
// Create and start agent // Create and start agent
+4
View File
@@ -205,6 +205,8 @@ func main() {
ownerService := service.NewOwnerService(ownerRepo, auditService) ownerService := service.NewOwnerService(ownerRepo, auditService)
agentGroupRepo := postgres.NewAgentGroupRepository(db) agentGroupRepo := postgres.NewAgentGroupRepository(db)
agentGroupService := service.NewAgentGroupService(agentGroupRepo, auditService) agentGroupService := service.NewAgentGroupService(agentGroupRepo, auditService)
discoveryRepo := postgres.NewDiscoveryRepository(db)
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
logger.Info("initialized all services") logger.Info("initialized all services")
// Initialize stats and metrics services // Initialize stats and metrics services
@@ -227,6 +229,7 @@ func main() {
statsHandler := handler.NewStatsHandler(statsService) statsHandler := handler.NewStatsHandler(statsService)
metricsHandler := handler.NewMetricsHandler(statsService, time.Now()) metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
healthHandler := handler.NewHealthHandler(cfg.Auth.Type) healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
discoveryHandler := handler.NewDiscoveryHandler(discoveryService)
logger.Info("initialized all handlers") logger.Info("initialized all handlers")
// Create context with cancellation // Create context with cancellation
@@ -272,6 +275,7 @@ func main() {
statsHandler, statsHandler,
metricsHandler, metricsHandler,
healthHandler, healthHandler,
discoveryHandler,
) )
logger.Info("registered all API handlers") logger.Info("registered all API handlers")
+55
View File
@@ -703,6 +703,61 @@ flowchart TB
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.). For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
## Discovery Data Flow (M18b)
Certificate discovery enables operators to build a complete inventory of existing certificates before managing them with certctl. Here's how data flows through the system:
```mermaid
flowchart TB
AGENT["certctl-agent\n(on infrastructure)"]
SCAN["Filesystem Scanner\n(CERTCTL_DISCOVERY_DIRS)"]
EXTRACT["Extract Metadata\n(CN, SANs, serial, issuer, expiry, fingerprint)"]
REPORT["POST /api/v1/agents/{id}/discoveries\n(submit scan results)"]
HANDLER["Discovery Handler\n(parse request)"]
SERVICE["Discovery Service\n(ProcessDiscoveryReport)"]
REPO["Discovery Repository\n(upsert with fingerprint dedup)"]
DB["PostgreSQL\ndiscovered_certificates\ndiscovery_scans tables"]
AUDIT["Audit Service\n(RecordDiscoveryScanCompleted)"]
API_LIST["GET /api/v1/discovered-certificates\n(list for triage)"]
API_CLAIM["POST /discovered-certificates/{id}/claim\n(operator claims cert)"]
API_DISMISS["POST /discovered-certificates/{id}/dismiss\n(operator dismisses)"]
UPDATE_STATUS["Update Status\n(Unmanaged → Managed/Dismissed)"]
AGENT -->|"Scan loop\n(startup + 6h)"| SCAN
SCAN --> EXTRACT
EXTRACT --> REPORT
REPORT --> HANDLER
HANDLER --> SERVICE
SERVICE --> REPO
REPO -->|"Dedup by fingerprint\n+ agent + path"| DB
SERVICE --> AUDIT
AUDIT -->|"discovery_scan_completed"| DB
DB -->|"query unmanaged"| API_LIST
API_LIST -->|"operator reviews"| API_CLAIM
API_LIST -->|"operator reviews"| API_DISMISS
API_CLAIM --> UPDATE_STATUS
API_DISMISS --> UPDATE_STATUS
UPDATE_STATUS -->|"RecordDiscoveryCertClaimed\nRecordDiscoveryCertDismissed"| AUDIT
AUDIT --> DB
```
**Key steps:**
1. **Agent-side discovery** — Agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, walking directories recursively and parsing PEM/DER files
2. **Metadata extraction** — For each certificate found, extract: common name, SANs, serial number, issuer DN, subject DN, expiration date, key algorithm, key size, is_ca flag, SHA-256 fingerprint (used as dedup key)
3. **Server submission** — Agent POSTs scan results as `DiscoveryReport` to `POST /api/v1/agents/{id}/discoveries`
4. **Deduplication** — Server uses fingerprint + agent ID + filesystem path as unique key; prevents duplicate records of the same cert on the same agent
5. **Storage** — Records stored in `discovered_certificates` table with status = "Unmanaged"
6. **Audit**`discovery_scan_completed` event logged with agent ID, cert count, scan timestamp
7. **Operator triage** — Operator queries `GET /api/v1/discovered-certificates?status=Unmanaged` to see new findings
8. **Claim or dismiss** — For each unmanaged cert, operator either:
- **Claims it** via `POST /discovered-certificates/{id}/claim` — links to existing managed cert or creates new enrollment
- **Dismisses it** via `POST /discovered-certificates/{id}/dismiss` — removes from triage, marked as "Dismissed"
9. **Status tracking**`discovery_cert_claimed` and `discovery_cert_dismissed` events audit the operator's decision
10. **Summary**`GET /api/v1/discovery-summary` returns count of Unmanaged, Managed, and Dismissed certs (useful for compliance reporting)
This data flow is pull-based and non-blocking. Agents discover at their own pace; the server stores results for later review. There's no pressure to claim or dismiss; operators can leave certificates in "Unmanaged" status indefinitely.
## Testing Strategy ## Testing Strategy
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 860+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database. certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 860+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
+13
View File
@@ -190,6 +190,19 @@ certctl includes an MCP (Model Context Protocol) server that exposes all 76 API
The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key. The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key.
### Certificate Discovery
Certificate discovery is the process of automatically finding existing certificates in your infrastructure — certificates you didn't issue through certctl, possibly issued by other CAs or tools. This is essential for building a complete inventory before you can manage everything.
**How it works:** Agents can scan configured directories (configured via `CERTCTL_DISCOVERY_DIRS`) for certificate files. On startup and every 6 hours, the agent walks these directories recursively, parses PEM and DER files, extracts metadata (common name, SANs, expiration, issuer, key algorithm), and reports all findings to the control plane. The server deduplicates by fingerprint (prevents duplicate reports of the same cert) and stores them with a status: **Unmanaged** (discovered but not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage it).
This gives you a three-step triage workflow:
1. **Discover** — Agents find all existing certs on your infrastructure
2. **Triage** — Operators review discoveries and decide: claim it (enroll for management), or dismiss it (not worth managing)
3. **Baseline** — Once triaged, you have a complete baseline of what's deployed, what you're managing, and what's unmanaged
This is a prerequisite for multi-CA migration, compliance audits, and building confidence that you've found all the certificates that matter.
### Observability ### Observability
certctl exposes a JSON metrics endpoint at `GET /api/v1/metrics` with gauges (certificate totals by status, agent counts, pending jobs), counters (completed/failed jobs), and uptime. Five stats endpoints power the dashboard charts: summary statistics, certificates by status, expiration timeline, job trends, and issuance rate. certctl exposes a JSON metrics endpoint at `GET /api/v1/metrics` with gauges (certificate totals by status, agent counts, pending jobs), counters (completed/failed jobs), and uptime. Five stats endpoints power the dashboard charts: summary statistics, certificates by status, expiration timeline, job trends, and issuance rate.
+58
View File
@@ -581,6 +581,64 @@ docker rm -f nginx
6. **Idempotent operations** — Deploying the same certificate twice should succeed, not fail 6. **Idempotent operations** — Deploying the same certificate twice should succeed, not fail
7. **Report metadata** — Return deployment duration, target address, and other useful data in results 7. **Report metadata** — Return deployment duration, target address, and other useful data in results
## Agent Discovery Scanner
Agents include a built-in certificate discovery scanner that walks configured directories and reports unmanaged certificates to the control plane. This is useful for discovering existing certificates already deployed in your infrastructure, so you can bring them under certctl's management.
### Configuration
Enable discovery on an agent by setting `CERTCTL_DISCOVERY_DIRS` to a comma-separated list of directories:
```bash
export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/etc/apache2/ssl"
```
Or via command-line flag:
```bash
./agent --agent-id agent-nginx-01 --discovery-dirs "/etc/nginx/certs,/etc/ssl/certs"
```
The agent scans these directories on startup and every 6 hours, looking for certificate files in PEM or DER format (extensions: `.pem`, `.crt`, `.cer`, `.cert`, `.der`).
### How It Works
1. **Scan**: Agent recursively walks directories, extracts certificates
2. **Deduplicate**: Control plane deduplicates by SHA-256 fingerprint (same cert in multiple locations is one discovery)
3. **Store**: Discovered certificates stored with metadata (agent ID, file path, found date, fingerprint)
4. **Triage**: Operators query discovered certs via API, claim to link to managed certificates, or dismiss false positives
### API Endpoints
```bash
# List discovered certificates (filter by agent, status)
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-01&status=new" | jq .
# Get discovery detail
curl -s http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID | jq .
# Claim a discovered cert (link to managed certificate)
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim \
-H "Content-Type: application/json" \
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
# Dismiss a discovery
curl -s -X POST http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/dismiss | jq .
# View discovery scan history
curl -s http://localhost:8443/api/v1/discovery-scans | jq .
# Summary counts (new, claimed, dismissed)
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
```
### Use Cases
- **Inventory audit** — Find all TLS certificates running in your infrastructure
- **Migration** — Onboard existing certificates that were issued outside certctl
- **Compliance** — Detect rogue/unauthorized certificates in monitored directories
- **Integration** — Pull certificate data from systems that pre-generate certs (e.g., Kubernetes CertManager)
## What's Next ## What's Next
- [Architecture Guide](architecture.md) — Understanding the full system design - [Architecture Guide](architecture.md) — Understanding the full system design
+51
View File
@@ -675,6 +675,57 @@ curl -s "$API/api/v1/certificates/mc-demo-api/deployments" | jq .
--- ---
## Part 14: Certificate Discovery (M18b)
Agents can automatically discover existing certificates already deployed in your infrastructure. This is useful for building a baseline inventory before you start managing everything with certctl.
First, configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled:
```bash
# Stop the existing agent
docker compose -f deploy/docker-compose.yml stop agent
# Restart with discovery enabled (scans /tmp/certs every 6 hours, or on startup)
docker compose -f deploy/docker-compose.yml run -e CERTCTL_DISCOVERY_DIRS=/tmp/certs agent certctl-agent
```
Or with the CLI flag:
```bash
certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/certs --server http://localhost:8443 --api-key test-key-123
```
Now check what the agent discovered:
```bash
# List discovered certificates (should show unmanaged certs found on the agent)
curl -s "$API/api/v1/discovered-certificates?status=Unmanaged" | jq '.data[] | {id, common_name, expires_at, issuer_dn, status}'
# Get a summary of all discoveries
curl -s $API/api/v1/discovery-summary | jq .
```
If the agent found certificates, you'll see entries with `status: "Unmanaged"`. Now triage them — claim the ones you want to manage or dismiss the ones you don't:
```bash
# Claim a certificate (link it to a managed cert, or create new enrollment)
DISCOVERED_ID=$(curl -s "$API/api/v1/discovered-certificates?status=Unmanaged" | jq -r '.data[0].id')
curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/claim" \
-H "Content-Type: application/json" \
-d '{"reason": "Migrating from external CA to certctl"}' | jq .
# Or dismiss a certificate
curl -s -X POST "$API/api/v1/discovered-certificates/$DISCOVERED_ID/dismiss" \
-H "Content-Type: application/json" \
-d '{"reason": "Self-signed test cert, not production"}' | jq .
```
**How it works:** The agent scans `CERTCTL_DISCOVERY_DIRS` on startup and every 6 hours, extracts metadata (common name, SANs, issuer, expiration, key type, fingerprint) from all PEM and DER files, and POSTs the findings to `POST /api/v1/agents/{id}/discoveries`. The server deduplicates by fingerprint (prevents duplicate records) and stores results with a status: **Unmanaged** (discovered, not yet managed), **Managed** (linked to a control plane cert), or **Dismissed** (operator decided not to manage). This gives you a triage workflow: discover → review → claim or dismiss.
**In the dashboard**, the Discovery page (coming in future V2.x) will provide a visual triage interface for claiming and dismissing discovered certificates.
---
## End-to-End Architecture Summary ## End-to-End Architecture Summary
Here's what we just walked through, mapped to the system architecture: Here's what we just walked through, mapped to the system architecture:
+11 -1
View File
@@ -69,6 +69,9 @@ On the Certificates page, select multiple certificates using the checkboxes. A b
**10. "How do I see the deployment history?"** **10. "How do I see the deployment history?"**
Click any certificate, then scroll to the deployment timeline. A visual 4-step timeline shows the lifecycle: Requested → Issued → Deploying → Active. Previous versions show a rollback button. Click any certificate, then scroll to the deployment timeline. A visual 4-step timeline shows the lifecycle: Requested → Issued → Deploying → Active. Previous versions show a rollback button.
**11. "What about certificates already running in production?"**
Enable discovery on agents by setting `CERTCTL_DISCOVERY_DIRS` to directories containing certificates (e.g., `/etc/nginx/certs`). Agents scan on startup and every 6 hours, report findings to the control plane. Click "Discovered Certificates" to see what agents found — claim unmanaged certs to bring them under certctl's management, or dismiss them.
## API Walkthrough ## API Walkthrough
The dashboard is backed by a real REST API. Try these while the demo is running: The dashboard is backed by a real REST API. Try these while the demo is running:
@@ -111,6 +114,12 @@ curl -s http://localhost:8443/api/v1/agent-groups | jq .
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/revoke \ curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/revoke \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"reason": "superseded"}' | jq . -d '{"reason": "superseded"}' | jq .
# List discovered certificates
curl -s http://localhost:8443/api/v1/discovered-certificates | jq .
# Discovery summary (counts by status)
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
``` ```
## Demo Without Docker ## Demo Without Docker
@@ -147,7 +156,8 @@ If you're demoing to a team or customer, here's a suggested flow:
7. **Show profiles** — "Certificate profiles enforce crypto constraints — key types, max TTL, compliance requirements" 7. **Show profiles** — "Certificate profiles enforce crypto constraints — key types, max TTL, compliance requirements"
8. **Show policies** — "Guardrails prevent teams from going outside approved scope" 8. **Show policies** — "Guardrails prevent teams from going outside approved scope"
9. **Show bulk operations** — "Select multiple certs, trigger renewal or revoke in bulk with progress tracking" 9. **Show bulk operations** — "Select multiple certs, trigger renewal or revoke in bulk with progress tracking"
10. **Show the API** — "Everything you see here is API-first. We also have a CLI tool and an MCP server for AI assistant integration" 10. **Show certificate discovery** — "Agents scan your infrastructure for existing certificates you're not managing yet. We automatically deduplicate by fingerprint, show you what we found, and let you claim them or dismiss them"
11. **Show the API** — "Everything you see here is API-first. We also have a CLI tool and an MCP server for AI assistant integration"
The whole walkthrough takes 5-10 minutes. The whole walkthrough takes 5-10 minutes.
+72 -6
View File
@@ -7,7 +7,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026).
## API Surface ## API Surface
### Overview ### Overview
- **77 endpoints** across 16 resource domains under `/api/v1/` - **84 endpoints** across 17 resource domains under `/api/v1/`
- REST API with HTTP semantics (GET, POST, PUT, DELETE) - REST API with HTTP semantics (GET, POST, PUT, DELETE)
- All endpoints require authentication by default (configurable) - All endpoints require authentication by default (configurable)
- OpenAPI 3.1 spec with full schema documentation - OpenAPI 3.1 spec with full schema documentation
@@ -54,6 +54,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026).
| **Teams** | 5 | List, create, get, update, delete | | **Teams** | 5 | List, create, get, update, delete |
| **Owners** | 5 | List, create, get, update, delete | | **Owners** | 5 | List, create, get, update, delete |
| **Agent Groups** | 6 | List, create, get, update, delete, list agents in group | | **Agent Groups** | 6 | List, create, get, update, delete, list agents in group |
| **Discovery** | 7 | Submit scan results, list discovered certs, get detail, claim, dismiss, list scans, summary stats |
| **Audit** | 3 | List events, list by resource, export (CSV/JSON) | | **Audit** | 3 | List events, list by resource, export (CSV/JSON) |
| **Notifications** | 3 | List, get, mark as read | | **Notifications** | 3 | List, get, mark as read |
| **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate | | **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
@@ -346,6 +347,70 @@ Agents report to `/api/v1/agents/{id}/work` with supported target types and issu
--- ---
## Certificate Discovery (M18b)
### Overview
Agents automatically discover existing certificates in the infrastructure — on filesystem, in key stores, or elsewhere — report findings to the control plane, and operators triage them for enrollment.
### Agent-Side Discovery
- **Configuration** — `CERTCTL_DISCOVERY_DIRS` env var (comma-separated list) or `--discovery-dirs` CLI flag
- **Scan Execution** — Runs on agent startup and every 6 hours in background
- **Supported Formats** — PEM (.pem, .crt, .cer, .cert) and DER (.der) files
- **Recursive Walk** — Scans directory trees to find all certificates
- **File Filtering** — Skips files > 1MB and obvious key files
### Certificate Extraction
Each discovered certificate is parsed and its metadata extracted:
| Field | Source | Example |
|-------|--------|---------|
| **Common Name** | X.509 Subject CN | api.example.com |
| **SANs** | X.509 SubjectAltNames | api.example.com, *.api.example.com |
| **Serial** | Certificate serial number | 0x123abc... |
| **Issuer DN** | X.509 Issuer | CN=Internal CA, O=Acme Inc |
| **Subject DN** | X.509 Subject | CN=api.example.com, O=Acme Inc |
| **Not Before** | Validity start | 2024-01-15T00:00:00Z |
| **Not After** | Validity end | 2026-01-15T00:00:00Z |
| **Key Algorithm** | Key type | RSA, ECDSA, Ed25519 |
| **Key Size** | Bits | 2048, 256, 4096 |
| **Is CA** | CA flag in extensions | true/false |
| **Fingerprint** | SHA-256 hash (dedup key) | a1b2c3d4e5f6... |
### Server-Side Processing
- **Deduplication** — Uses fingerprint + agent ID + path as unique key; prevents duplicates
- **Status Tracking** — Three statuses: **Unmanaged** (discovered, not yet claimed), **Managed** (linked to control plane cert), **Dismissed** (operator decided not to manage)
- **Audit Trail** — `discovery_scan_completed`, `discovery_cert_claimed`, `discovery_cert_dismissed` events logged with actor and reason
- **Storage** — `discovered_certificates` and `discovery_scans` tables in PostgreSQL
### Triage Workflow
1. Agent submits scan results via `POST /api/v1/agents/{id}/discoveries`
2. Server deduplicates and stores discovery records
3. Operator views `GET /api/v1/discovered-certificates?status=Unmanaged`
4. For each unmanaged cert:
- **Claim it** — `POST /api/v1/discovered-certificates/{id}/claim` links to managed cert or creates new enrollment
- **Dismiss it** — `POST /api/v1/discovered-certificates/{id}/dismiss` removes from triage queue
5. Tracking enables visibility into what's deployed vs. what's managed
### Discovery API Endpoints (M18b)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/agents/{id}/discoveries` | POST | Agent submits scan results |
| `/api/v1/discovered-certificates` | GET | List discovered certs (with ?agent_id, ?status filters) |
| `/api/v1/discovered-certificates/{id}` | GET | Get single discovered cert detail |
| `/api/v1/discovered-certificates/{id}/claim` | POST | Link to managed cert or create enrollment |
| `/api/v1/discovered-certificates/{id}/dismiss` | POST | Dismiss from triage |
| `/api/v1/discovery-scans` | GET | List scan history with timestamps |
| `/api/v1/discovery-summary` | GET | Aggregate status counts (Unmanaged, Managed, Dismissed) |
### Use Cases
- **Inventory Baseline** — Scan production servers at deployment time to establish baseline of existing certificates
- **Compliance Discovery** — Find all TLS certs before renewing certificate policies
- **Migration Planning** — Discover unmanaged certs to plan migration from other CA/platforms
- **Audit Preparation** — Triage discovered certs into managed and dismissed for compliance reports
- **Multi-CA Migration** — Find all certs currently issued by old CA, claim them for renewal under new issuer
---
## Ownership & Accountability ## Ownership & Accountability
### Teams ### Teams
@@ -840,7 +905,7 @@ The web dashboard is the primary operational interface for certctl. Built with *
| Category | Count | | Category | Count |
|----------|-------| |----------|-------|
| **API Endpoints** | 77 (under /api/v1/) | | **API Endpoints** | 84 (under /api/v1/) |
| **Dashboard Pages** | 19 | | **Dashboard Pages** | 19 |
| **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) | | **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) |
| **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) | | **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) |
@@ -850,9 +915,10 @@ The web dashboard is the primary operational interface for certctl. Built with *
| **Policy Rule Types** | 5 (AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime) | | **Policy Rule Types** | 5 (AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime) |
| **Certificate States** | 8 (Pending, Active, Expiring, Expired, RenewalInProgress, Failed, Revoked, Archived) | | **Certificate States** | 8 (Pending, Active, Expiring, Expired, RenewalInProgress, Failed, Revoked, Archived) |
| **Revocation Reason Codes** | 8 (RFC 5280 compliant) | | **Revocation Reason Codes** | 8 (RFC 5280 compliant) |
| **MCP Tools** | 76 (16 resource domains) | | **Discovery Statuses** | 3 (Unmanaged, Managed, Dismissed) |
| **MCP Tools** | 83 (17 resource domains) |
| **CLI Subcommands** | 10 | | **CLI Subcommands** | 10 |
| **Database Tables** | 18+ | | **Database Tables** | 20+ |
| **Test Suite** | 860+ tests | | **Test Suite** | 881+ tests |
| **Environment Variables** | 40+ configuration options | | **Environment Variables** | 41+ configuration options |
+26
View File
@@ -338,6 +338,32 @@ docker compose -f deploy/docker-compose.yml down -v
The `-v` flag removes the PostgreSQL data volume so you get a clean slate next time. The `-v` flag removes the PostgreSQL data volume so you get a clean slate next time.
### Certificate Discovery
Agents can scan your infrastructure for existing certificates you're not yet managing:
```bash
# Configure agent to scan directories
export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
# Agent scans on startup + every 6 hours, reports findings to control plane
```
Query discovered certificates:
```bash
# List all discovered certs from a specific agent
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
# Get discovery summary (counts by status)
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
# Claim a discovered cert (link to managed cert)
curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
-H "Content-Type: application/json" \
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
```
## What's Next ## What's Next
- **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA and watch it appear in the dashboard - **[Advanced Demo](demo-advanced.md)** — Issue a real certificate via the Local CA and watch it appear in the dashboard
+232
View File
@@ -0,0 +1,232 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/shankar0123/certctl/internal/domain"
)
// DiscoveryService defines the interface used by the discovery handler.
type DiscoveryService interface {
ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error)
ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error)
GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
ClaimDiscovered(ctx context.Context, id string, managedCertID string) error
DismissDiscovered(ctx context.Context, id string) error
ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error)
GetDiscoverySummary(ctx context.Context) (map[string]int, error)
}
// DiscoveryHandler handles HTTP requests for certificate discovery.
type DiscoveryHandler struct {
svc DiscoveryService
}
// NewDiscoveryHandler creates a new discovery handler.
func NewDiscoveryHandler(svc DiscoveryService) DiscoveryHandler {
return DiscoveryHandler{svc: svc}
}
// SubmitDiscoveryReport handles POST /api/v1/agents/{id}/discoveries
// Agents submit their filesystem scan results here.
func (h DiscoveryHandler) SubmitDiscoveryReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
agentID := r.PathValue("id")
if agentID == "" {
Error(w, http.StatusBadRequest, "agent ID is required")
return
}
var report domain.DiscoveryReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
// Override agent ID from path (security: agents can only report for themselves)
report.AgentID = agentID
scan, err := h.svc.ProcessDiscoveryReport(r.Context(), &report)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to process discovery report: %v", err))
return
}
JSON(w, http.StatusAccepted, scan)
}
// ListDiscovered handles GET /api/v1/discovered-certificates
func (h DiscoveryHandler) ListDiscovered(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
query := r.URL.Query()
agentID := query.Get("agent_id")
status := query.Get("status")
page := parseIntDefault(query.Get("page"), 1)
perPage := parseIntDefault(query.Get("per_page"), 50)
if perPage > 500 {
perPage = 50
}
certs, total, err := h.svc.ListDiscovered(r.Context(), agentID, status, page, perPage)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list discovered certificates: %v", err))
return
}
JSON(w, http.StatusOK, PagedResponse{
Data: certs,
Total: int64(total),
Page: page,
PerPage: perPage,
})
}
// GetDiscovered handles GET /api/v1/discovered-certificates/{id}
func (h DiscoveryHandler) GetDiscovered(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "discovered certificate ID is required")
return
}
cert, err := h.svc.GetDiscovered(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, fmt.Sprintf("discovered certificate not found: %v", err))
return
}
JSON(w, http.StatusOK, cert)
}
// ClaimDiscovered handles POST /api/v1/discovered-certificates/{id}/claim
func (h DiscoveryHandler) ClaimDiscovered(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "discovered certificate ID is required")
return
}
var body struct {
ManagedCertificateID string `json:"managed_certificate_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
if body.ManagedCertificateID == "" {
Error(w, http.StatusBadRequest, "managed_certificate_id is required")
return
}
if err := h.svc.ClaimDiscovered(r.Context(), id, body.ManagedCertificateID); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to claim certificate: %v", err))
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "claimed",
"message": "Discovered certificate linked to managed certificate",
})
}
// DismissDiscovered handles POST /api/v1/discovered-certificates/{id}/dismiss
func (h DiscoveryHandler) DismissDiscovered(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "discovered certificate ID is required")
return
}
if err := h.svc.DismissDiscovered(r.Context(), id); err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to dismiss certificate: %v", err))
return
}
JSON(w, http.StatusOK, map[string]string{
"status": "dismissed",
"message": "Discovered certificate dismissed",
})
}
// ListScans handles GET /api/v1/discovery-scans
func (h DiscoveryHandler) ListScans(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
query := r.URL.Query()
agentID := query.Get("agent_id")
page := parseIntDefault(query.Get("page"), 1)
perPage := parseIntDefault(query.Get("per_page"), 50)
scans, total, err := h.svc.ListScans(r.Context(), agentID, page, perPage)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list discovery scans: %v", err))
return
}
JSON(w, http.StatusOK, PagedResponse{
Data: scans,
Total: int64(total),
Page: page,
PerPage: perPage,
})
}
// GetDiscoverySummary handles GET /api/v1/discovery-summary
func (h DiscoveryHandler) GetDiscoverySummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
summary, err := h.svc.GetDiscoverySummary(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to get discovery summary: %v", err))
return
}
JSON(w, http.StatusOK, summary)
}
// parseIntDefault parses an integer from a string with a default fallback.
func parseIntDefault(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
val, err := strconv.Atoi(s)
if err != nil || val < 1 {
return defaultVal
}
return val
}
@@ -0,0 +1,613 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// MockDiscoveryService is a mock implementation of DiscoveryService interface.
type MockDiscoveryService struct {
ProcessDiscoveryReportFn func(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error)
ListDiscoveredFn func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error)
GetDiscoveredFn func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
ClaimDiscoveredFn func(ctx context.Context, id string, managedCertID string) error
DismissDiscoveredFn func(ctx context.Context, id string) error
ListScansFn func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
GetScanFn func(ctx context.Context, id string) (*domain.DiscoveryScan, error)
GetDiscoverySummaryFn func(ctx context.Context) (map[string]int, error)
}
func (m *MockDiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
if m.ProcessDiscoveryReportFn != nil {
return m.ProcessDiscoveryReportFn(ctx, report)
}
return nil, nil
}
func (m *MockDiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
if m.ListDiscoveredFn != nil {
return m.ListDiscoveredFn(ctx, agentID, status, page, perPage)
}
return nil, 0, nil
}
func (m *MockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
if m.GetDiscoveredFn != nil {
return m.GetDiscoveredFn(ctx, id)
}
return nil, nil
}
func (m *MockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error {
if m.ClaimDiscoveredFn != nil {
return m.ClaimDiscoveredFn(ctx, id, managedCertID)
}
return nil
}
func (m *MockDiscoveryService) DismissDiscovered(ctx context.Context, id string) error {
if m.DismissDiscoveredFn != nil {
return m.DismissDiscoveredFn(ctx, id)
}
return nil
}
func (m *MockDiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
if m.ListScansFn != nil {
return m.ListScansFn(ctx, agentID, page, perPage)
}
return nil, 0, nil
}
func (m *MockDiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
if m.GetScanFn != nil {
return m.GetScanFn(ctx, id)
}
return nil, nil
}
func (m *MockDiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
if m.GetDiscoverySummaryFn != nil {
return m.GetDiscoverySummaryFn(ctx)
}
return nil, nil
}
// Helper function to create context with request ID.
func discoveryContextWithRequestID() context.Context {
return context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-123")
}
// Test SubmitDiscoveryReport - success case
func TestSubmitDiscoveryReport_Success(t *testing.T) {
now := time.Now()
scan := &domain.DiscoveryScan{
ID: "dscan-1",
AgentID: "agent-1",
CertificatesFound: 2,
CertificatesNew: 1,
ErrorsCount: 0,
ScanDurationMs: 150,
StartedAt: now,
CompletedAt: &now,
}
mock := &MockDiscoveryService{
ProcessDiscoveryReportFn: func(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
if report.AgentID == "agent-1" && len(report.Certificates) == 2 {
return scan, nil
}
return nil, fmt.Errorf("unexpected report")
},
}
handler := NewDiscoveryHandler(mock)
reportBody := domain.DiscoveryReport{
AgentID: "agent-1",
Certificates: []domain.DiscoveredCertEntry{
{
FingerprintSHA256: "abc123",
CommonName: "example.com",
SerialNumber: "001",
SourcePath: "/etc/certs/example.com.crt",
},
{
FingerprintSHA256: "def456",
CommonName: "api.example.com",
SerialNumber: "002",
SourcePath: "/etc/certs/api.example.com.crt",
},
},
ScanDurationMs: 150,
}
body, _ := json.Marshal(reportBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/discoveries", bytes.NewReader(body))
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "agent-1")
w := httptest.NewRecorder()
handler.SubmitDiscoveryReport(w, req)
if w.Code != http.StatusAccepted {
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
}
var response *domain.DiscoveryScan
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.ID != "dscan-1" {
t.Errorf("expected scan ID dscan-1, got %s", response.ID)
}
if response.CertificatesFound != 2 {
t.Errorf("expected 2 certificates found, got %d", response.CertificatesFound)
}
}
// Test SubmitDiscoveryReport - invalid body
func TestSubmitDiscoveryReport_InvalidBody(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/agent-1/discoveries", bytes.NewReader([]byte("invalid json")))
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "agent-1")
w := httptest.NewRecorder()
handler.SubmitDiscoveryReport(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test SubmitDiscoveryReport - method not allowed
func TestSubmitDiscoveryReport_MethodNotAllowed(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/agent-1/discoveries", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "agent-1")
w := httptest.NewRecorder()
handler.SubmitDiscoveryReport(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// Test ListDiscovered - success case
func TestListDiscovered_Success(t *testing.T) {
now := time.Now()
certs := []*domain.DiscoveredCertificate{
{
ID: "dcert-1",
CommonName: "example.com",
SerialNumber: "001",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "dcert-2",
CommonName: "api.example.com",
SerialNumber: "002",
Status: domain.DiscoveryStatusManaged,
CreatedAt: now,
UpdatedAt: now,
},
}
mock := &MockDiscoveryService{
ListDiscoveredFn: func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
if page == 1 && perPage == 50 {
return certs, 2, nil
}
return nil, 0, nil
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates?page=1&per_page=50", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListDiscovered(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response PagedResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Total != 2 {
t.Errorf("expected total 2, got %d", response.Total)
}
}
// Test ListDiscovered - with filters
func TestListDiscovered_WithFilters(t *testing.T) {
mock := &MockDiscoveryService{
ListDiscoveredFn: func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
if agentID == "agent-1" && status == "Unmanaged" {
return []*domain.DiscoveredCertificate{}, 0, nil
}
return nil, 0, nil
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates?agent_id=agent-1&status=Unmanaged&page=1&per_page=25", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListDiscovered(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
}
// Test ListDiscovered - method not allowed
func TestListDiscovered_MethodNotAllowed(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListDiscovered(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// Test GetDiscovered - success case
func TestGetDiscovered_Success(t *testing.T) {
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
SerialNumber: "001",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
mock := &MockDiscoveryService{
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
if id == "dcert-1" {
return cert, nil
}
return nil, fmt.Errorf("not found")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/dcert-1", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.GetDiscovered(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response *domain.DiscoveredCertificate
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.ID != "dcert-1" {
t.Errorf("expected ID dcert-1, got %s", response.ID)
}
}
// Test GetDiscovered - not found
func TestGetDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{
GetDiscoveredFn: func(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
return nil, fmt.Errorf("not found")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/nonexistent", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "nonexistent")
w := httptest.NewRecorder()
handler.GetDiscovered(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
// Test ClaimDiscovered - success case
func TestClaimDiscovered_Success(t *testing.T) {
mock := &MockDiscoveryService{
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string) error {
if id == "dcert-1" && managedCertID == "mc-prod-1" {
return nil
}
return fmt.Errorf("unexpected parameters")
},
}
handler := NewDiscoveryHandler(mock)
claimBody := map[string]string{
"managed_certificate_id": "mc-prod-1",
}
body, _ := json.Marshal(claimBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/claim", bytes.NewReader(body))
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.ClaimDiscovered(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]string
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["status"] != "claimed" {
t.Errorf("expected status 'claimed', got %s", response["status"])
}
}
// Test ClaimDiscovered - missing managed_certificate_id
func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
claimBody := map[string]string{}
body, _ := json.Marshal(claimBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/claim", bytes.NewReader(body))
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.ClaimDiscovered(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test ClaimDiscovered - discovered cert not found
func TestClaimDiscovered_NotFound(t *testing.T) {
mock := &MockDiscoveryService{
ClaimDiscoveredFn: func(ctx context.Context, id string, managedCertID string) error {
return fmt.Errorf("discovered certificate not found")
},
}
handler := NewDiscoveryHandler(mock)
claimBody := map[string]string{
"managed_certificate_id": "mc-prod-1",
}
body, _ := json.Marshal(claimBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/nonexistent/claim", bytes.NewReader(body))
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "nonexistent")
w := httptest.NewRecorder()
handler.ClaimDiscovered(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test DismissDiscovered - success case
func TestDismissDiscovered_Success(t *testing.T) {
mock := &MockDiscoveryService{
DismissDiscoveredFn: func(ctx context.Context, id string) error {
if id == "dcert-1" {
return nil
}
return fmt.Errorf("not found")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/dismiss", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.DismissDiscovered(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]string
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["status"] != "dismissed" {
t.Errorf("expected status 'dismissed', got %s", response["status"])
}
}
// Test DismissDiscovered - method not allowed
func TestDismissDiscovered_MethodNotAllowed(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/dcert-1/dismiss", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.DismissDiscovered(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// Test ListScans - success case
func TestListScans_Success(t *testing.T) {
now := time.Now()
scans := []*domain.DiscoveryScan{
{
ID: "dscan-1",
AgentID: "agent-1",
CertificatesFound: 5,
CertificatesNew: 2,
ScanDurationMs: 200,
StartedAt: now,
CompletedAt: &now,
},
}
mock := &MockDiscoveryService{
ListScansFn: func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
if page == 1 && perPage == 50 {
return scans, 1, nil
}
return nil, 0, nil
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-scans?page=1&per_page=50", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListScans(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response PagedResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Total != 1 {
t.Errorf("expected total 1, got %d", response.Total)
}
}
// Test ListScans - with agent filter
func TestListScans_WithAgentFilter(t *testing.T) {
mock := &MockDiscoveryService{
ListScansFn: func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
if agentID == "agent-1" {
return []*domain.DiscoveryScan{}, 0, nil
}
return nil, 0, nil
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-scans?agent_id=agent-1", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListScans(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
}
// Test GetDiscoverySummary - success case
func TestGetDiscoverySummary_Success(t *testing.T) {
summary := map[string]int{
"Unmanaged": 5,
"Managed": 3,
"Dismissed": 1,
}
mock := &MockDiscoveryService{
GetDiscoverySummaryFn: func(ctx context.Context) (map[string]int, error) {
return summary, nil
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-summary", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.GetDiscoverySummary(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]int
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["Unmanaged"] != 5 {
t.Errorf("expected Unmanaged count 5, got %d", response["Unmanaged"])
}
if response["Managed"] != 3 {
t.Errorf("expected Managed count 3, got %d", response["Managed"])
}
}
// Test GetDiscoverySummary - method not allowed
func TestGetDiscoverySummary_MethodNotAllowed(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovery-summary", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.GetDiscoverySummary(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
+10
View File
@@ -60,6 +60,7 @@ func (r *Router) RegisterHandlers(
stats handler.StatsHandler, stats handler.StatsHandler,
metrics handler.MetricsHandler, metrics handler.MetricsHandler,
health handler.HealthHandler, health handler.HealthHandler,
discovery handler.DiscoveryHandler,
) { ) {
// Health endpoints (no auth middleware — must always be accessible) // Health endpoints (no auth middleware — must always be accessible)
r.mux.Handle("GET /health", middleware.Chain( r.mux.Handle("GET /health", middleware.Chain(
@@ -187,6 +188,15 @@ func (r *Router) RegisterHandlers(
// Metrics routes: /api/v1/metrics // Metrics routes: /api/v1/metrics
r.Register("GET /api/v1/metrics", http.HandlerFunc(metrics.GetMetrics)) r.Register("GET /api/v1/metrics", http.HandlerFunc(metrics.GetMetrics))
// Discovery routes: /api/v1/discovered-certificates, /api/v1/discovery-scans
r.Register("POST /api/v1/agents/{id}/discoveries", http.HandlerFunc(discovery.SubmitDiscoveryReport))
r.Register("GET /api/v1/discovered-certificates", http.HandlerFunc(discovery.ListDiscovered))
r.Register("GET /api/v1/discovered-certificates/{id}", http.HandlerFunc(discovery.GetDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/claim", http.HandlerFunc(discovery.ClaimDiscovered))
r.Register("POST /api/v1/discovered-certificates/{id}/dismiss", http.HandlerFunc(discovery.DismissDiscovered))
r.Register("GET /api/v1/discovery-scans", http.HandlerFunc(discovery.ListScans))
r.Register("GET /api/v1/discovery-summary", http.HandlerFunc(discovery.GetDiscoverySummary))
} }
// GetMux returns the underlying http.ServeMux for direct access if needed. // GetMux returns the underlying http.ServeMux for direct access if needed.
+113
View File
@@ -0,0 +1,113 @@
package domain
import (
"time"
)
// DiscoveryStatus represents the triage state of a discovered certificate.
type DiscoveryStatus string
const (
// DiscoveryStatusUnmanaged indicates a discovered cert not yet linked to a managed cert.
DiscoveryStatusUnmanaged DiscoveryStatus = "Unmanaged"
// DiscoveryStatusManaged indicates a discovered cert linked to a managed cert.
DiscoveryStatusManaged DiscoveryStatus = "Managed"
// DiscoveryStatusDismissed indicates a cert the operator chose to ignore.
DiscoveryStatusDismissed DiscoveryStatus = "Dismissed"
)
// IsValidDiscoveryStatus returns true if the status is a recognized discovery status.
func IsValidDiscoveryStatus(s string) bool {
switch DiscoveryStatus(s) {
case DiscoveryStatusUnmanaged, DiscoveryStatusManaged, DiscoveryStatusDismissed:
return true
}
return false
}
// DiscoveredCertificate represents a certificate found on an agent's filesystem.
type DiscoveredCertificate struct {
ID string `json:"id"`
FingerprintSHA256 string `json:"fingerprint_sha256"`
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
SerialNumber string `json:"serial_number"`
IssuerDN string `json:"issuer_dn"`
SubjectDN string `json:"subject_dn"`
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
KeyAlgorithm string `json:"key_algorithm"`
KeySize int `json:"key_size"`
IsCA bool `json:"is_ca"`
PEMData string `json:"pem_data,omitempty"`
SourcePath string `json:"source_path"`
SourceFormat string `json:"source_format"`
AgentID string `json:"agent_id"`
DiscoveryScanID string `json:"discovery_scan_id,omitempty"`
ManagedCertificateID string `json:"managed_certificate_id,omitempty"`
Status DiscoveryStatus `json:"status"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// IsExpired returns true if the discovered certificate has expired.
func (d *DiscoveredCertificate) IsExpired() bool {
if d.NotAfter == nil {
return false
}
return d.NotAfter.Before(time.Now())
}
// DaysUntilExpiry returns the number of days until the certificate expires.
// Returns -1 if NotAfter is not set.
func (d *DiscoveredCertificate) DaysUntilExpiry() int {
if d.NotAfter == nil {
return -1
}
hours := time.Until(*d.NotAfter).Hours()
return int(hours / 24)
}
// DiscoveryScan represents a single discovery scan run by an agent.
type DiscoveryScan struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
Directories []string `json:"directories"`
CertificatesFound int `json:"certificates_found"`
CertificatesNew int `json:"certificates_new"`
ErrorsCount int `json:"errors_count"`
ScanDurationMs int `json:"scan_duration_ms"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// DiscoveryReport is the payload an agent sends after scanning its filesystem.
type DiscoveryReport struct {
AgentID string `json:"agent_id"`
Directories []string `json:"directories"`
Certificates []DiscoveredCertEntry `json:"certificates"`
Errors []string `json:"errors,omitempty"`
ScanDurationMs int `json:"scan_duration_ms"`
}
// DiscoveredCertEntry represents a single certificate found during a filesystem scan.
// This is the agent-side representation (no server-side IDs yet).
type DiscoveredCertEntry struct {
FingerprintSHA256 string `json:"fingerprint_sha256"`
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
SerialNumber string `json:"serial_number"`
IssuerDN string `json:"issuer_dn"`
SubjectDN string `json:"subject_dn"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
KeyAlgorithm string `json:"key_algorithm"`
KeySize int `json:"key_size"`
IsCA bool `json:"is_ca"`
PEMData string `json:"pem_data"`
SourcePath string `json:"source_path"`
SourceFormat string `json:"source_format"`
}
+105
View File
@@ -0,0 +1,105 @@
package domain
import (
"testing"
"time"
)
func TestIsValidDiscoveryStatus(t *testing.T) {
tests := []struct {
name string
status string
want bool
}{
{"Unmanaged", "Unmanaged", true},
{"Managed", "Managed", true},
{"Dismissed", "Dismissed", true},
{"empty string", "", false},
{"invalid status", "Unknown", false},
{"partial match", "Manage", false},
{"case sensitive", "unmanaged", false},
{"lowercase managed", "managed", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidDiscoveryStatus(tt.status); got != tt.want {
t.Errorf("IsValidDiscoveryStatus(%q) = %v, want %v", tt.status, got, tt.want)
}
})
}
}
func TestDiscoveredCertificate_IsExpired(t *testing.T) {
now := time.Now()
pastTime := now.AddDate(-1, 0, 0)
futureTime := now.AddDate(1, 0, 0)
tests := []struct {
name string
notAfter *time.Time
want bool
}{
{"expired certificate", &pastTime, true},
{"valid certificate", &futureTime, false},
{"nil NotAfter", nil, false},
{"expires at current time (edge case)", &now, false}, // Before() = false when at same time
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dc := &DiscoveredCertificate{
ID: "dcert-1",
NotAfter: tt.notAfter,
}
if got := dc.IsExpired(); got != tt.want {
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
}
})
}
}
func TestDiscoveredCertificate_DaysUntilExpiry(t *testing.T) {
now := time.Now()
tests := []struct {
name string
notAfter *time.Time
wantDays int
}{
{"nil NotAfter", nil, -1},
{"expires in 30 days", &time.Time{}, 0}, // placeholder, will be calculated below
{"expires in 1 day", &time.Time{}, 1},
{"expires in 0 days (expired)", &time.Time{}, 0},
}
// Test with actual future times
thirtyDaysFromNow := now.AddDate(0, 0, 30)
oneDayFromNow := now.AddDate(0, 0, 1)
pastTime := now.AddDate(0, 0, -1)
testCases := []struct {
name string
notAfter *time.Time
wantMin int
wantMax int
}{
{"nil NotAfter", nil, -1, -1},
{"expires in 30 days", &thirtyDaysFromNow, 29, 31},
{"expires in 1 day", &oneDayFromNow, 0, 2},
{"already expired", &pastTime, -2, -1},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
dc := &DiscoveredCertificate{
ID: "dcert-2",
NotAfter: tt.notAfter,
}
got := dc.DaysUntilExpiry()
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("DaysUntilExpiry() = %d, want between %d and %d", got, tt.wantMin, tt.wantMax)
}
})
}
}
+37
View File
@@ -79,6 +79,7 @@ func TestCertificateLifecycle(t *testing.T) {
statsHandler := handler.NewStatsHandler(&mockStatsService{}) statsHandler := handler.NewStatsHandler(&mockStatsService{})
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now()) metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
healthHandler := handler.NewHealthHandler("none") healthHandler := handler.NewHealthHandler("none")
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
// Create router and register handlers // Create router and register handlers
r := router.New() r := router.New()
@@ -98,6 +99,7 @@ func TestCertificateLifecycle(t *testing.T) {
statsHandler, statsHandler,
metricsHandler, metricsHandler,
healthHandler, healthHandler,
discoveryHandler,
) )
// Create test server // Create test server
@@ -1137,3 +1139,38 @@ func (m *mockStatsService) GetJobStats(ctx context.Context, days int) (interface
func (m *mockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) { func (m *mockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
return []interface{}{}, nil return []interface{}{}, nil
} }
// mockDiscoveryService implements handler.DiscoveryService for integration tests.
type mockDiscoveryService struct{}
func (m *mockDiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
return &domain.DiscoveryScan{ID: "dscan-test"}, nil
}
func (m *mockDiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
return nil, 0, nil
}
func (m *mockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
return nil, fmt.Errorf("not found")
}
func (m *mockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error {
return nil
}
func (m *mockDiscoveryService) DismissDiscovered(ctx context.Context, id string) error {
return nil
}
func (m *mockDiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
return nil, 0, nil
}
func (m *mockDiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
return nil, fmt.Errorf("not found")
}
func (m *mockDiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
return map[string]int{}, nil
}
+2
View File
@@ -72,6 +72,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
statsHandler := handler.NewStatsHandler(&mockStatsService{}) statsHandler := handler.NewStatsHandler(&mockStatsService{})
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now()) metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
healthHandler := handler.NewHealthHandler("none") healthHandler := handler.NewHealthHandler("none")
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
r := router.New() r := router.New()
r.RegisterHandlers( r.RegisterHandlers(
@@ -90,6 +91,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
statsHandler, statsHandler,
metricsHandler, metricsHandler,
healthHandler, healthHandler,
discoveryHandler,
) )
server := httptest.NewServer(r) server := httptest.NewServer(r)
+33
View File
@@ -205,6 +205,39 @@ type AgentGroupRepository interface {
RemoveMember(ctx context.Context, groupID, agentID string) error RemoveMember(ctx context.Context, groupID, agentID string) error
} }
// DiscoveryRepository defines operations for managing certificate discovery.
type DiscoveryRepository interface {
// CreateScan stores a new discovery scan record.
CreateScan(ctx context.Context, scan *domain.DiscoveryScan) error
// GetScan retrieves a discovery scan by ID.
GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error)
// ListScans returns discovery scans, optionally filtered by agent ID.
ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error)
// CreateDiscovered stores a new discovered certificate (upserts by fingerprint+agent+path).
// Returns true if the certificate was newly inserted (not just updated).
CreateDiscovered(ctx context.Context, cert *domain.DiscoveredCertificate) (bool, error)
// GetDiscovered retrieves a discovered certificate by ID.
GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error)
// ListDiscovered returns discovered certificates matching the filter.
ListDiscovered(ctx context.Context, filter *DiscoveryFilter) ([]*domain.DiscoveredCertificate, int, error)
// UpdateDiscoveredStatus updates the status and optional managed certificate link.
UpdateDiscoveredStatus(ctx context.Context, id string, status domain.DiscoveryStatus, managedCertID string) error
// GetByFingerprint retrieves discovered certificates by SHA-256 fingerprint.
GetByFingerprint(ctx context.Context, fingerprint string) ([]*domain.DiscoveredCertificate, error)
// CountByStatus returns counts of discovered certificates grouped by status.
CountByStatus(ctx context.Context) (map[string]int, error)
}
// DiscoveryFilter defines filters for listing discovered certificates.
type DiscoveryFilter struct {
AgentID string
Status string
IsExpired bool
IsCA bool
Page int
PerPage int
}
// OwnerRepository defines operations for managing certificate owners. // OwnerRepository defines operations for managing certificate owners.
type OwnerRepository interface { type OwnerRepository interface {
// List returns all owners. // List returns all owners.
+405
View File
@@ -0,0 +1,405 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/lib/pq"
"github.com/shankar0123/certctl/internal/domain"
)
// DiscoveryRepository implements the repository.DiscoveryRepository interface.
type DiscoveryRepository struct {
db *sql.DB
}
// NewDiscoveryRepository creates a new PostgreSQL-backed discovery repository.
func NewDiscoveryRepository(db *sql.DB) *DiscoveryRepository {
return &DiscoveryRepository{db: db}
}
// --- Discovery Scans ---
// CreateScan stores a new discovery scan record.
func (r *DiscoveryRepository) CreateScan(ctx context.Context, scan *domain.DiscoveryScan) error {
query := `
INSERT INTO discovery_scans (id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING`
_, err := r.db.ExecContext(ctx, query,
scan.ID,
scan.AgentID,
pq.Array(scan.Directories),
scan.CertificatesFound,
scan.CertificatesNew,
scan.ErrorsCount,
scan.ScanDurationMs,
scan.StartedAt,
scan.CompletedAt,
)
if err != nil {
return fmt.Errorf("failed to create discovery scan: %w", err)
}
return nil
}
// GetScan retrieves a discovery scan by ID.
func (r *DiscoveryRepository) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
query := `
SELECT id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at
FROM discovery_scans WHERE id = $1`
scan := &domain.DiscoveryScan{}
var dirs []string
err := r.db.QueryRowContext(ctx, query, id).Scan(
&scan.ID, &scan.AgentID, pq.Array(&dirs),
&scan.CertificatesFound, &scan.CertificatesNew, &scan.ErrorsCount,
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovery scan not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("failed to get discovery scan: %w", err)
}
scan.Directories = dirs
return scan, nil
}
// ListScans returns discovery scans, optionally filtered by agent ID.
func (r *DiscoveryRepository) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
if page < 1 {
page = 1
}
if perPage <= 0 || perPage > 500 {
perPage = 50
}
var whereConditions []string
var args []interface{}
argCount := 1
if agentID != "" {
whereConditions = append(whereConditions, fmt.Sprintf("agent_id = $%d", argCount))
args = append(args, agentID)
argCount++
}
whereClause := ""
if len(whereConditions) > 0 {
whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
}
// Count
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM discovery_scans %s", whereClause)
var total int
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count discovery scans: %w", err)
}
// List
offset := (page - 1) * perPage
listQuery := fmt.Sprintf(`
SELECT id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at
FROM discovery_scans %s
ORDER BY started_at DESC
LIMIT $%d OFFSET $%d`, whereClause, argCount, argCount+1)
args = append(args, perPage, offset)
rows, err := r.db.QueryContext(ctx, listQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list discovery scans: %w", err)
}
defer rows.Close()
var scans []*domain.DiscoveryScan
for rows.Next() {
scan := &domain.DiscoveryScan{}
var dirs []string
if err := rows.Scan(
&scan.ID, &scan.AgentID, pq.Array(&dirs),
&scan.CertificatesFound, &scan.CertificatesNew, &scan.ErrorsCount,
&scan.ScanDurationMs, &scan.StartedAt, &scan.CompletedAt,
); err != nil {
return nil, 0, fmt.Errorf("failed to scan discovery scan row: %w", err)
}
scan.Directories = dirs
scans = append(scans, scan)
}
return scans, total, nil
}
// --- Discovered Certificates ---
// CreateDiscovered stores a new discovered certificate.
// Uses ON CONFLICT to update last_seen_at for existing fingerprint+agent+path combos.
func (r *DiscoveryRepository) CreateDiscovered(ctx context.Context, cert *domain.DiscoveredCertificate) (bool, error) {
query := `
INSERT INTO discovered_certificates (
id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn,
not_before, not_after, key_algorithm, key_size, is_ca, pem_data,
source_path, source_format, agent_id, discovery_scan_id,
status, first_seen_at, last_seen_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
ON CONFLICT (fingerprint_sha256, agent_id, source_path) DO UPDATE SET
last_seen_at = EXCLUDED.last_seen_at,
discovery_scan_id = EXCLUDED.discovery_scan_id,
updated_at = NOW()
RETURNING (xmax = 0) AS is_new`
var isNew bool
err := r.db.QueryRowContext(ctx, query,
cert.ID, cert.FingerprintSHA256, cert.CommonName, pq.Array(cert.SANs),
cert.SerialNumber, cert.IssuerDN, cert.SubjectDN,
cert.NotBefore, cert.NotAfter, cert.KeyAlgorithm, cert.KeySize, cert.IsCA,
cert.PEMData, cert.SourcePath, cert.SourceFormat,
cert.AgentID, nullableString(cert.DiscoveryScanID),
string(cert.Status), cert.FirstSeenAt, cert.LastSeenAt,
cert.CreatedAt, cert.UpdatedAt,
).Scan(&isNew)
if err != nil {
return false, fmt.Errorf("failed to upsert discovered certificate: %w", err)
}
return isNew, nil
}
// GetDiscovered retrieves a discovered certificate by ID.
func (r *DiscoveryRepository) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
query := `
SELECT id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn,
not_before, not_after, key_algorithm, key_size, is_ca, pem_data,
source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id,
status, first_seen_at, last_seen_at, dismissed_at, created_at, updated_at
FROM discovered_certificates WHERE id = $1`
cert := &domain.DiscoveredCertificate{}
var sans []string
var scanID, managedID sql.NullString
err := r.db.QueryRowContext(ctx, query, id).Scan(
&cert.ID, &cert.FingerprintSHA256, &cert.CommonName, pq.Array(&sans),
&cert.SerialNumber, &cert.IssuerDN, &cert.SubjectDN,
&cert.NotBefore, &cert.NotAfter, &cert.KeyAlgorithm, &cert.KeySize, &cert.IsCA,
&cert.PEMData, &cert.SourcePath, &cert.SourceFormat,
&cert.AgentID, &scanID, &managedID,
&cert.Status, &cert.FirstSeenAt, &cert.LastSeenAt, &cert.DismissedAt,
&cert.CreatedAt, &cert.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("discovered certificate not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("failed to get discovered certificate: %w", err)
}
cert.SANs = sans
if scanID.Valid {
cert.DiscoveryScanID = scanID.String
}
if managedID.Valid {
cert.ManagedCertificateID = managedID.String
}
return cert, nil
}
// ListDiscovered returns discovered certificates matching the filter.
func (r *DiscoveryRepository) ListDiscovered(ctx context.Context, filter *DiscoveryFilter) ([]*domain.DiscoveredCertificate, int, error) {
if filter.Page < 1 {
filter.Page = 1
}
if filter.PerPage <= 0 || filter.PerPage > 500 {
filter.PerPage = 50
}
var whereConditions []string
var args []interface{}
argCount := 1
if filter.AgentID != "" {
whereConditions = append(whereConditions, fmt.Sprintf("agent_id = $%d", argCount))
args = append(args, filter.AgentID)
argCount++
}
if filter.Status != "" {
whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argCount))
args = append(args, filter.Status)
argCount++
}
if filter.IsExpired {
whereConditions = append(whereConditions, "not_after < NOW()")
}
if filter.IsCA {
whereConditions = append(whereConditions, "is_ca = TRUE")
}
whereClause := ""
if len(whereConditions) > 0 {
whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
}
// Count
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM discovered_certificates %s", whereClause)
var total int
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count discovered certificates: %w", err)
}
// List
offset := (filter.Page - 1) * filter.PerPage
listQuery := fmt.Sprintf(`
SELECT id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn,
not_before, not_after, key_algorithm, key_size, is_ca, pem_data,
source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id,
status, first_seen_at, last_seen_at, dismissed_at, created_at, updated_at
FROM discovered_certificates %s
ORDER BY last_seen_at DESC
LIMIT $%d OFFSET $%d`, whereClause, argCount, argCount+1)
args = append(args, filter.PerPage, offset)
rows, err := r.db.QueryContext(ctx, listQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list discovered certificates: %w", err)
}
defer rows.Close()
var certs []*domain.DiscoveredCertificate
for rows.Next() {
cert := &domain.DiscoveredCertificate{}
var sans []string
var scanID, managedID sql.NullString
if err := rows.Scan(
&cert.ID, &cert.FingerprintSHA256, &cert.CommonName, pq.Array(&sans),
&cert.SerialNumber, &cert.IssuerDN, &cert.SubjectDN,
&cert.NotBefore, &cert.NotAfter, &cert.KeyAlgorithm, &cert.KeySize, &cert.IsCA,
&cert.PEMData, &cert.SourcePath, &cert.SourceFormat,
&cert.AgentID, &scanID, &managedID,
&cert.Status, &cert.FirstSeenAt, &cert.LastSeenAt, &cert.DismissedAt,
&cert.CreatedAt, &cert.UpdatedAt,
); err != nil {
return nil, 0, fmt.Errorf("failed to scan discovered certificate row: %w", err)
}
cert.SANs = sans
if scanID.Valid {
cert.DiscoveryScanID = scanID.String
}
if managedID.Valid {
cert.ManagedCertificateID = managedID.String
}
certs = append(certs, cert)
}
return certs, total, nil
}
// UpdateDiscoveredStatus updates the status and optional managed certificate link.
func (r *DiscoveryRepository) UpdateDiscoveredStatus(ctx context.Context, id string, status domain.DiscoveryStatus, managedCertID string) error {
var query string
var args []interface{}
now := time.Now()
switch status {
case domain.DiscoveryStatusManaged:
query = `UPDATE discovered_certificates SET status = $1, managed_certificate_id = $2, updated_at = $3 WHERE id = $4`
args = []interface{}{string(status), managedCertID, now, id}
case domain.DiscoveryStatusDismissed:
query = `UPDATE discovered_certificates SET status = $1, dismissed_at = $2, updated_at = $3 WHERE id = $4`
args = []interface{}{string(status), now, now, id}
default:
query = `UPDATE discovered_certificates SET status = $1, managed_certificate_id = NULL, dismissed_at = NULL, updated_at = $2 WHERE id = $3`
args = []interface{}{string(status), now, id}
}
result, err := r.db.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to update discovered certificate status: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("discovered certificate not found: %s", id)
}
return nil
}
// GetByFingerprint retrieves discovered certificates by SHA-256 fingerprint.
func (r *DiscoveryRepository) GetByFingerprint(ctx context.Context, fingerprint string) ([]*domain.DiscoveredCertificate, error) {
query := `
SELECT id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn,
not_before, not_after, key_algorithm, key_size, is_ca, '',
source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id,
status, first_seen_at, last_seen_at, dismissed_at, created_at, updated_at
FROM discovered_certificates WHERE fingerprint_sha256 = $1
ORDER BY last_seen_at DESC`
rows, err := r.db.QueryContext(ctx, query, fingerprint)
if err != nil {
return nil, fmt.Errorf("failed to get by fingerprint: %w", err)
}
defer rows.Close()
var certs []*domain.DiscoveredCertificate
for rows.Next() {
cert := &domain.DiscoveredCertificate{}
var sans []string
var scanID, managedID sql.NullString
if err := rows.Scan(
&cert.ID, &cert.FingerprintSHA256, &cert.CommonName, pq.Array(&sans),
&cert.SerialNumber, &cert.IssuerDN, &cert.SubjectDN,
&cert.NotBefore, &cert.NotAfter, &cert.KeyAlgorithm, &cert.KeySize, &cert.IsCA,
&cert.PEMData, &cert.SourcePath, &cert.SourceFormat,
&cert.AgentID, &scanID, &managedID,
&cert.Status, &cert.FirstSeenAt, &cert.LastSeenAt, &cert.DismissedAt,
&cert.CreatedAt, &cert.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
cert.SANs = sans
if scanID.Valid {
cert.DiscoveryScanID = scanID.String
}
if managedID.Valid {
cert.ManagedCertificateID = managedID.String
}
certs = append(certs, cert)
}
return certs, nil
}
// CountByStatus returns counts of discovered certificates grouped by status.
func (r *DiscoveryRepository) CountByStatus(ctx context.Context) (map[string]int, error) {
query := `SELECT status, COUNT(*) FROM discovered_certificates GROUP BY status`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to count by status: %w", err)
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
counts[status] = count
}
return counts, nil
}
// DiscoveryFilter defines filters for listing discovered certificates.
type DiscoveryFilter struct {
AgentID string
Status string
IsExpired bool
IsCA bool
Page int
PerPage int
}
// nullableString returns a sql.NullString, null if the string is empty.
func nullableString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
+208
View File
@@ -0,0 +1,208 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// DiscoveryService provides business logic for certificate discovery.
type DiscoveryService struct {
discoveryRepo repository.DiscoveryRepository
certRepo repository.CertificateRepository
auditService *AuditService
}
// NewDiscoveryService creates a new discovery service.
func NewDiscoveryService(
discoveryRepo repository.DiscoveryRepository,
certRepo repository.CertificateRepository,
auditService *AuditService,
) *DiscoveryService {
return &DiscoveryService{
discoveryRepo: discoveryRepo,
certRepo: certRepo,
auditService: auditService,
}
}
// ProcessDiscoveryReport processes a discovery report from an agent.
// It creates a scan record, upserts each discovered certificate, and returns scan summary.
func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
if report.AgentID == "" {
return nil, fmt.Errorf("agent_id is required")
}
if len(report.Certificates) == 0 && len(report.Errors) == 0 {
return nil, fmt.Errorf("report must contain at least one certificate or error")
}
now := time.Now()
scan := &domain.DiscoveryScan{
ID: generateID("dscan"),
AgentID: report.AgentID,
Directories: report.Directories,
CertificatesFound: len(report.Certificates),
ErrorsCount: len(report.Errors),
ScanDurationMs: report.ScanDurationMs,
StartedAt: now.Add(-time.Duration(report.ScanDurationMs) * time.Millisecond),
CompletedAt: &now,
}
// Upsert each discovered certificate
newCount := 0
for _, entry := range report.Certificates {
cert := &domain.DiscoveredCertificate{
ID: generateID("dcert"),
FingerprintSHA256: entry.FingerprintSHA256,
CommonName: entry.CommonName,
SANs: entry.SANs,
SerialNumber: entry.SerialNumber,
IssuerDN: entry.IssuerDN,
SubjectDN: entry.SubjectDN,
KeyAlgorithm: entry.KeyAlgorithm,
KeySize: entry.KeySize,
IsCA: entry.IsCA,
PEMData: entry.PEMData,
SourcePath: entry.SourcePath,
SourceFormat: entry.SourceFormat,
AgentID: report.AgentID,
DiscoveryScanID: scan.ID,
Status: domain.DiscoveryStatusUnmanaged,
FirstSeenAt: now,
LastSeenAt: now,
CreatedAt: now,
UpdatedAt: now,
}
// Parse time fields
if entry.NotBefore != "" {
if t, err := time.Parse(time.RFC3339, entry.NotBefore); err == nil {
cert.NotBefore = &t
}
}
if entry.NotAfter != "" {
if t, err := time.Parse(time.RFC3339, entry.NotAfter); err == nil {
cert.NotAfter = &t
}
}
isNew, err := s.discoveryRepo.CreateDiscovered(ctx, cert)
if err != nil {
slog.Error("failed to upsert discovered certificate",
"fingerprint", entry.FingerprintSHA256,
"source_path", entry.SourcePath,
"error", err)
continue
}
if isNew {
newCount++
}
}
scan.CertificatesNew = newCount
// Store the scan record
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
return nil, fmt.Errorf("failed to create scan record: %w", err)
}
// Audit trail
if err := s.auditService.RecordEvent(ctx, report.AgentID, domain.ActorTypeSystem,
"discovery_scan_completed", "discovery_scan", scan.ID,
map[string]interface{}{
"agent_id": report.AgentID,
"directories": report.Directories,
"certificates_found": scan.CertificatesFound,
"certificates_new": newCount,
"errors_count": scan.ErrorsCount,
}); err != nil {
slog.Error("failed to record audit event", "error", err)
}
return scan, nil
}
// ListDiscovered returns discovered certificates matching the filter.
func (s *DiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
filter := &repository.DiscoveryFilter{
AgentID: agentID,
Status: status,
Page: page,
PerPage: perPage,
}
return s.discoveryRepo.ListDiscovered(ctx, filter)
}
// GetDiscovered retrieves a discovered certificate by ID.
func (s *DiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
return s.discoveryRepo.GetDiscovered(ctx, id)
}
// ClaimDiscovered links a discovered certificate to a managed certificate.
func (s *DiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string) error {
if managedCertID == "" {
return fmt.Errorf("managed_certificate_id is required")
}
// Verify the discovered cert exists
disc, err := s.discoveryRepo.GetDiscovered(ctx, id)
if err != nil {
return err
}
// Verify the managed cert exists
if _, err := s.certRepo.Get(ctx, managedCertID); err != nil {
return fmt.Errorf("managed certificate not found: %s", managedCertID)
}
if err := s.discoveryRepo.UpdateDiscoveredStatus(ctx, id, domain.DiscoveryStatusManaged, managedCertID); err != nil {
return err
}
// Audit trail
if err := s.auditService.RecordEvent(ctx, "operator", domain.ActorTypeUser,
"discovery_cert_claimed", "discovered_certificate", id,
map[string]interface{}{
"managed_certificate_id": managedCertID,
"fingerprint": disc.FingerprintSHA256,
"common_name": disc.CommonName,
}); err != nil {
slog.Error("failed to record audit event", "error", err)
}
return nil
}
// DismissDiscovered marks a discovered certificate as dismissed.
func (s *DiscoveryService) DismissDiscovered(ctx context.Context, id string) error {
if err := s.discoveryRepo.UpdateDiscoveredStatus(ctx, id, domain.DiscoveryStatusDismissed, ""); err != nil {
return err
}
// Audit trail
if err := s.auditService.RecordEvent(ctx, "operator", domain.ActorTypeUser,
"discovery_cert_dismissed", "discovered_certificate", id, nil); err != nil {
slog.Error("failed to record audit event", "error", err)
}
return nil
}
// ListScans returns discovery scans, optionally filtered by agent ID.
func (s *DiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
return s.discoveryRepo.ListScans(ctx, agentID, page, perPage)
}
// GetScan retrieves a discovery scan by ID.
func (s *DiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
return s.discoveryRepo.GetScan(ctx, id)
}
// GetDiscoverySummary returns a summary of discovery status counts.
func (s *DiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
return s.discoveryRepo.CountByStatus(ctx)
}
+504
View File
@@ -0,0 +1,504 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// mockDiscoveryRepo is a test implementation of DiscoveryRepository
type mockDiscoveryRepo struct {
Scans map[string]*domain.DiscoveryScan
Discovered map[string]*domain.DiscoveredCertificate
CreateScanErr error
GetScanErr error
ListScansErr error
CreateDiscoveredErr error
GetDiscoveredErr error
ListDiscoveredErr error
UpdateStatusErr error
GetByFingerprintErr error
CountByStatusErr error
}
func newMockDiscoveryRepository() *mockDiscoveryRepo {
return &mockDiscoveryRepo{
Scans: make(map[string]*domain.DiscoveryScan),
Discovered: make(map[string]*domain.DiscoveredCertificate),
}
}
func (m *mockDiscoveryRepo) CreateScan(ctx context.Context, scan *domain.DiscoveryScan) error {
if m.CreateScanErr != nil {
return m.CreateScanErr
}
m.Scans[scan.ID] = scan
return nil
}
func (m *mockDiscoveryRepo) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
if m.GetScanErr != nil {
return nil, m.GetScanErr
}
scan, ok := m.Scans[id]
if !ok {
return nil, errNotFound
}
return scan, nil
}
func (m *mockDiscoveryRepo) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
if m.ListScansErr != nil {
return nil, 0, m.ListScansErr
}
var scans []*domain.DiscoveryScan
for _, s := range m.Scans {
if agentID == "" || s.AgentID == agentID {
scans = append(scans, s)
}
}
return scans, len(scans), nil
}
func (m *mockDiscoveryRepo) CreateDiscovered(ctx context.Context, cert *domain.DiscoveredCertificate) (bool, error) {
if m.CreateDiscoveredErr != nil {
return false, m.CreateDiscoveredErr
}
_, exists := m.Discovered[cert.ID]
m.Discovered[cert.ID] = cert
return !exists, nil // true if new (not existed before)
}
func (m *mockDiscoveryRepo) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
if m.GetDiscoveredErr != nil {
return nil, m.GetDiscoveredErr
}
cert, ok := m.Discovered[id]
if !ok {
return nil, errNotFound
}
return cert, nil
}
func (m *mockDiscoveryRepo) ListDiscovered(ctx context.Context, filter *repository.DiscoveryFilter) ([]*domain.DiscoveredCertificate, int, error) {
if m.ListDiscoveredErr != nil {
return nil, 0, m.ListDiscoveredErr
}
var certs []*domain.DiscoveredCertificate
for _, c := range m.Discovered {
if filter.AgentID != "" && c.AgentID != filter.AgentID {
continue
}
if filter.Status != "" && string(c.Status) != filter.Status {
continue
}
certs = append(certs, c)
}
return certs, len(certs), nil
}
func (m *mockDiscoveryRepo) UpdateDiscoveredStatus(ctx context.Context, id string, status domain.DiscoveryStatus, managedCertID string) error {
if m.UpdateStatusErr != nil {
return m.UpdateStatusErr
}
cert, ok := m.Discovered[id]
if !ok {
return errNotFound
}
cert.Status = status
cert.ManagedCertificateID = managedCertID
now := time.Now()
if status == domain.DiscoveryStatusDismissed {
cert.DismissedAt = &now
}
return nil
}
func (m *mockDiscoveryRepo) GetByFingerprint(ctx context.Context, fingerprint string) ([]*domain.DiscoveredCertificate, error) {
if m.GetByFingerprintErr != nil {
return nil, m.GetByFingerprintErr
}
var certs []*domain.DiscoveredCertificate
for _, c := range m.Discovered {
if c.FingerprintSHA256 == fingerprint {
certs = append(certs, c)
}
}
return certs, nil
}
func (m *mockDiscoveryRepo) CountByStatus(ctx context.Context) (map[string]int, error) {
if m.CountByStatusErr != nil {
return nil, m.CountByStatusErr
}
counts := make(map[string]int)
for _, c := range m.Discovered {
counts[string(c.Status)]++
}
return counts, nil
}
// helper to create a test DiscoveryService wired for discovery tests
func newDiscoveryTestService() (*DiscoveryService, *mockDiscoveryRepo, *mockCertRepo, *mockAuditRepo) {
discoveryRepo := newMockDiscoveryRepository()
certRepo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
discoveryService := NewDiscoveryService(discoveryRepo, certRepo, auditService)
return discoveryService, discoveryRepo, certRepo, auditRepo
}
func TestProcessDiscoveryReport_Success(t *testing.T) {
svc, discoveryRepo, _, auditRepo := newDiscoveryTestService()
report := &domain.DiscoveryReport{
AgentID: "agent-1",
Directories: []string{"/etc/certs", "/opt/certs"},
ScanDurationMs: 150,
Certificates: []domain.DiscoveredCertEntry{
{
FingerprintSHA256: "abc123",
CommonName: "example.com",
SANs: []string{"www.example.com"},
SerialNumber: "001",
IssuerDN: "CN=Let's Encrypt",
SubjectDN: "CN=example.com",
NotBefore: time.Now().AddDate(-1, 0, 0).Format(time.RFC3339),
NotAfter: time.Now().AddDate(1, 0, 0).Format(time.RFC3339),
KeyAlgorithm: "RSA",
KeySize: 2048,
IsCA: false,
PEMData: "-----BEGIN CERTIFICATE-----...",
SourcePath: "/etc/certs/example.com.crt",
SourceFormat: "PEM",
},
},
Errors: []string{},
}
scan, err := svc.ProcessDiscoveryReport(context.Background(), report)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if scan == nil {
t.Fatal("expected scan to be returned")
}
if scan.AgentID != "agent-1" {
t.Errorf("expected agent ID agent-1, got %s", scan.AgentID)
}
if scan.CertificatesFound != 1 {
t.Errorf("expected 1 certificate found, got %d", scan.CertificatesFound)
}
if scan.CertificatesNew != 1 {
t.Errorf("expected 1 new certificate, got %d", scan.CertificatesNew)
}
// Verify scan was persisted
if len(discoveryRepo.Scans) != 1 {
t.Fatalf("expected 1 scan in repo, got %d", len(discoveryRepo.Scans))
}
// Verify discovered cert was persisted
if len(discoveryRepo.Discovered) != 1 {
t.Fatalf("expected 1 discovered cert in repo, got %d", len(discoveryRepo.Discovered))
}
// Verify audit event was recorded
if len(auditRepo.Events) == 0 {
t.Error("expected audit event to be recorded")
}
foundDiscoveryAudit := false
for _, e := range auditRepo.Events {
if e.Action == "discovery_scan_completed" {
foundDiscoveryAudit = true
}
}
if !foundDiscoveryAudit {
t.Error("expected discovery_scan_completed audit event")
}
}
func TestProcessDiscoveryReport_EmptyAgentID(t *testing.T) {
svc, _, _, _ := newDiscoveryTestService()
report := &domain.DiscoveryReport{
AgentID: "", // empty agent ID
Certificates: []domain.DiscoveredCertEntry{
{
FingerprintSHA256: "abc123",
CommonName: "example.com",
},
},
}
_, err := svc.ProcessDiscoveryReport(context.Background(), report)
if err == nil {
t.Fatal("expected error for empty agent_id")
}
if !errors.Is(err, err) { // just verify error occurred
t.Errorf("expected validation error")
}
}
func TestProcessDiscoveryReport_EmptyReport(t *testing.T) {
svc, _, _, _ := newDiscoveryTestService()
report := &domain.DiscoveryReport{
AgentID: "agent-1",
Certificates: []domain.DiscoveredCertEntry{},
Errors: []string{},
ScanDurationMs: 100,
}
_, err := svc.ProcessDiscoveryReport(context.Background(), report)
if err == nil {
t.Fatal("expected error for empty report")
}
}
func TestListDiscovered_Success(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService()
now := time.Now()
cert1 := &domain.DiscoveredCertificate{
ID: "dcert-1",
AgentID: "agent-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
cert2 := &domain.DiscoveredCertificate{
ID: "dcert-2",
AgentID: "agent-1",
CommonName: "api.example.com",
Status: domain.DiscoveryStatusManaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert1.ID] = cert1
discoveryRepo.Discovered[cert2.ID] = cert2
certs, total, err := svc.ListDiscovered(context.Background(), "agent-1", "", 1, 50)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(certs) != 2 {
t.Errorf("expected 2 certs, got %d", len(certs))
}
if total != 2 {
t.Errorf("expected total 2, got %d", total)
}
}
func TestListDiscovered_WithStatusFilter(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService()
now := time.Now()
cert1 := &domain.DiscoveredCertificate{
ID: "dcert-1",
AgentID: "agent-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
cert2 := &domain.DiscoveredCertificate{
ID: "dcert-2",
AgentID: "agent-1",
CommonName: "api.example.com",
Status: domain.DiscoveryStatusManaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert1.ID] = cert1
discoveryRepo.Discovered[cert2.ID] = cert2
certs, total, err := svc.ListDiscovered(context.Background(), "agent-1", "Unmanaged", 1, 50)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(certs) != 1 {
t.Errorf("expected 1 cert, got %d", len(certs))
}
if total != 1 {
t.Errorf("expected total 1, got %d", total)
}
}
func TestGetDiscovered_Success(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService()
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert.ID] = cert
retrieved, err := svc.GetDiscovered(context.Background(), "dcert-1")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if retrieved.ID != "dcert-1" {
t.Errorf("expected ID dcert-1, got %s", retrieved.ID)
}
}
func TestClaimDiscovered_Success(t *testing.T) {
svc, discoveryRepo, certRepo, auditRepo := newDiscoveryTestService()
now := time.Now()
discoveredCert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
FingerprintSHA256: "abc123",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[discoveredCert.ID] = discoveredCert
managedCert := &domain.ManagedCertificate{
ID: "mc-prod-1",
CommonName: "example.com",
Status: domain.CertificateStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
certRepo.AddCert(managedCert)
err := svc.ClaimDiscovered(context.Background(), "dcert-1", "mc-prod-1")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Verify status was updated
updated := discoveryRepo.Discovered["dcert-1"]
if updated.Status != domain.DiscoveryStatusManaged {
t.Errorf("expected status Managed, got %s", updated.Status)
}
if updated.ManagedCertificateID != "mc-prod-1" {
t.Errorf("expected managed cert ID mc-prod-1, got %s", updated.ManagedCertificateID)
}
// Verify audit event was recorded
if len(auditRepo.Events) == 0 {
t.Error("expected audit event to be recorded")
}
foundClaimAudit := false
for _, e := range auditRepo.Events {
if e.Action == "discovery_cert_claimed" {
foundClaimAudit = true
}
}
if !foundClaimAudit {
t.Error("expected discovery_cert_claimed audit event")
}
}
func TestClaimDiscovered_MissingManagedCertID(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService()
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert.ID] = cert
err := svc.ClaimDiscovered(context.Background(), "dcert-1", "")
if err == nil {
t.Fatal("expected error for empty managed_certificate_id")
}
}
func TestClaimDiscovered_ManagedCertNotFound(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService()
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert.ID] = cert
err := svc.ClaimDiscovered(context.Background(), "dcert-1", "nonexistent-cert")
if err == nil {
t.Fatal("expected error for nonexistent managed certificate")
}
if !errors.Is(err, err) { // just verify error occurred
t.Errorf("expected 'not found' error")
}
}
func TestDismissDiscovered_Success(t *testing.T) {
svc, discoveryRepo, _, auditRepo := newDiscoveryTestService()
now := time.Now()
cert := &domain.DiscoveredCertificate{
ID: "dcert-1",
CommonName: "example.com",
Status: domain.DiscoveryStatusUnmanaged,
CreatedAt: now,
UpdatedAt: now,
}
discoveryRepo.Discovered[cert.ID] = cert
err := svc.DismissDiscovered(context.Background(), "dcert-1")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Verify status was updated
updated := discoveryRepo.Discovered["dcert-1"]
if updated.Status != domain.DiscoveryStatusDismissed {
t.Errorf("expected status Dismissed, got %s", updated.Status)
}
if updated.DismissedAt == nil {
t.Error("expected DismissedAt to be set")
}
// Verify audit event was recorded
if len(auditRepo.Events) == 0 {
t.Error("expected audit event to be recorded")
}
foundDismissAudit := false
for _, e := range auditRepo.Events {
if e.Action == "discovery_cert_dismissed" {
foundDismissAudit = true
}
}
if !foundDismissAudit {
t.Error("expected discovery_cert_dismissed audit event")
}
}
func TestDismissDiscovered_NotFound(t *testing.T) {
svc, discoveryRepo, _, _ := newDiscoveryTestService()
discoveryRepo.UpdateStatusErr = errNotFound
err := svc.DismissDiscovered(context.Background(), "nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent cert")
}
}
+3
View File
@@ -0,0 +1,3 @@
-- Rollback Migration 000006: Filesystem Certificate Discovery
DROP TABLE IF EXISTS discovered_certificates;
DROP TABLE IF EXISTS discovery_scans;
+59
View File
@@ -0,0 +1,59 @@
-- Migration 000006: Filesystem Certificate Discovery
-- Agents scan configured directories for existing certificates and report to the control plane.
-- The control plane deduplicates by SHA-256 fingerprint and stores discovery metadata.
-- Discovery scans track each scan run by an agent
CREATE TABLE IF NOT EXISTS discovery_scans (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL REFERENCES agents(id),
directories TEXT[] NOT NULL,
certificates_found INTEGER NOT NULL DEFAULT 0,
certificates_new INTEGER NOT NULL DEFAULT 0,
errors_count INTEGER NOT NULL DEFAULT 0,
scan_duration_ms INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_discovery_scans_agent_id ON discovery_scans(agent_id);
CREATE INDEX IF NOT EXISTS idx_discovery_scans_started_at ON discovery_scans(started_at DESC);
-- Discovered certificates store certs found on agent filesystems
CREATE TABLE IF NOT EXISTS discovered_certificates (
id TEXT PRIMARY KEY,
fingerprint_sha256 TEXT NOT NULL,
common_name TEXT NOT NULL DEFAULT '',
sans TEXT[] DEFAULT '{}',
serial_number TEXT NOT NULL DEFAULT '',
issuer_dn TEXT NOT NULL DEFAULT '',
subject_dn TEXT NOT NULL DEFAULT '',
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
key_algorithm TEXT NOT NULL DEFAULT '',
key_size INTEGER NOT NULL DEFAULT 0,
is_ca BOOLEAN NOT NULL DEFAULT FALSE,
pem_data TEXT NOT NULL DEFAULT '',
source_path TEXT NOT NULL DEFAULT '',
source_format TEXT NOT NULL DEFAULT 'PEM',
agent_id TEXT NOT NULL REFERENCES agents(id),
discovery_scan_id TEXT REFERENCES discovery_scans(id),
managed_certificate_id TEXT REFERENCES managed_certificates(id),
status TEXT NOT NULL DEFAULT 'Unmanaged',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
dismissed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Unique constraint: same fingerprint on same agent at same path
CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_certs_fingerprint_agent_path
ON discovered_certificates(fingerprint_sha256, agent_id, source_path);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_discovered_certs_agent_id ON discovered_certificates(agent_id);
CREATE INDEX IF NOT EXISTS idx_discovered_certs_status ON discovered_certificates(status);
CREATE INDEX IF NOT EXISTS idx_discovered_certs_fingerprint ON discovered_certificates(fingerprint_sha256);
CREATE INDEX IF NOT EXISTS idx_discovered_certs_not_after ON discovered_certificates(not_after);
CREATE INDEX IF NOT EXISTS idx_discovered_certs_managed_id ON discovered_certificates(managed_certificate_id)
WHERE managed_certificate_id IS NOT NULL;