mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 11:11:30 +00:00
Implement M3: expiration threshold alerting with dedup and status transitions
- Add alert_thresholds_days JSONB column to renewal_policies (default [30,14,7,0]) - Add RenewalPolicy.AlertThresholdsDays field + EffectiveAlertThresholds() helper - Add RenewalPolicyRepository interface + postgres implementation - Rewrite CheckExpiringCertificates with per-policy threshold alerting - Add SendThresholdAlert + HasThresholdNotification for deduplication via [threshold:N] tags - Add Type and MessageLike filters to NotificationFilter + postgres query support - Auto-transition certs to Expiring (>0 days) or Expired (<=0 days) status - Record expiration_alert_sent audit events per threshold crossing - Fix .gitignore: allow SQL migration files, scope server/agent build artifact rules - Track previously untracked cmd/ and migrations/ directories - Update docs (README, architecture, demo-advanced) for threshold alerting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+5
-3
@@ -44,13 +44,15 @@ temp/
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.sql
|
||||
|
||||
# Allow migration SQL files (don't ignore *.sql globally)
|
||||
# SQL files in migrations/ are tracked
|
||||
|
||||
# Build artifacts
|
||||
certctl-server
|
||||
certctl-agent
|
||||
server
|
||||
agent
|
||||
/server
|
||||
/agent
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -126,7 +126,7 @@ flowchart TB
|
||||
|-------|---------|
|
||||
| `managed_certificates` | Certificate records with metadata, status, expiry, tags |
|
||||
| `certificate_versions` | Historical versions with PEM chains and CSRs |
|
||||
| `renewal_policies` | Renewal window, auto-renew settings, retry config |
|
||||
| `renewal_policies` | Renewal window, auto-renew settings, retry config, alert thresholds |
|
||||
| `issuers` | CA configurations (Local CA, ACME, etc.) |
|
||||
| `deployment_targets` | Target systems (NGINX, F5, IIS) with agent assignments |
|
||||
| `agents` | Registered agents with heartbeat tracking |
|
||||
@@ -309,7 +309,7 @@ make docker-clean # Stop + remove volumes
|
||||
|
||||
Summary:
|
||||
|
||||
- **V1 (current)**: Dashboard, inventory, alerting, Local CA issuer (end-to-end lifecycle wired), NGINX/F5/IIS target connectors, agents with work polling, REST API (40+ endpoints), policies, audit trail, Docker Compose
|
||||
- **V1 (current)**: Dashboard, inventory, threshold-based expiration alerting (30/14/7/0 days with dedup), Local CA issuer (end-to-end lifecycle wired), ACME v2 (HTTP-01), NGINX/F5/IIS target connectors, agents with work polling, REST API (40+ endpoints), policies, audit trail, Docker Compose
|
||||
- **V2**: Charts/trends, bulk import, OIDC/SSO, deployment rollback, CLI, Slack/Teams
|
||||
- **V3**: Certificate discovery, network scanning, unknown cert detection
|
||||
- **V4+**: Kubernetes CRD, Terraform provider, multi-region, HA control plane, HSM support
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
||||
)
|
||||
|
||||
// AgentConfig represents the agent-side configuration.
|
||||
type AgentConfig struct {
|
||||
ServerURL string // Control plane server URL (e.g., http://localhost:8443)
|
||||
APIKey string // Agent API key for authentication
|
||||
AgentName string // Agent name for identification
|
||||
AgentID string // Agent ID for API calls (set after registration or from env)
|
||||
Hostname string // Server hostname
|
||||
}
|
||||
|
||||
// Agent represents the local agent that runs on target servers.
|
||||
// It periodically sends heartbeats, polls for work, and executes deployment jobs.
|
||||
type Agent struct {
|
||||
config *AgentConfig
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
|
||||
// Configuration
|
||||
heartbeatInterval time.Duration
|
||||
pollInterval time.Duration
|
||||
}
|
||||
|
||||
// WorkResponse represents the response from the work polling endpoint.
|
||||
type WorkResponse struct {
|
||||
Jobs []JobItem `json:"jobs"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// JobItem represents a job returned from the control plane, enriched with target details.
|
||||
type JobItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetConfig json.RawMessage `json:"target_config,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent instance.
|
||||
func NewAgent(cfg *AgentConfig, logger *slog.Logger) *Agent {
|
||||
return &Agent{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
heartbeatInterval: 60 * time.Second,
|
||||
pollInterval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the agent's main loop.
|
||||
// It sends heartbeats, polls for work, and handles graceful shutdown via context cancellation.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
a.logger.Info("agent starting",
|
||||
"server_url", a.config.ServerURL,
|
||||
"agent_name", a.config.AgentName,
|
||||
"agent_id", a.config.AgentID)
|
||||
|
||||
// Create ticker channels for heartbeat and polling
|
||||
heartbeatTicker := time.NewTicker(a.heartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
pollTicker := time.NewTicker(a.pollInterval)
|
||||
defer pollTicker.Stop()
|
||||
|
||||
// Run initial heartbeat and poll
|
||||
a.sendHeartbeat(ctx)
|
||||
a.pollForWork(ctx)
|
||||
|
||||
// Main event loop
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.logger.Info("agent shutting down", "reason", ctx.Err())
|
||||
return ctx.Err()
|
||||
|
||||
case <-heartbeatTicker.C:
|
||||
a.sendHeartbeat(ctx)
|
||||
|
||||
case <-pollTicker.C:
|
||||
a.pollForWork(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the control plane.
|
||||
// POST /api/v1/agents/{agentID}/heartbeat
|
||||
func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||
a.logger.Debug("sending heartbeat", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/heartbeat", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, map[string]string{
|
||||
"version": "1.0.0",
|
||||
"hostname": a.config.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("heartbeat failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("heartbeat rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("heartbeat acknowledged")
|
||||
}
|
||||
|
||||
// pollForWork queries the control plane for pending deployment jobs and processes them.
|
||||
// GET /api/v1/agents/{agentID}/work
|
||||
func (a *Agent) pollForWork(ctx context.Context) {
|
||||
a.logger.Debug("polling for work", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/work", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("work poll failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("work poll rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
var workResp WorkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&workResp); err != nil {
|
||||
a.logger.Error("failed to decode work response", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if workResp.Count == 0 {
|
||||
a.logger.Debug("no pending work")
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("received work", "job_count", workResp.Count)
|
||||
|
||||
// Process each job
|
||||
for _, job := range workResp.Jobs {
|
||||
if job.Type == "Deployment" {
|
||||
a.executeDeploymentJob(ctx, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeDeploymentJob executes a deployment job by fetching the certificate and deploying it
|
||||
// to the target system using the appropriate connector (NGINX, F5 BIG-IP, or IIS).
|
||||
//
|
||||
// Flow:
|
||||
// 1. Report job as Running
|
||||
// 2. Fetch the certificate PEM from the control plane
|
||||
// 3. Instantiate the target connector based on target_type from the work response
|
||||
// 4. Call DeployCertificate on the connector
|
||||
// 5. Report job as Completed (or Failed)
|
||||
func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing deployment job",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"target_type", job.TargetType)
|
||||
|
||||
// Report job as running
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Running", ""); err != nil {
|
||||
a.logger.Error("failed to report job running", "error", err)
|
||||
}
|
||||
|
||||
// Fetch the certificate from the control plane
|
||||
certPEM, err := a.fetchCertificate(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to fetch certificate",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
_ = a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("cert fetch failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("certificate fetched for deployment",
|
||||
"job_id", job.ID,
|
||||
"cert_length", len(certPEM))
|
||||
|
||||
// Split PEM into cert and chain (separated by double newline between PEM blocks)
|
||||
certOnly, chainPEM := splitPEMChain(certPEM)
|
||||
|
||||
// Deploy to the target using the appropriate connector
|
||||
if job.TargetType != "" {
|
||||
connector, err := a.createTargetConnector(job.TargetType, job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create target connector",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
_ = a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("connector init failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
deployReq := target.DeploymentRequest{
|
||||
CertPEM: certOnly,
|
||||
ChainPEM: chainPEM,
|
||||
TargetConfig: job.TargetConfig,
|
||||
Metadata: map[string]string{
|
||||
"certificate_id": job.CertificateID,
|
||||
"job_id": job.ID,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, deployReq)
|
||||
if err != nil {
|
||||
a.logger.Error("deployment failed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
_ = a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("deployment failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("target connector deployment completed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"success", result.Success,
|
||||
"message", result.Message)
|
||||
} else {
|
||||
a.logger.Info("no target type specified, skipping connector invocation",
|
||||
"job_id", job.ID)
|
||||
}
|
||||
|
||||
// Report job as completed
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Completed", ""); err != nil {
|
||||
a.logger.Error("failed to report job completed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("deployment job completed", "job_id", job.ID)
|
||||
}
|
||||
|
||||
// createTargetConnector instantiates the appropriate target connector based on type.
|
||||
func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMessage) (target.Connector, error) {
|
||||
switch targetType {
|
||||
case "NGINX":
|
||||
var cfg nginx.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid NGINX config: %w", err)
|
||||
}
|
||||
}
|
||||
return nginx.New(&cfg, a.logger), nil
|
||||
|
||||
case "F5":
|
||||
var cfg f5.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
||||
}
|
||||
}
|
||||
return f5.New(&cfg, a.logger), nil
|
||||
|
||||
case "IIS":
|
||||
var cfg iis.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid IIS config: %w", err)
|
||||
}
|
||||
}
|
||||
return iis.New(&cfg, a.logger), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// splitPEMChain splits a PEM chain into the first certificate (cert) and the rest (chain).
|
||||
// The control plane returns the full chain as a single string with PEM blocks concatenated.
|
||||
func splitPEMChain(pemChain string) (string, string) {
|
||||
const endCert = "-----END CERTIFICATE-----"
|
||||
idx := 0
|
||||
count := 0
|
||||
for i := 0; i < len(pemChain); i++ {
|
||||
if i+len(endCert) <= len(pemChain) && pemChain[i:i+len(endCert)] == endCert {
|
||||
count++
|
||||
if count == 1 {
|
||||
idx = i + len(endCert)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx == 0 || idx >= len(pemChain) {
|
||||
return pemChain, ""
|
||||
}
|
||||
cert := pemChain[:idx] + "\n"
|
||||
chain := ""
|
||||
// Skip whitespace between cert and chain
|
||||
for idx < len(pemChain) && (pemChain[idx] == '\n' || pemChain[idx] == '\r' || pemChain[idx] == ' ') {
|
||||
idx++
|
||||
}
|
||||
if idx < len(pemChain) {
|
||||
chain = pemChain[idx:]
|
||||
}
|
||||
return cert, chain
|
||||
}
|
||||
|
||||
// fetchCertificate retrieves the certificate PEM chain from the control plane.
|
||||
// GET /api/v1/agents/{agentID}/certificates/{certID}
|
||||
func (a *Agent) fetchCertificate(ctx context.Context, certID string) (string, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/certificates/%s", a.config.AgentID, certID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var certResp struct {
|
||||
CertificatePEM string `json:"certificate_pem"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&certResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return certResp.CertificatePEM, nil
|
||||
}
|
||||
|
||||
// reportJobStatus reports the result of a job back to the control plane.
|
||||
// POST /api/v1/agents/{agentID}/jobs/{jobID}/status
|
||||
func (a *Agent) reportJobStatus(ctx context.Context, jobID string, status string, errorMsg string) error {
|
||||
a.logger.Debug("reporting job status",
|
||||
"job_id", jobID,
|
||||
"status", status)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/jobs/%s/status", a.config.AgentID, jobID)
|
||||
payload := map[string]string{
|
||||
"status": status,
|
||||
}
|
||||
if errorMsg != "" {
|
||||
payload["error"] = errorMsg
|
||||
}
|
||||
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("status report failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
a.logger.Debug("job status reported", "job_id", jobID, "status", status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeRequest is a helper for making authenticated HTTP requests to the control plane.
|
||||
// It includes the API key in the Authorization header.
|
||||
func (a *Agent) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
|
||||
url := fmt.Sprintf("%s%s", a.config.ServerURL, path)
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.config.APIKey))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 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")
|
||||
apiKey := flag.String("api-key", getEnvDefault("CERTCTL_API_KEY", ""), "Agent API key")
|
||||
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)")
|
||||
flag.Parse()
|
||||
|
||||
if *apiKey == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: -api-key flag or CERTCTL_API_KEY env var is required\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *agentID == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: -agent-id flag or CERTCTL_AGENT_ID env var is required\n")
|
||||
fmt.Fprintf(os.Stderr, "Register an agent first via POST /api/v1/agents\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set up structured logging
|
||||
logLevel := slog.LevelInfo
|
||||
if getEnvDefault("CERTCTL_LOG_LEVEL", "info") == "debug" {
|
||||
logLevel = slog.LevelDebug
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: logLevel,
|
||||
}))
|
||||
|
||||
// Get hostname
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = "unknown"
|
||||
}
|
||||
|
||||
// Create agent configuration
|
||||
agentCfg := &AgentConfig{
|
||||
ServerURL: *serverURL,
|
||||
APIKey: *apiKey,
|
||||
AgentName: *agentName,
|
||||
AgentID: *agentID,
|
||||
Hostname: hostname,
|
||||
}
|
||||
|
||||
// Create and start agent
|
||||
agent := NewAgent(agentCfg, logger)
|
||||
|
||||
// Create context with cancellation for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Set up signal handling
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Run agent in background
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- agent.Run(ctx)
|
||||
}()
|
||||
|
||||
// Wait for signal or agent error
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
logger.Info("received shutdown signal", "signal", sig.String())
|
||||
cancel()
|
||||
<-errChan
|
||||
case err := <-errChan:
|
||||
if err != context.Canceled {
|
||||
logger.Error("agent error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("agent stopped")
|
||||
}
|
||||
|
||||
// getEnvDefault reads an environment variable with a fallback default value.
|
||||
func getEnvDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/handler"
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/api/router"
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
"github.com/shankar0123/certctl/internal/repository/postgres"
|
||||
"github.com/shankar0123/certctl/internal/scheduler"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set up structured logging
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: cfg.GetLogLevel(),
|
||||
}))
|
||||
|
||||
logger.Info("certctl server starting",
|
||||
"version", "0.1.0",
|
||||
"server_host", cfg.Server.Host,
|
||||
"server_port", cfg.Server.Port)
|
||||
|
||||
// Initialize database connection pool
|
||||
db, err := postgres.NewDB(cfg.Database.URL)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
logger.Info("connected to database")
|
||||
|
||||
// Run migrations
|
||||
logger.Info("running migrations", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunMigrations(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("migrations completed")
|
||||
|
||||
// Initialize repositories with real PostgreSQL connection
|
||||
auditRepo := postgres.NewAuditRepository(db)
|
||||
certificateRepo := postgres.NewCertificateRepository(db)
|
||||
issuerRepo := postgres.NewIssuerRepository(db)
|
||||
targetRepo := postgres.NewTargetRepository(db)
|
||||
agentRepo := postgres.NewAgentRepository(db)
|
||||
jobRepo := postgres.NewJobRepository(db)
|
||||
policyRepo := postgres.NewPolicyRepository(db)
|
||||
notificationRepo := postgres.NewNotificationRepository(db)
|
||||
renewalPolicyRepo := postgres.NewRenewalPolicyRepository(db)
|
||||
teamRepo := postgres.NewTeamRepository(db)
|
||||
ownerRepo := postgres.NewOwnerRepository(db)
|
||||
logger.Info("initialized all repositories")
|
||||
|
||||
// Initialize Local CA issuer connector
|
||||
// This provides in-memory certificate signing for development, testing, and demo.
|
||||
// The CA is ephemeral (regenerated on restart) and NOT suitable for production.
|
||||
localCA := local.New(nil, logger)
|
||||
logger.Info("initialized Local CA issuer connector")
|
||||
|
||||
// Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.)
|
||||
// The ACME connector is registered but only activated when an issuer record
|
||||
// in the database references it. Configuration comes from the issuer's config JSON.
|
||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||
}, logger)
|
||||
logger.Info("initialized ACME issuer connector")
|
||||
|
||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||
issuerRegistry := map[string]service.IssuerConnector{
|
||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
||||
"iss-acme-staging": service.NewIssuerConnectorAdapter(acmeConnector),
|
||||
"iss-acme-prod": service.NewIssuerConnectorAdapter(acmeConnector),
|
||||
}
|
||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||
|
||||
// Initialize services (following the dependency graph)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
certificateService := service.NewCertificateService(certificateRepo, policyService, auditService)
|
||||
notificationService := service.NewNotificationService(notificationRepo, make(map[string]service.Notifier))
|
||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, auditService, notificationService, issuerRegistry)
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||
targetService := service.NewTargetService(targetRepo, auditService)
|
||||
teamService := service.NewTeamService(teamRepo, auditService)
|
||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||
logger.Info("initialized all services")
|
||||
|
||||
// Initialize API handlers
|
||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||
targetHandler := handler.NewTargetHandler(targetService)
|
||||
agentHandler := handler.NewAgentHandler(agentService)
|
||||
jobHandler := handler.NewJobHandler(jobService)
|
||||
policyHandler := handler.NewPolicyHandler(policyService)
|
||||
teamHandler := handler.NewTeamHandler(teamService)
|
||||
ownerHandler := handler.NewOwnerHandler(ownerService)
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
healthHandler := handler.NewHealthHandler()
|
||||
logger.Info("initialized all handlers")
|
||||
|
||||
// Create context with cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Initialize scheduler
|
||||
sched := scheduler.NewScheduler(
|
||||
renewalService,
|
||||
jobService,
|
||||
agentService,
|
||||
notificationService,
|
||||
logger,
|
||||
)
|
||||
|
||||
// Configure scheduler intervals from config
|
||||
sched.SetRenewalCheckInterval(cfg.Scheduler.RenewalCheckInterval)
|
||||
sched.SetJobProcessorInterval(cfg.Scheduler.JobProcessorInterval)
|
||||
sched.SetAgentHealthCheckInterval(cfg.Scheduler.AgentHealthCheckInterval)
|
||||
sched.SetNotificationProcessInterval(cfg.Scheduler.NotificationProcessInterval)
|
||||
|
||||
// Start scheduler
|
||||
logger.Info("starting scheduler")
|
||||
startedChan := sched.Start(ctx)
|
||||
<-startedChan
|
||||
logger.Info("scheduler started")
|
||||
|
||||
// Build the API router with all handlers
|
||||
apiRouter := router.New()
|
||||
apiRouter.RegisterHandlers(
|
||||
certificateHandler,
|
||||
issuerHandler,
|
||||
targetHandler,
|
||||
agentHandler,
|
||||
jobHandler,
|
||||
policyHandler,
|
||||
teamHandler,
|
||||
ownerHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
healthHandler,
|
||||
)
|
||||
logger.Info("registered all API handlers")
|
||||
|
||||
// Apply middleware to API router
|
||||
apiHandler := middleware.Chain(
|
||||
apiRouter,
|
||||
middleware.RequestID,
|
||||
middleware.Logging,
|
||||
middleware.Recovery,
|
||||
)
|
||||
|
||||
// Wrap with dashboard static file serving if web/ directory exists
|
||||
var finalHandler http.Handler
|
||||
webDir := "./web"
|
||||
if _, err := os.Stat(webDir); err == nil {
|
||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
// API and health routes go to the API handler
|
||||
if path == "/health" || path == "/ready" ||
|
||||
(len(path) >= 8 && path[:8] == "/api/v1/") {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Serve the dashboard SPA index.html for everything else
|
||||
http.ServeFile(w, r, webDir+"/index.html")
|
||||
})
|
||||
logger.Info("dashboard available at /")
|
||||
} else {
|
||||
finalHandler = apiHandler
|
||||
logger.Info("dashboard directory not found, serving API only")
|
||||
}
|
||||
|
||||
// Server configuration
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server in background
|
||||
logger.Info("starting HTTP server", "address", addr)
|
||||
go func() {
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("HTTP server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
sig := <-sigChan
|
||||
logger.Info("received shutdown signal", "signal", sig.String())
|
||||
|
||||
// Graceful shutdown
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
cancel() // Stop scheduler
|
||||
|
||||
logger.Info("shutting down HTTP server")
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("error closing database connection", "error", err)
|
||||
}
|
||||
|
||||
logger.Info("certctl server stopped")
|
||||
}
|
||||
@@ -315,7 +315,11 @@ flowchart LR
|
||||
| Agent health check | 2 minutes | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | Sends pending notifications via configured channels |
|
||||
|
||||
When the renewal checker finds a certificate within its renewal window (e.g., 30 days before expiry), it creates a renewal job. The job processor picks it up, coordinates with the issuer, and triggers deployment. All steps are logged in the audit trail and generate notifications.
|
||||
When the renewal checker finds a certificate within its renewal window, it performs two tasks: threshold-based alerting and renewal job creation.
|
||||
|
||||
**Threshold-Based Expiration Alerting**: Each renewal policy defines configurable alert thresholds (default: 30, 14, 7, 0 days before expiry). For each certificate approaching expiry, the scheduler checks which thresholds have been crossed and sends deduplicated notifications. A certificate that crosses the 14-day threshold only gets one 14-day alert, even though the renewal checker runs every hour. Deduplication is tracked via threshold tags embedded in the notification message and queried with the `MessageLike` filter. Certificates are also transitioned to `Expiring` status when they enter the alert window and `Expired` when they hit 0 days.
|
||||
|
||||
**Renewal Job Creation**: If the certificate's issuer has a registered connector, the scheduler creates a renewal job. The job processor picks it up, coordinates with the issuer, and triggers deployment. All steps are logged in the audit trail and generate notifications.
|
||||
|
||||
## Connector Architecture
|
||||
|
||||
|
||||
@@ -383,7 +383,9 @@ Certctl sends notifications for certificate lifecycle events. Check what notific
|
||||
curl -s $API/api/v1/notifications | jq '.data[0:5]'
|
||||
```
|
||||
|
||||
**How it works:** The `NotificationService` generates notification records in the `notification_events` table whenever significant events occur — certificate creation, expiration warnings, renewal success/failure, deployment results, policy violations. Each notification has a `channel` (Email, Webhook) and a `recipient`.
|
||||
**How it works:** The `NotificationService` generates notification records in the `notification_events` table whenever significant events occur — expiration warnings at configurable thresholds (30, 14, 7, 0 days by default), renewal success/failure, deployment results, and policy violations. Each notification has a `channel` (Email, Webhook) and a `recipient`.
|
||||
|
||||
**Threshold-Based Alerting:** Each renewal policy defines configurable alert thresholds via the `alert_thresholds_days` field (e.g., `[30, 14, 7, 0]` for the standard policy, `[14, 7, 3, 0]` for the urgent policy). The scheduler checks which thresholds each certificate has crossed and sends one notification per threshold, deduplicated so the same alert is never sent twice. Certificates are automatically transitioned to `Expiring` status when entering the alert window and `Expired` when they hit 0 days.
|
||||
|
||||
The notification processor loop runs every 60 seconds and processes pending notifications:
|
||||
|
||||
@@ -431,7 +433,7 @@ curl -s -X POST $API/api/v1/certificates \
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
**How it works:** This certificate is created with status `Active` and an explicit `expires_at` 18 days from now. The scheduler's renewal checker will flag this certificate when it runs because `expires_at - now() < 30 days` (the default renewal window in `rp-default`). It would transition the status to `Expiring` and create a renewal job.
|
||||
**How it works:** This certificate is created with status `Active` and an explicit `expires_at` 18 days from now. The scheduler's renewal checker will flag this certificate when it runs because `expires_at - now() < 30 days` (the default renewal window in `rp-default`). It would transition the status to `Expiring`, send deduplicated threshold alerts at 30 and 14 days (since both thresholds have been crossed), and create a renewal job.
|
||||
|
||||
**Why `environment` matters:** The environment field isn't just metadata — it feeds the policy engine. A policy rule with type `AllowedEnvironments` can restrict which environments are valid. If someone tries to create a certificate with `environment: "yolo"`, the policy engine flags a violation. In a mature deployment, you'd enforce policies strictly: production certificates must use a trusted CA (not Local CA), staging certificates can use Let's Encrypt staging, and development certificates can use the Local CA.
|
||||
|
||||
|
||||
@@ -54,12 +54,26 @@ const (
|
||||
|
||||
// RenewalPolicy defines renewal parameters for a managed certificate.
|
||||
type RenewalPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RenewalWindowDays int `json:"renewal_window_days"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
RetryInterval int `json:"retry_interval_seconds"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RenewalWindowDays int `json:"renewal_window_days"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
RetryInterval int `json:"retry_interval_seconds"`
|
||||
AlertThresholdsDays []int `json:"alert_thresholds_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DefaultAlertThresholds returns the standard alert thresholds when none are configured.
|
||||
func DefaultAlertThresholds() []int {
|
||||
return []int{30, 14, 7, 0}
|
||||
}
|
||||
|
||||
// EffectiveAlertThresholds returns the configured thresholds or defaults if empty.
|
||||
func (p *RenewalPolicy) EffectiveAlertThresholds() []int {
|
||||
if len(p.AlertThresholdsDays) > 0 {
|
||||
return p.AlertThresholdsDays
|
||||
}
|
||||
return DefaultAlertThresholds()
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ type AuditFilter struct {
|
||||
// NotificationFilter defines filtering criteria for notification queries.
|
||||
type NotificationFilter struct {
|
||||
CertificateID string // optional: filter by certificate
|
||||
Type string // optional: filter by notification type (e.g., "ExpirationWarning")
|
||||
Status string // e.g., "pending", "sent", "failed"
|
||||
Channel string // e.g., "email", "slack", "webhook"
|
||||
MessageLike string // optional: LIKE match on message content (for threshold dedup)
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@ type JobRepository interface {
|
||||
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
||||
}
|
||||
|
||||
// RenewalPolicyRepository defines operations for managing renewal policies.
|
||||
type RenewalPolicyRepository interface {
|
||||
// Get retrieves a renewal policy by ID.
|
||||
Get(ctx context.Context, id string) (*domain.RenewalPolicy, error)
|
||||
// List returns all renewal policies.
|
||||
List(ctx context.Context) ([]*domain.RenewalPolicy, error)
|
||||
}
|
||||
|
||||
// PolicyRepository defines operations for managing compliance policies and violations.
|
||||
type PolicyRepository interface {
|
||||
// ListRules returns all policy rules.
|
||||
|
||||
@@ -67,11 +67,21 @@ func (r *NotificationRepository) List(ctx context.Context, filter *repository.No
|
||||
args = append(args, filter.CertificateID)
|
||||
argCount++
|
||||
}
|
||||
if filter.Type != "" {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("type = $%d", argCount))
|
||||
args = append(args, filter.Type)
|
||||
argCount++
|
||||
}
|
||||
if filter.Status != "" {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argCount))
|
||||
args = append(args, filter.Status)
|
||||
argCount++
|
||||
}
|
||||
if filter.MessageLike != "" {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("message LIKE $%d", argCount))
|
||||
args = append(args, filter.MessageLike)
|
||||
argCount++
|
||||
}
|
||||
if filter.Channel != "" {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("channel = $%d", argCount))
|
||||
args = append(args, filter.Channel)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// RenewalPolicyRepository implements repository.RenewalPolicyRepository
|
||||
type RenewalPolicyRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewRenewalPolicyRepository creates a new RenewalPolicyRepository
|
||||
func NewRenewalPolicyRepository(db *sql.DB) *RenewalPolicyRepository {
|
||||
return &RenewalPolicyRepository{db: db}
|
||||
}
|
||||
|
||||
// Get retrieves a renewal policy by ID
|
||||
func (r *RenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
|
||||
var policy domain.RenewalPolicy
|
||||
var thresholdsJSON []byte
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, renewal_window_days, auto_renew, max_retries,
|
||||
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
|
||||
FROM renewal_policies
|
||||
WHERE id = $1
|
||||
`, id).Scan(&policy.ID, &policy.Name, &policy.RenewalWindowDays, &policy.AutoRenew,
|
||||
&policy.MaxRetries, &policy.RetryInterval, &thresholdsJSON,
|
||||
&policy.CreatedAt, &policy.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("renewal policy not found: %s", id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query renewal policy: %w", err)
|
||||
}
|
||||
|
||||
// Parse alert thresholds from JSONB
|
||||
if len(thresholdsJSON) > 0 {
|
||||
if err := json.Unmarshal(thresholdsJSON, &policy.AlertThresholdsDays); err != nil {
|
||||
// Fall back to defaults if JSON is malformed
|
||||
policy.AlertThresholdsDays = domain.DefaultAlertThresholds()
|
||||
}
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// List returns all renewal policies
|
||||
func (r *RenewalPolicyRepository) List(ctx context.Context) ([]*domain.RenewalPolicy, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, renewal_window_days, auto_renew, max_retries,
|
||||
retry_interval_minutes, alert_thresholds_days, created_at, updated_at
|
||||
FROM renewal_policies
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query renewal policies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var policies []*domain.RenewalPolicy
|
||||
for rows.Next() {
|
||||
var policy domain.RenewalPolicy
|
||||
var thresholdsJSON []byte
|
||||
|
||||
if err := rows.Scan(&policy.ID, &policy.Name, &policy.RenewalWindowDays, &policy.AutoRenew,
|
||||
&policy.MaxRetries, &policy.RetryInterval, &thresholdsJSON,
|
||||
&policy.CreatedAt, &policy.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan renewal policy: %w", err)
|
||||
}
|
||||
|
||||
if len(thresholdsJSON) > 0 {
|
||||
if err := json.Unmarshal(thresholdsJSON, &policy.AlertThresholdsDays); err != nil {
|
||||
policy.AlertThresholdsDays = domain.DefaultAlertThresholds()
|
||||
}
|
||||
}
|
||||
|
||||
policies = append(policies, &policy)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating renewal policy rows: %w", err)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
@@ -34,12 +34,26 @@ func NewNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
// SendExpirationWarning sends a certificate expiration warning.
|
||||
// SendExpirationWarning sends a certificate expiration warning for a specific threshold.
|
||||
func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error {
|
||||
body := fmt.Sprintf(
|
||||
"The certificate for %s will expire in %d days (%s).\n\nPlease schedule renewal.",
|
||||
cert.CommonName, daysUntilExpiry, cert.ExpiresAt.Format("2006-01-02"),
|
||||
)
|
||||
return s.SendThresholdAlert(ctx, cert, daysUntilExpiry, daysUntilExpiry)
|
||||
}
|
||||
|
||||
// SendThresholdAlert sends an expiration alert for a specific threshold (e.g., 30-day, 14-day, expired).
|
||||
// The threshold parameter indicates which configured threshold triggered the alert.
|
||||
func (s *NotificationService) SendThresholdAlert(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int, threshold int) error {
|
||||
var body string
|
||||
if threshold <= 0 {
|
||||
body = fmt.Sprintf(
|
||||
"[EXPIRED] The certificate for %s has expired (%s).\n\nImmediate action required.\n\n[threshold:%d]",
|
||||
cert.CommonName, cert.ExpiresAt.Format("2006-01-02"), threshold,
|
||||
)
|
||||
} else {
|
||||
body = fmt.Sprintf(
|
||||
"The certificate for %s will expire in %d days (%s).\n\nPlease schedule renewal.\n\n[threshold:%d]",
|
||||
cert.CommonName, daysUntilExpiry, cert.ExpiresAt.Format("2006-01-02"), threshold,
|
||||
)
|
||||
}
|
||||
|
||||
// Create notification record
|
||||
notif := &domain.NotificationEvent{
|
||||
@@ -61,6 +75,24 @@ func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *d
|
||||
return s.sendNotification(ctx, notif)
|
||||
}
|
||||
|
||||
// HasThresholdNotification checks whether an expiration warning has already been sent
|
||||
// for a specific certificate and threshold combination. Used for deduplication.
|
||||
func (s *NotificationService) HasThresholdNotification(ctx context.Context, certID string, threshold int) (bool, error) {
|
||||
filter := &repository.NotificationFilter{
|
||||
CertificateID: certID,
|
||||
Type: string(domain.NotificationTypeExpirationWarning),
|
||||
MessageLike: fmt.Sprintf("%%[threshold:%d]%%", threshold),
|
||||
PerPage: 1,
|
||||
}
|
||||
|
||||
existing, err := s.notifRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check existing notifications: %w", err)
|
||||
}
|
||||
|
||||
return len(existing) > 0, nil
|
||||
}
|
||||
|
||||
// SendRenewalNotification sends a renewal success or failure notification.
|
||||
func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert *domain.ManagedCertificate, success bool, err error) error {
|
||||
var body string
|
||||
|
||||
+111
-16
@@ -18,11 +18,12 @@ import (
|
||||
|
||||
// RenewalService manages certificate renewal workflows.
|
||||
type RenewalService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
renewalPolicyRepo repository.RenewalPolicyRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
}
|
||||
|
||||
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
||||
@@ -48,29 +49,37 @@ type IssuanceResult struct {
|
||||
func NewRenewalService(
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
renewalPolicyRepo repository.RenewalPolicyRepository,
|
||||
auditService *AuditService,
|
||||
notificationSvc *NotificationService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
) *RenewalService {
|
||||
return &RenewalService{
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
auditService: auditService,
|
||||
notificationSvc: notificationSvc,
|
||||
issuerRegistry: issuerRegistry,
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
renewalPolicyRepo: renewalPolicyRepo,
|
||||
auditService: auditService,
|
||||
notificationSvc: notificationSvc,
|
||||
issuerRegistry: issuerRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckExpiringCertificates identifies certificates needing renewal based on policy windows.
|
||||
// CheckExpiringCertificates identifies certificates needing renewal and sends threshold-based
|
||||
// expiration alerts. For each certificate, it looks up the renewal policy's configured alert
|
||||
// thresholds (default: 30, 14, 7, 0 days) and sends deduplicated notifications at each threshold.
|
||||
// Certificates are also transitioned to Expiring/Expired status as appropriate.
|
||||
func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
// Default renewal window: 30 days before expiry
|
||||
renewalWindow := time.Now().AddDate(0, 0, 30)
|
||||
// Use the maximum possible threshold window (30 days) plus buffer for query
|
||||
renewalWindow := time.Now().AddDate(0, 0, 31)
|
||||
|
||||
expiring, err := s.certRepo.GetExpiringCertificates(ctx, renewalWindow)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch expiring certificates: %w", err)
|
||||
}
|
||||
|
||||
// Cache renewal policies to avoid repeated lookups
|
||||
policyCache := make(map[string]*domain.RenewalPolicy)
|
||||
|
||||
for _, cert := range expiring {
|
||||
// Skip if already renewing or archived
|
||||
if cert.Status == domain.CertificateStatusRenewalInProgress || cert.Status == domain.CertificateStatusArchived {
|
||||
@@ -80,11 +89,31 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
// Calculate days until expiry
|
||||
daysUntil := time.Until(cert.ExpiresAt).Hours() / 24
|
||||
|
||||
// Send expiration warning notification (always, regardless of issuer availability)
|
||||
if err := s.notificationSvc.SendExpirationWarning(ctx, cert, int(daysUntil)); err != nil {
|
||||
fmt.Printf("failed to send expiration warning for cert %s: %v\n", cert.ID, err)
|
||||
// Look up renewal policy for alert thresholds
|
||||
thresholds := domain.DefaultAlertThresholds()
|
||||
if cert.RenewalPolicyID != "" {
|
||||
policy, ok := policyCache[cert.RenewalPolicyID]
|
||||
if !ok {
|
||||
policy, err = s.renewalPolicyRepo.Get(ctx, cert.RenewalPolicyID)
|
||||
if err != nil {
|
||||
// Log but continue with defaults
|
||||
fmt.Printf("failed to fetch renewal policy %s for cert %s, using defaults: %v\n",
|
||||
cert.RenewalPolicyID, cert.ID, err)
|
||||
} else {
|
||||
policyCache[cert.RenewalPolicyID] = policy
|
||||
}
|
||||
}
|
||||
if policy != nil {
|
||||
thresholds = policy.EffectiveAlertThresholds()
|
||||
}
|
||||
}
|
||||
|
||||
// Update certificate status based on expiry
|
||||
s.updateCertExpiryStatus(ctx, cert, daysUntil)
|
||||
|
||||
// Send threshold-based alerts with deduplication
|
||||
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||
|
||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
|
||||
continue
|
||||
@@ -137,6 +166,72 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendThresholdAlerts sends deduplicated expiration notifications based on configured thresholds.
|
||||
// For each threshold that the certificate has crossed (e.g., ≤30 days, ≤14 days), it checks
|
||||
// whether a notification for that threshold was already sent. Only new threshold crossings
|
||||
// trigger notifications.
|
||||
func (s *RenewalService) sendThresholdAlerts(ctx context.Context, cert *domain.ManagedCertificate, daysUntil int, thresholds []int) {
|
||||
for _, threshold := range thresholds {
|
||||
// Only alert if the cert has crossed this threshold (days remaining ≤ threshold)
|
||||
if daysUntil > threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we already sent a notification for this threshold (deduplication)
|
||||
alreadySent, err := s.notificationSvc.HasThresholdNotification(ctx, cert.ID, threshold)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to check notification dedup for cert %s threshold %d: %v\n",
|
||||
cert.ID, threshold, err)
|
||||
continue
|
||||
}
|
||||
if alreadySent {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send the threshold alert
|
||||
if err := s.notificationSvc.SendThresholdAlert(ctx, cert, daysUntil, threshold); err != nil {
|
||||
fmt.Printf("failed to send threshold alert for cert %s at %d days: %v\n",
|
||||
cert.ID, threshold, err)
|
||||
}
|
||||
|
||||
// Record audit event for the alert
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"expiration_alert_sent", "certificate", cert.ID,
|
||||
map[string]interface{}{
|
||||
"threshold_days": threshold,
|
||||
"days_until_expiry": daysUntil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// updateCertExpiryStatus transitions a certificate to Expiring or Expired status based on
|
||||
// how many days remain before expiry. Expired = 0 or fewer days, Expiring = within 30 days.
|
||||
func (s *RenewalService) updateCertExpiryStatus(ctx context.Context, cert *domain.ManagedCertificate, daysUntil float64) {
|
||||
var newStatus domain.CertificateStatus
|
||||
|
||||
if daysUntil <= 0 {
|
||||
newStatus = domain.CertificateStatusExpired
|
||||
} else {
|
||||
newStatus = domain.CertificateStatusExpiring
|
||||
}
|
||||
|
||||
// Only update if status is changing and cert isn't already in a terminal/active renewal state
|
||||
if cert.Status == newStatus {
|
||||
return
|
||||
}
|
||||
if cert.Status == domain.CertificateStatusRenewalInProgress ||
|
||||
cert.Status == domain.CertificateStatusArchived ||
|
||||
cert.Status == domain.CertificateStatusRevoked {
|
||||
return
|
||||
}
|
||||
|
||||
cert.Status = newStatus
|
||||
cert.UpdatedAt = time.Now()
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
fmt.Printf("failed to update cert %s status to %s: %v\n", cert.ID, newStatus, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRenewalJob executes a renewal job: generate CSR, call issuer, store new version,
|
||||
// update cert status, and create deployment jobs for targets.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Rollback initial schema - drop tables in reverse dependency order
|
||||
|
||||
DROP TABLE IF EXISTS notification_events;
|
||||
DROP TABLE IF EXISTS audit_events;
|
||||
DROP TABLE IF EXISTS policy_violations;
|
||||
DROP TABLE IF EXISTS policy_rules;
|
||||
DROP TABLE IF EXISTS jobs;
|
||||
DROP TABLE IF EXISTS certificate_versions;
|
||||
DROP TABLE IF EXISTS certificate_target_mappings;
|
||||
DROP TABLE IF EXISTS deployment_targets;
|
||||
DROP TABLE IF EXISTS managed_certificates;
|
||||
DROP TABLE IF EXISTS agents;
|
||||
DROP TABLE IF EXISTS issuers;
|
||||
DROP TABLE IF EXISTS renewal_policies;
|
||||
DROP TABLE IF EXISTS owners;
|
||||
DROP TABLE IF EXISTS teams;
|
||||
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||
@@ -0,0 +1,223 @@
|
||||
-- Create initial schema for certificate control plane
|
||||
-- IDs are TEXT to support application-generated prefixed IDs (e.g., "team-123", "cert-456")
|
||||
|
||||
-- Table: teams
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name);
|
||||
|
||||
-- Table: owners
|
||||
CREATE TABLE IF NOT EXISTS owners (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_owners_email ON owners(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_owners_team_id ON owners(team_id);
|
||||
|
||||
-- Table: renewal_policies
|
||||
CREATE TABLE IF NOT EXISTS renewal_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
renewal_window_days INT NOT NULL,
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT true,
|
||||
max_retries INT NOT NULL DEFAULT 3,
|
||||
retry_interval_minutes INT NOT NULL DEFAULT 60,
|
||||
alert_thresholds_days JSONB NOT NULL DEFAULT '[30, 14, 7, 0]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_renewal_policies_name ON renewal_policies(name);
|
||||
|
||||
-- Table: issuers
|
||||
CREATE TABLE IF NOT EXISTS issuers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
config JSONB,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_issuers_name ON issuers(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_issuers_enabled ON issuers(enabled);
|
||||
|
||||
-- Table: agents
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'offline',
|
||||
last_heartbeat_at TIMESTAMPTZ,
|
||||
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
api_key_hash VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_hostname ON agents(hostname);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_last_heartbeat_at ON agents(last_heartbeat_at);
|
||||
|
||||
-- Table: managed_certificates
|
||||
CREATE TABLE IF NOT EXISTS managed_certificates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
common_name VARCHAR(255) NOT NULL,
|
||||
sans TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
environment VARCHAR(50),
|
||||
owner_id TEXT NOT NULL REFERENCES owners(id) ON DELETE RESTRICT,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
issuer_id TEXT NOT NULL REFERENCES issuers(id) ON DELETE RESTRICT,
|
||||
renewal_policy_id TEXT NOT NULL REFERENCES renewal_policies(id) ON DELETE RESTRICT,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
expires_at TIMESTAMPTZ,
|
||||
tags JSONB NOT NULL DEFAULT '{}',
|
||||
last_renewal_at TIMESTAMPTZ,
|
||||
last_deployment_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_certificates_status ON managed_certificates(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_certificates_expires_at ON managed_certificates(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_certificates_owner_id ON managed_certificates(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_certificates_team_id ON managed_certificates(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_certificates_issuer_id ON managed_certificates(issuer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_certificates_name ON managed_certificates(name);
|
||||
|
||||
-- Table: deployment_targets
|
||||
CREATE TABLE IF NOT EXISTS deployment_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
config JSONB,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deployment_targets_agent_id ON deployment_targets(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deployment_targets_enabled ON deployment_targets(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_deployment_targets_name ON deployment_targets(name);
|
||||
|
||||
-- Table: certificate_target_mappings
|
||||
CREATE TABLE IF NOT EXISTS certificate_target_mappings (
|
||||
certificate_id TEXT NOT NULL REFERENCES managed_certificates(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES deployment_targets(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (certificate_id, target_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_certificate_target_mappings_target_id ON certificate_target_mappings(target_id);
|
||||
|
||||
-- Table: certificate_versions
|
||||
CREATE TABLE IF NOT EXISTS certificate_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
certificate_id TEXT NOT NULL REFERENCES managed_certificates(id) ON DELETE CASCADE,
|
||||
serial_number VARCHAR(255) NOT NULL,
|
||||
not_before TIMESTAMPTZ NOT NULL,
|
||||
not_after TIMESTAMPTZ NOT NULL,
|
||||
fingerprint_sha256 VARCHAR(255) NOT NULL UNIQUE,
|
||||
pem_chain TEXT NOT NULL,
|
||||
csr_pem TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_certificate_versions_certificate_id ON certificate_versions(certificate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_certificate_versions_fingerprint ON certificate_versions(fingerprint_sha256);
|
||||
|
||||
-- Table: jobs
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
certificate_id TEXT REFERENCES managed_certificates(id) ON DELETE CASCADE,
|
||||
target_id TEXT REFERENCES deployment_targets(id) ON DELETE SET NULL,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 3,
|
||||
last_error TEXT,
|
||||
deployment_result JSONB,
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_certificate_id ON jobs(certificate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_scheduled_at ON jobs(scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_agent_id ON jobs(agent_id);
|
||||
|
||||
-- Table: policy_rules
|
||||
CREATE TABLE IF NOT EXISTS policy_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
config JSONB NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_rules_name ON policy_rules(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_rules_enabled ON policy_rules(enabled);
|
||||
|
||||
-- Table: policy_violations
|
||||
CREATE TABLE IF NOT EXISTS policy_violations (
|
||||
id TEXT PRIMARY KEY,
|
||||
certificate_id TEXT NOT NULL REFERENCES managed_certificates(id) ON DELETE CASCADE,
|
||||
rule_id TEXT NOT NULL REFERENCES policy_rules(id) ON DELETE CASCADE,
|
||||
message TEXT NOT NULL,
|
||||
severity VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_violations_certificate_id ON policy_violations(certificate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_violations_rule_id ON policy_violations(rule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_violations_severity ON policy_violations(severity);
|
||||
|
||||
-- Table: audit_events
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor VARCHAR(255) NOT NULL,
|
||||
actor_type VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(255) NOT NULL,
|
||||
resource_type VARCHAR(255) NOT NULL,
|
||||
resource_id VARCHAR(255) NOT NULL,
|
||||
details JSONB,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_resource_type_id ON audit_events(resource_type, resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_actor ON audit_events(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action);
|
||||
|
||||
-- Table: notification_events
|
||||
CREATE TABLE IF NOT EXISTS notification_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
certificate_id TEXT REFERENCES managed_certificates(id) ON DELETE CASCADE,
|
||||
channel VARCHAR(255) NOT NULL,
|
||||
recipient VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
sent_at TIMESTAMPTZ,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_certificate_id ON notification_events(certificate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_status ON notification_events(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_type ON notification_events(type);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Seed data for certificate control plane
|
||||
|
||||
-- Default renewal policy
|
||||
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days)
|
||||
VALUES (
|
||||
'rp-default',
|
||||
'default',
|
||||
30,
|
||||
true,
|
||||
3,
|
||||
60,
|
||||
'[30, 14, 7, 0]'::jsonb
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy rules: Require owner assignment
|
||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'pr-require-owner',
|
||||
'require-owner',
|
||||
'ownership',
|
||||
'{"requirement": "owner_id must be set"}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy rules: Allowed environments
|
||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'pr-allowed-environments',
|
||||
'allowed-environments',
|
||||
'environment',
|
||||
'{"allowed": ["production", "staging", "development"]}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy rules: Maximum certificate lifetime
|
||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'pr-max-certificate-lifetime',
|
||||
'max-certificate-lifetime',
|
||||
'lifetime',
|
||||
'{"max_days": 90}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy rules: Minimum renewal window
|
||||
INSERT INTO policy_rules (id, name, type, config, enabled)
|
||||
VALUES (
|
||||
'pr-min-renewal-window',
|
||||
'min-renewal-window',
|
||||
'renewal_window',
|
||||
'{"min_days": 14}'::jsonb,
|
||||
true
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,156 @@
|
||||
-- =============================================================================
|
||||
-- Demo Seed Data for certctl
|
||||
-- Run after schema migration to populate a realistic demo environment
|
||||
-- =============================================================================
|
||||
|
||||
-- Teams
|
||||
INSERT INTO teams (id, name, description, created_at, updated_at) VALUES
|
||||
('t-platform', 'Platform Engineering', 'Core infrastructure and platform services', NOW(), NOW()),
|
||||
('t-security', 'Security Operations', 'Security tooling and compliance', NOW(), NOW()),
|
||||
('t-payments', 'Payments', 'Payment processing services', NOW(), NOW()),
|
||||
('t-frontend', 'Frontend', 'Web and mobile applications', NOW(), NOW()),
|
||||
('t-data', 'Data Engineering', 'Data pipelines and analytics', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Owners
|
||||
INSERT INTO owners (id, name, email, team_id, created_at, updated_at) VALUES
|
||||
('o-alice', 'Alice Chen', 'alice@example.com', 't-platform', NOW(), NOW()),
|
||||
('o-bob', 'Bob Martinez', 'bob@example.com', 't-security', NOW(), NOW()),
|
||||
('o-carol', 'Carol Williams', 'carol@example.com', 't-payments', NOW(), NOW()),
|
||||
('o-dave', 'Dave Kim', 'dave@example.com', 't-frontend', NOW(), NOW()),
|
||||
('o-eve', 'Eve Johnson', 'eve@example.com', 't-data', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Renewal Policies
|
||||
INSERT INTO renewal_policies (id, name, renewal_window_days, auto_renew, max_retries, retry_interval_minutes, alert_thresholds_days, created_at, updated_at) VALUES
|
||||
('rp-standard', 'Standard 30-day', 30, true, 3, 60, '[30, 14, 7, 0]'::jsonb, NOW(), NOW()),
|
||||
('rp-urgent', 'Urgent 14-day', 14, true, 5, 30, '[14, 7, 3, 0]'::jsonb, NOW(), NOW()),
|
||||
('rp-manual', 'Manual Only', 30, false, 0, 0, '[30, 14, 7, 0]'::jsonb, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Issuers
|
||||
INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VALUES
|
||||
('iss-local', 'Local Dev CA', 'local', '{"ca_common_name": "CertCtl Demo CA", "validity_days": 90}', true, NOW(), NOW()),
|
||||
('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com"}', true, NOW(), NOW()),
|
||||
('iss-digicert', 'DigiCert (disabled)', 'generic_ca', '{"api_url": "https://api.digicert.com", "api_key": "REDACTED"}', false, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Agents
|
||||
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash) VALUES
|
||||
('ag-web-prod', 'web-prod-agent', 'web-prod-01.internal', 'online', NOW() - INTERVAL '30 seconds', NOW() - INTERVAL '90 days', 'demo_hash_1'),
|
||||
('ag-web-staging', 'web-staging-agent', 'web-stg-01.internal', 'online', NOW() - INTERVAL '45 seconds', NOW() - INTERVAL '60 days', 'demo_hash_2'),
|
||||
('ag-lb-prod', 'lb-prod-agent', 'f5-prod-01.internal', 'online', NOW() - INTERVAL '15 seconds', NOW() - INTERVAL '120 days', 'demo_hash_3'),
|
||||
('ag-iis-prod', 'iis-prod-agent', 'iis-prod-01.internal', 'offline', NOW() - INTERVAL '3 hours', NOW() - INTERVAL '30 days', 'demo_hash_4'),
|
||||
('ag-data-prod', 'data-prod-agent', 'data-prod-01.internal', 'online', NOW() - INTERVAL '20 seconds', NOW() - INTERVAL '45 days', 'demo_hash_5')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Deployment Targets
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at) VALUES
|
||||
('tgt-nginx-prod', 'NGINX Production', 'nginx', 'ag-web-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW(), NOW()),
|
||||
('tgt-nginx-staging', 'NGINX Staging', 'nginx', 'ag-web-staging', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW(), NOW()),
|
||||
('tgt-f5-prod', 'F5 BIG-IP Production','f5', 'ag-lb-prod', '{"host": "f5-prod-01.internal", "partition": "Common", "ssl_profile": "clientssl"}', true, NOW(), NOW()),
|
||||
('tgt-iis-prod', 'IIS Production', 'iis', 'ag-iis-prod', '{"site_name": "Default Web Site", "binding_info": "*:443:"}', true, NOW(), NOW()),
|
||||
('tgt-nginx-data', 'NGINX Data Services', 'nginx', 'ag-data-prod', '{"cert_path": "/etc/nginx/ssl/cert.pem", "key_path": "/etc/nginx/ssl/key.pem", "reload_command": "nginx -s reload"}', true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Managed Certificates — varied statuses and expiry dates for realistic dashboard
|
||||
INSERT INTO managed_certificates (id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, status, expires_at, tags, last_renewal_at, last_deployment_at, created_at, updated_at) VALUES
|
||||
-- Active, healthy certs
|
||||
('mc-api-prod', 'api-production', 'api.example.com', ARRAY['api.example.com', 'api-v2.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '75 days', '{"service": "api-gateway", "tier": "critical"}', NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days', NOW() - INTERVAL '180 days', NOW()),
|
||||
('mc-web-prod', 'web-production', 'www.example.com', ARRAY['www.example.com', 'example.com'], 'production', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '60 days', '{"service": "web-app", "tier": "critical"}', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '365 days', NOW()),
|
||||
('mc-pay-prod', 'payments-production', 'pay.example.com', ARRAY['pay.example.com', 'checkout.example.com'], 'production', 'o-carol', 't-payments', 'iss-local', 'rp-urgent', 'active', NOW() + INTERVAL '45 days', '{"service": "payments", "tier": "critical", "pci": "true"}', NOW() - INTERVAL '45 days', NOW() - INTERVAL '45 days', NOW() - INTERVAL '200 days', NOW()),
|
||||
('mc-dash-prod', 'dashboard-production', 'dashboard.example.com', ARRAY['dashboard.example.com'], 'production', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '82 days', '{"service": "dashboard", "tier": "high"}', NOW() - INTERVAL '8 days', NOW() - INTERVAL '8 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
('mc-data-prod', 'data-api-production', 'data.example.com', ARRAY['data.example.com', 'analytics.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '55 days', '{"service": "data-api", "tier": "high"}', NOW() - INTERVAL '35 days', NOW() - INTERVAL '35 days', NOW() - INTERVAL '150 days', NOW()),
|
||||
|
||||
-- Expiring soon (< 30 days)
|
||||
('mc-auth-prod', 'auth-production', 'auth.example.com', ARRAY['auth.example.com', 'login.example.com', 'sso.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-urgent', 'expiring', NOW() + INTERVAL '12 days', '{"service": "auth", "tier": "critical"}', NOW() - INTERVAL '78 days', NOW() - INTERVAL '78 days', NOW() - INTERVAL '300 days', NOW()),
|
||||
('mc-cdn-prod', 'cdn-production', 'cdn.example.com', ARRAY['cdn.example.com', 'static.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'expiring', NOW() + INTERVAL '8 days', '{"service": "cdn", "tier": "high"}', NOW() - INTERVAL '82 days', NOW() - INTERVAL '82 days', NOW() - INTERVAL '250 days', NOW()),
|
||||
('mc-mail-prod', 'mail-production', 'mail.example.com', ARRAY['mail.example.com', 'smtp.example.com'], 'production', 'o-bob', 't-security', 'iss-local', 'rp-standard', 'expiring', NOW() + INTERVAL '5 days', '{"service": "email", "tier": "medium"}', NOW() - INTERVAL '85 days', NOW() - INTERVAL '85 days', NOW() - INTERVAL '400 days', NOW()),
|
||||
|
||||
-- Expired
|
||||
('mc-legacy-prod', 'legacy-app', 'legacy.example.com', ARRAY['legacy.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-manual', 'expired', NOW() - INTERVAL '3 days', '{"service": "legacy", "tier": "low", "decom": "planned"}', NOW() - INTERVAL '93 days', NOW() - INTERVAL '93 days', NOW() - INTERVAL '500 days', NOW()),
|
||||
('mc-old-api', 'old-api-v1', 'api-v1.example.com', ARRAY['api-v1.example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-manual', 'expired', NOW() - INTERVAL '15 days', '{"service": "api-v1", "tier": "low", "deprecated": "true"}', NULL, NULL, NOW() - INTERVAL '600 days', NOW()),
|
||||
|
||||
-- Staging certs
|
||||
('mc-api-stg', 'api-staging', 'api.staging.example.com', ARRAY['api.staging.example.com'], 'staging', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '65 days', '{"service": "api-gateway", "tier": "low"}', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '120 days', NOW()),
|
||||
('mc-web-stg', 'web-staging', 'www.staging.example.com', ARRAY['www.staging.example.com', 'staging.example.com'], 'staging', 'o-dave', 't-frontend', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '70 days', '{"service": "web-app", "tier": "low"}', NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days', NOW() - INTERVAL '100 days', NOW()),
|
||||
|
||||
-- Renewal in progress
|
||||
('mc-grafana-prod', 'grafana-production', 'grafana.example.com', ARRAY['grafana.example.com', 'metrics.example.com'], 'production', 'o-eve', 't-data', 'iss-local', 'rp-standard', 'renewal_in_progress', NOW() + INTERVAL '3 days', '{"service": "monitoring", "tier": "high"}', NOW() - INTERVAL '87 days', NOW() - INTERVAL '87 days', NOW() - INTERVAL '180 days', NOW()),
|
||||
|
||||
-- Failed
|
||||
('mc-vpn-prod', 'vpn-production', 'vpn.example.com', ARRAY['vpn.example.com'], 'production', 'o-bob', 't-security', 'iss-acme-le', 'rp-urgent', 'failed', NOW() + INTERVAL '1 day', '{"service": "vpn", "tier": "critical"}', NULL, NULL, NOW() - INTERVAL '90 days', NOW()),
|
||||
|
||||
-- Wildcard
|
||||
('mc-wildcard-prod', 'wildcard-production', '*.example.com', ARRAY['*.example.com', 'example.com'], 'production', 'o-alice', 't-platform', 'iss-local', 'rp-standard', 'active', NOW() + INTERVAL '50 days', '{"service": "wildcard", "tier": "critical"}', NOW() - INTERVAL '40 days', NOW() - INTERVAL '40 days', NOW() - INTERVAL '365 days', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Certificate-Target Mappings
|
||||
INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES
|
||||
('mc-api-prod', 'tgt-nginx-prod'),
|
||||
('mc-api-prod', 'tgt-f5-prod'),
|
||||
('mc-web-prod', 'tgt-nginx-prod'),
|
||||
('mc-web-prod', 'tgt-f5-prod'),
|
||||
('mc-pay-prod', 'tgt-nginx-prod'),
|
||||
('mc-pay-prod', 'tgt-f5-prod'),
|
||||
('mc-dash-prod', 'tgt-nginx-prod'),
|
||||
('mc-data-prod', 'tgt-nginx-data'),
|
||||
('mc-auth-prod', 'tgt-nginx-prod'),
|
||||
('mc-auth-prod', 'tgt-f5-prod'),
|
||||
('mc-cdn-prod', 'tgt-f5-prod'),
|
||||
('mc-mail-prod', 'tgt-nginx-prod'),
|
||||
('mc-legacy-prod', 'tgt-iis-prod'),
|
||||
('mc-api-stg', 'tgt-nginx-staging'),
|
||||
('mc-web-stg', 'tgt-nginx-staging'),
|
||||
('mc-grafana-prod', 'tgt-nginx-data'),
|
||||
('mc-vpn-prod', 'tgt-f5-prod'),
|
||||
('mc-wildcard-prod', 'tgt-nginx-prod'),
|
||||
('mc-wildcard-prod', 'tgt-f5-prod'),
|
||||
('mc-wildcard-prod', 'tgt-nginx-staging')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Certificate Versions (latest version for each active cert)
|
||||
INSERT INTO certificate_versions (id, certificate_id, serial_number, not_before, not_after, fingerprint_sha256, pem_chain, csr_pem, created_at) VALUES
|
||||
('cv-api-1', 'mc-api-prod', '0A:1B:2C:3D:4E:5F:00:01', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days', 'sha256:ab12cd34ef56', '-----BEGIN CERTIFICATE-----\nMIIDemoAPI...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '15 days'),
|
||||
('cv-web-1', 'mc-web-prod', '0A:1B:2C:3D:4E:5F:00:02', NOW() - INTERVAL '30 days', NOW() + INTERVAL '60 days', 'sha256:cd34ef56ab12', '-----BEGIN CERTIFICATE-----\nMIIDemoWeb...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '30 days'),
|
||||
('cv-pay-1', 'mc-pay-prod', '0A:1B:2C:3D:4E:5F:00:03', NOW() - INTERVAL '45 days', NOW() + INTERVAL '45 days', 'sha256:ef56ab12cd34', '-----BEGIN CERTIFICATE-----\nMIIDemoPay...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '45 days'),
|
||||
('cv-auth-1', 'mc-auth-prod', '0A:1B:2C:3D:4E:5F:00:04', NOW() - INTERVAL '78 days', NOW() + INTERVAL '12 days', 'sha256:1234abcdef56', '-----BEGIN CERTIFICATE-----\nMIIDemoAuth...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '78 days'),
|
||||
('cv-wild-1', 'mc-wildcard-prod', '0A:1B:2C:3D:4E:5F:00:05', NOW() - INTERVAL '40 days', NOW() + INTERVAL '50 days', 'sha256:5678abcdef12', '-----BEGIN CERTIFICATE-----\nMIIDemoWild...\n-----END CERTIFICATE-----', NULL, NOW() - INTERVAL '40 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Recent Audit Events
|
||||
INSERT INTO audit_events (id, actor, actor_type, action, resource_type, resource_id, details, timestamp) VALUES
|
||||
('audit-demo-01', 'alice@example.com', 'user', 'certificate.renewed', 'certificate', 'mc-api-prod', '{"issuer": "local", "serial": "0A:1B:2C:3D:4E:5F:00:01"}', NOW() - INTERVAL '15 days'),
|
||||
('audit-demo-02', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-nginx-prod", "status": "success"}', NOW() - INTERVAL '15 days' + INTERVAL '5 minutes'),
|
||||
('audit-demo-03', 'system', 'system', 'certificate.deployed', 'certificate', 'mc-api-prod', '{"target": "tgt-f5-prod", "status": "success"}', NOW() - INTERVAL '15 days' + INTERVAL '8 minutes'),
|
||||
('audit-demo-04', 'dave@example.com', 'user', 'certificate.renewed', 'certificate', 'mc-web-prod', '{"issuer": "local", "serial": "0A:1B:2C:3D:4E:5F:00:02"}', NOW() - INTERVAL '30 days'),
|
||||
('audit-demo-05', 'carol@example.com', 'user', 'certificate.created', 'certificate', 'mc-pay-prod', '{"common_name": "pay.example.com"}', NOW() - INTERVAL '200 days'),
|
||||
('audit-demo-06', 'system', 'system', 'renewal.started', 'certificate', 'mc-grafana-prod', '{"reason": "expiring_in_3_days"}', NOW() - INTERVAL '2 hours'),
|
||||
('audit-demo-07', 'system', 'system', 'renewal.failed', 'certificate', 'mc-vpn-prod', '{"error": "ACME challenge failed: DNS timeout", "attempt": 3}', NOW() - INTERVAL '1 hour'),
|
||||
('audit-demo-08', 'system', 'system', 'expiration.warning', 'certificate', 'mc-auth-prod', '{"days_until_expiry": 12}', NOW() - INTERVAL '30 minutes'),
|
||||
('audit-demo-09', 'system', 'system', 'expiration.warning', 'certificate', 'mc-cdn-prod', '{"days_until_expiry": 8}', NOW() - INTERVAL '25 minutes'),
|
||||
('audit-demo-10', 'system', 'system', 'expiration.warning', 'certificate', 'mc-mail-prod', '{"days_until_expiry": 5}', NOW() - INTERVAL '20 minutes'),
|
||||
('audit-demo-11', 'bob@example.com', 'user', 'agent.registered', 'agent', 'ag-iis-prod', '{"hostname": "iis-prod-01.internal"}', NOW() - INTERVAL '30 days'),
|
||||
('audit-demo-12', 'system', 'system', 'agent.offline', 'agent', 'ag-iis-prod', '{"last_heartbeat": "3 hours ago"}', NOW() - INTERVAL '3 hours'),
|
||||
('audit-demo-13', 'alice@example.com', 'user', 'policy.violation', 'certificate', 'mc-legacy-prod', '{"rule": "max-certificate-lifetime", "message": "Certificate expired"}', NOW() - INTERVAL '3 days'),
|
||||
('audit-demo-14', 'bob@example.com', 'user', 'issuer.configured', 'issuer', 'iss-local', '{"type": "local", "ca_common_name": "CertCtl Demo CA"}', NOW() - INTERVAL '90 days'),
|
||||
('audit-demo-15', 'alice@example.com', 'user', 'target.configured', 'target', 'tgt-nginx-prod', '{"type": "nginx", "agent": "ag-web-prod"}', NOW() - INTERVAL '90 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Policy Violations (reference policy rules by their IDs from seed.sql)
|
||||
INSERT INTO policy_violations (id, certificate_id, rule_id, message, severity, created_at) VALUES
|
||||
('pv-demo-01', 'mc-legacy-prod', 'pr-max-certificate-lifetime', 'Certificate has expired and exceeds maximum lifetime policy', 'critical', NOW() - INTERVAL '3 days'),
|
||||
('pv-demo-02', 'mc-old-api', 'pr-max-certificate-lifetime', 'Certificate expired 15 days ago', 'critical', NOW() - INTERVAL '15 days'),
|
||||
('pv-demo-03', 'mc-vpn-prod', 'pr-min-renewal-window', 'Renewal failed within minimum renewal window', 'error', NOW() - INTERVAL '1 hour'),
|
||||
('pv-demo-04', 'mc-mail-prod', 'pr-min-renewal-window', 'Certificate expiring in 5 days, below 14-day minimum window','warning', NOW() - INTERVAL '20 minutes')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Notification Events
|
||||
INSERT INTO notification_events (id, type, certificate_id, channel, recipient, message, sent_at, status, error) VALUES
|
||||
('ne-demo-01', 'expiration_warning', 'mc-auth-prod', 'email', 'bob@example.com', 'Certificate auth-production expires in 12 days', NOW() - INTERVAL '30 minutes', 'sent', NULL),
|
||||
('ne-demo-02', 'expiration_warning', 'mc-cdn-prod', 'email', 'alice@example.com', 'Certificate cdn-production expires in 8 days', NOW() - INTERVAL '25 minutes', 'sent', NULL),
|
||||
('ne-demo-03', 'expiration_warning', 'mc-mail-prod', 'email', 'bob@example.com', 'Certificate mail-production expires in 5 days', NOW() - INTERVAL '20 minutes', 'sent', NULL),
|
||||
('ne-demo-04', 'renewal_failure', 'mc-vpn-prod', 'webhook', 'https://hooks.example.com/certctl', 'Renewal failed for vpn-production after 3 attempts', NOW() - INTERVAL '1 hour', 'sent', NULL),
|
||||
('ne-demo-05', 'renewal_success', 'mc-api-prod', 'email', 'alice@example.com', 'Certificate api-production renewed successfully', NOW() - INTERVAL '15 days', 'sent', NULL),
|
||||
('ne-demo-06', 'deployment_success', 'mc-api-prod', 'webhook', 'https://hooks.example.com/certctl', 'Certificate api-production deployed to NGINX Production', NOW() - INTERVAL '15 days', 'sent', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
Reference in New Issue
Block a user