Files
certctl/internal/api/handler/discovery.go
T
Shankar 2cad4d7ade Close M-004 (OCSP issuer binding) and M-005 (discovery actor propagation) coverage-gap findings
M-004 — OCSP issuer binding (composite key):
  The OCSP lookup path now binds (issuer_id, serial) as a composite key
  rather than resolving by serial alone. CertificateRepository and
  RevocationRepository gain GetByIssuerAndSerial methods; ca_operations.go
  scopes both lookups by the issuer_id path param. When no managed cert
  binds to that (issuer, serial) tuple, GetOCSPResponse constructs an
  RFC 6960 §2.2 'unknown' response (CertStatus=2) instead of the prior
  default 'good'. Short-lived cert exemption (profile TTL < 1h) is
  preserved. Real repo errors (non-sql.ErrNoRows) fail closed with a log.

  Regression coverage: internal/service/ca_operations_test.go
    - TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer
    - TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial

M-005 — Discovery Claim/Dismiss actor propagation:
  DiscoveryService.ClaimDiscovered and DismissDiscovered now accept an
  explicit 'actor string' parameter (propagation pattern mirrors
  bulk_revocation.go / revocation_svc.go). The handler layer passes
  resolveActor(r.Context()) — the named-key identity established by the
  M-002 auth unification — and the service falls back to 'api' (the same
  safe sentinel resolveActor uses when no auth context is present) only
  when the caller passes an empty string. Never falls back to 'operator'.

  Regression coverage: internal/service/discovery_test.go
    - TestDiscoveryService_ClaimDiscovered_AuditActor
    - TestDiscoveryService_DismissDiscovered_AuditActor
    - TestDiscoveryService_ClaimDiscovered_EmptyActorFallsBackToAPI
    - TestDiscoveryService_DismissDiscovered_EmptyActorFallsBackToAPI

Each new test asserts event.Actor matches the caller-supplied string (or
'api' on empty input) and explicitly asserts event.Actor != 'operator'
to lock in the historical fix intent.

Files:
  internal/api/handler/discovery.go          — pass resolveActor(ctx)
  internal/api/handler/discovery_handler_test.go — updated call sites
  internal/integration/lifecycle_test.go     — updated mock wiring
  internal/repository/interfaces.go          — GetByIssuerAndSerial on
                                               CertificateRepository +
                                               RevocationRepository
  internal/repository/postgres/certificate.go — composite key lookup
  internal/service/ca_operations.go          — (issuer_id, serial) scoping
  internal/service/ca_operations_test.go     — 2 new M-004 tests
  internal/service/discovery.go              — actor parameter + 'api' fallback
  internal/service/discovery_test.go         — 4 new M-005 tests
  internal/service/shortlived_test.go        — mock signature update
  internal/service/testutil_test.go          — mock GetByIssuerAndSerial
2026-04-18 22:20:25 +00:00

238 lines
7.1 KiB
Go

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.
// ClaimDiscovered and DismissDiscovered accept an explicit actor parameter so
// the handler can flow the authenticated named-key identity into the audit
// trail (M-005). Services that call these methods from non-request contexts
// pass a descriptive sentinel (e.g., "system") or "" (which falls back to
// "api").
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, actor string) error
DismissDiscovered(ctx context.Context, id string, actor 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, resolveActor(r.Context())); 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, resolveActor(r.Context())); 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
}