mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +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
230 lines
7.7 KiB
Go
230 lines
7.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/repository"
|
|
)
|
|
|
|
// DiscoveryService provides business logic for certificate discovery.
|
|
type DiscoveryService struct {
|
|
discoveryRepo repository.DiscoveryRepository
|
|
certRepo repository.CertificateRepository
|
|
auditService *AuditService
|
|
}
|
|
|
|
// NewDiscoveryService creates a new discovery service.
|
|
func NewDiscoveryService(
|
|
discoveryRepo repository.DiscoveryRepository,
|
|
certRepo repository.CertificateRepository,
|
|
auditService *AuditService,
|
|
) *DiscoveryService {
|
|
return &DiscoveryService{
|
|
discoveryRepo: discoveryRepo,
|
|
certRepo: certRepo,
|
|
auditService: auditService,
|
|
}
|
|
}
|
|
|
|
// ProcessDiscoveryReport processes a discovery report from an agent.
|
|
// It creates a scan record, upserts each discovered certificate, and returns scan summary.
|
|
func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
|
|
if report.AgentID == "" {
|
|
return nil, fmt.Errorf("agent_id is required")
|
|
}
|
|
if len(report.Certificates) == 0 && len(report.Errors) == 0 {
|
|
return nil, fmt.Errorf("report must contain at least one certificate or error")
|
|
}
|
|
|
|
// Ensure directories is never nil (PostgreSQL TEXT[] NOT NULL)
|
|
if report.Directories == nil {
|
|
report.Directories = []string{}
|
|
}
|
|
|
|
now := time.Now()
|
|
scan := &domain.DiscoveryScan{
|
|
ID: generateID("dscan"),
|
|
AgentID: report.AgentID,
|
|
Directories: report.Directories,
|
|
CertificatesFound: len(report.Certificates),
|
|
ErrorsCount: len(report.Errors),
|
|
ScanDurationMs: report.ScanDurationMs,
|
|
StartedAt: now.Add(-time.Duration(report.ScanDurationMs) * time.Millisecond),
|
|
CompletedAt: &now,
|
|
}
|
|
|
|
// Store the scan record first (discovered certs reference scan via FK)
|
|
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
|
|
return nil, fmt.Errorf("failed to create scan record: %w", err)
|
|
}
|
|
|
|
// Upsert each discovered certificate
|
|
newCount := 0
|
|
for _, entry := range report.Certificates {
|
|
cert := &domain.DiscoveredCertificate{
|
|
ID: generateID("dcert"),
|
|
FingerprintSHA256: entry.FingerprintSHA256,
|
|
CommonName: entry.CommonName,
|
|
SANs: entry.SANs,
|
|
SerialNumber: entry.SerialNumber,
|
|
IssuerDN: entry.IssuerDN,
|
|
SubjectDN: entry.SubjectDN,
|
|
KeyAlgorithm: entry.KeyAlgorithm,
|
|
KeySize: entry.KeySize,
|
|
IsCA: entry.IsCA,
|
|
PEMData: entry.PEMData,
|
|
SourcePath: entry.SourcePath,
|
|
SourceFormat: entry.SourceFormat,
|
|
AgentID: report.AgentID,
|
|
DiscoveryScanID: scan.ID,
|
|
Status: domain.DiscoveryStatusUnmanaged,
|
|
FirstSeenAt: now,
|
|
LastSeenAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
// Parse time fields
|
|
if entry.NotBefore != "" {
|
|
if t, err := time.Parse(time.RFC3339, entry.NotBefore); err == nil {
|
|
cert.NotBefore = &t
|
|
}
|
|
}
|
|
if entry.NotAfter != "" {
|
|
if t, err := time.Parse(time.RFC3339, entry.NotAfter); err == nil {
|
|
cert.NotAfter = &t
|
|
}
|
|
}
|
|
|
|
isNew, err := s.discoveryRepo.CreateDiscovered(ctx, cert)
|
|
if err != nil {
|
|
slog.Error("failed to upsert discovered certificate",
|
|
"fingerprint", entry.FingerprintSHA256,
|
|
"source_path", entry.SourcePath,
|
|
"error", err)
|
|
continue
|
|
}
|
|
if isNew {
|
|
newCount++
|
|
}
|
|
}
|
|
|
|
scan.CertificatesNew = newCount
|
|
|
|
// Audit trail
|
|
if err := s.auditService.RecordEvent(ctx, report.AgentID, domain.ActorTypeSystem,
|
|
"discovery_scan_completed", "discovery_scan", scan.ID,
|
|
map[string]interface{}{
|
|
"agent_id": report.AgentID,
|
|
"directories": report.Directories,
|
|
"certificates_found": scan.CertificatesFound,
|
|
"certificates_new": newCount,
|
|
"errors_count": scan.ErrorsCount,
|
|
}); err != nil {
|
|
slog.Error("failed to record audit event", "error", err)
|
|
}
|
|
|
|
return scan, nil
|
|
}
|
|
|
|
// ListDiscovered returns discovered certificates matching the filter.
|
|
func (s *DiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
|
filter := &repository.DiscoveryFilter{
|
|
AgentID: agentID,
|
|
Status: status,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
}
|
|
return s.discoveryRepo.ListDiscovered(ctx, filter)
|
|
}
|
|
|
|
// GetDiscovered retrieves a discovered certificate by ID.
|
|
func (s *DiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
|
return s.discoveryRepo.GetDiscovered(ctx, id)
|
|
}
|
|
|
|
// ClaimDiscovered links a discovered certificate to a managed certificate.
|
|
// The actor parameter names the authenticated identity that initiated the
|
|
// claim and is recorded on the audit event. Callers in the handler layer pass
|
|
// resolveActor(ctx); service-to-service callers pass a descriptive sentinel
|
|
// (e.g., "system"). Empty actor falls back to "api" (the same safe sentinel
|
|
// resolveActor uses when no auth context is present), never to "operator" —
|
|
// hardcoding "operator" was M-005, a coverage-gap closure where audit records
|
|
// failed to identify who actually performed the triage action.
|
|
func (s *DiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string, actor string) error {
|
|
if managedCertID == "" {
|
|
return fmt.Errorf("managed_certificate_id is required")
|
|
}
|
|
|
|
// Verify the discovered cert exists
|
|
disc, err := s.discoveryRepo.GetDiscovered(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get discovered certificate: %w", err)
|
|
}
|
|
|
|
// Verify the managed cert exists
|
|
if _, err := s.certRepo.Get(ctx, managedCertID); err != nil {
|
|
return fmt.Errorf("managed certificate not found: %s", managedCertID)
|
|
}
|
|
|
|
if err := s.discoveryRepo.UpdateDiscoveredStatus(ctx, id, domain.DiscoveryStatusManaged, managedCertID); err != nil {
|
|
return fmt.Errorf("failed to update discovered certificate status: %w", err)
|
|
}
|
|
|
|
if actor == "" {
|
|
actor = "api"
|
|
}
|
|
|
|
// Audit trail
|
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
"discovery_cert_claimed", "discovered_certificate", id,
|
|
map[string]interface{}{
|
|
"managed_certificate_id": managedCertID,
|
|
"fingerprint": disc.FingerprintSHA256,
|
|
"common_name": disc.CommonName,
|
|
}); err != nil {
|
|
slog.Error("failed to record audit event", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DismissDiscovered marks a discovered certificate as dismissed. See
|
|
// ClaimDiscovered for the actor contract — same rules apply (M-005).
|
|
func (s *DiscoveryService) DismissDiscovered(ctx context.Context, id string, actor string) error {
|
|
if err := s.discoveryRepo.UpdateDiscoveredStatus(ctx, id, domain.DiscoveryStatusDismissed, ""); err != nil {
|
|
return fmt.Errorf("failed to dismiss discovered certificate: %w", err)
|
|
}
|
|
|
|
if actor == "" {
|
|
actor = "api"
|
|
}
|
|
|
|
// Audit trail
|
|
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
|
"discovery_cert_dismissed", "discovered_certificate", id, nil); err != nil {
|
|
slog.Error("failed to record audit event", "error", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListScans returns discovery scans, optionally filtered by agent ID.
|
|
func (s *DiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
|
return s.discoveryRepo.ListScans(ctx, agentID, page, perPage)
|
|
}
|
|
|
|
// GetScan retrieves a discovery scan by ID.
|
|
func (s *DiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
|
|
return s.discoveryRepo.GetScan(ctx, id)
|
|
}
|
|
|
|
// GetDiscoverySummary returns a summary of discovery status counts.
|
|
func (s *DiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
|
|
return s.discoveryRepo.CountByStatus(ctx)
|
|
}
|