mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:01:36 +00:00
fe7e766510
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
238 lines
7.1 KiB
Go
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
|
|
}
|