feat: M15a — certificate revocation API, CRL endpoint, and revocation notifications

Implements core revocation infrastructure: POST /api/v1/certificates/{id}/revoke
with all 8 RFC 5280 reason codes, JSON-formatted CRL at GET /api/v1/crl, webhook
and email revocation notifications, best-effort issuer notification, and immutable
revocation audit trail. Includes 48 new tests across service, handler, integration,
and domain layers (600+ total). Fixes 3 pre-existing test bugs (team_test error
matching, agent_group delete status code, team handler per_page validation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-22 10:59:18 -04:00
parent d881403d11
commit 5d98e373e3
27 changed files with 1710 additions and 37 deletions
+141 -3
View File
@@ -12,9 +12,12 @@ import (
// CertificateService provides business logic for certificate management.
type CertificateService struct {
certRepo repository.CertificateRepository
policyService *PolicyService
auditService *AuditService
certRepo repository.CertificateRepository
revocationRepo repository.RevocationRepository
policyService *PolicyService
auditService *AuditService
notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector
}
// NewCertificateService creates a new certificate service.
@@ -30,6 +33,21 @@ func NewCertificateService(
}
}
// SetRevocationRepo sets the revocation repository (called after construction to avoid init order issues).
func (s *CertificateService) SetRevocationRepo(repo repository.RevocationRepository) {
s.revocationRepo = repo
}
// SetNotificationService sets the notification service for revocation alerts.
func (s *CertificateService) SetNotificationService(svc *NotificationService) {
s.notificationSvc = svc
}
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
func (s *CertificateService) SetIssuerRegistry(registry map[string]IssuerConnector) {
s.issuerRegistry = registry
}
// List returns a paginated list of certificates matching the filter.
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
certs, total, err := s.certRepo.List(ctx, filter)
@@ -333,3 +351,123 @@ func (s *CertificateService) TriggerRenewal(certID string) error {
func (s *CertificateService) TriggerDeployment(certID string, targetID string) error {
return s.TriggerDeploymentWithActor(context.Background(), certID, "api")
}
// RevokeCertificate revokes a certificate with the given reason.
// Steps:
// 1. Validate the certificate exists and is revocable
// 2. Get the latest certificate version (for serial number)
// 3. Update certificate status to Revoked
// 4. Record revocation in certificate_revocations table
// 5. Notify the issuer connector (best-effort)
// 6. Record audit event
// 7. Send revocation notification
func (s *CertificateService) RevokeCertificate(certID string, reason string) error {
return s.RevokeCertificateWithActor(context.Background(), certID, reason, "api")
}
// RevokeCertificateWithActor performs revocation with actor tracking.
func (s *CertificateService) RevokeCertificateWithActor(ctx context.Context, certID string, reason string, actor string) error {
// 1. Validate certificate exists and is revocable
cert, err := s.certRepo.Get(ctx, certID)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
if cert.Status == domain.CertificateStatusRevoked {
return fmt.Errorf("certificate is already revoked")
}
if cert.Status == domain.CertificateStatusArchived {
return fmt.Errorf("cannot revoke archived certificate")
}
// Validate reason code
if reason == "" {
reason = string(domain.RevocationReasonUnspecified)
}
if !domain.IsValidRevocationReason(reason) {
return fmt.Errorf("invalid revocation reason: %s", reason)
}
// 2. Get latest certificate version for serial number
version, err := s.certRepo.GetLatestVersion(ctx, certID)
if err != nil {
return fmt.Errorf("failed to get certificate version: %w", err)
}
// 3. Update certificate status to Revoked
now := time.Now()
cert.Status = domain.CertificateStatusRevoked
cert.RevokedAt = &now
cert.RevocationReason = reason
cert.UpdatedAt = now
if err := s.certRepo.Update(ctx, cert); err != nil {
return fmt.Errorf("failed to update certificate status: %w", err)
}
// 4. Record revocation in certificate_revocations table (for CRL generation)
if s.revocationRepo != nil {
revocation := &domain.CertificateRevocation{
ID: generateID("rev"),
CertificateID: certID,
SerialNumber: version.SerialNumber,
Reason: reason,
RevokedBy: actor,
RevokedAt: now,
IssuerID: cert.IssuerID,
CreatedAt: now,
}
if err := s.revocationRepo.Create(ctx, revocation); err != nil {
slog.Error("failed to record revocation for CRL", "error", err, "certificate_id", certID)
// Don't fail the overall revocation — the cert status is already updated
}
}
// 5. Notify the issuer connector (best-effort)
if s.issuerRegistry != nil {
if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok {
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
slog.Error("failed to notify issuer of revocation",
"error", err,
"issuer_id", cert.IssuerID,
"serial", version.SerialNumber)
// Best-effort — don't fail the overall revocation
} else if s.revocationRepo != nil {
// Mark issuer as notified
revocations, _ := s.revocationRepo.ListByCertificate(ctx, certID)
for _, rev := range revocations {
if rev.SerialNumber == version.SerialNumber {
_ = s.revocationRepo.MarkIssuerNotified(ctx, rev.ID)
}
}
}
}
}
// 6. Record audit event
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_revoked", "certificate", certID,
map[string]interface{}{
"common_name": cert.CommonName,
"serial": version.SerialNumber,
"reason": reason,
}); err != nil {
slog.Error("failed to record audit event", "error", err)
}
// 7. Send revocation notification
if s.notificationSvc != nil {
if err := s.notificationSvc.SendRevocationNotification(ctx, cert, reason); err != nil {
slog.Error("failed to send revocation notification", "error", err, "certificate_id", certID)
}
}
return nil
}
// GetRevokedCertificates returns all revoked certificate records (for CRL generation).
func (s *CertificateService) GetRevokedCertificates() ([]*domain.CertificateRevocation, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
return s.revocationRepo.ListAll(context.Background())
}