Add certificate revocation, deletion, and retrieval

Admins can now revoke or delete certificate records from the cert detail
page in the web UI. Revoked certificates display a [REVOKED] badge and
show revocation metadata (time and actor). Deletion redirects to the
issuer page.

The REST API gains three new authenticated endpoints that mirror the
gRPC surface:
  GET    /v1/ca/{mount}/cert/{serial}         (auth required)
  POST   /v1/ca/{mount}/cert/{serial}/revoke  (admin only)
  DELETE /v1/ca/{mount}/cert/{serial}         (admin only)

The CA engine stores revocation state (revoked, revoked_at, revoked_by)
directly in the existing CertRecord barrier entry. The proto CertRecord
message is extended with the same three fields (field numbers 10–12).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 13:37:54 -07:00
parent 74e35ce63e
commit d574685b99
27 changed files with 839 additions and 91 deletions

View File

@@ -420,6 +420,45 @@ func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.Si
}, nil
}
func (cs *caServer) RevokeCert(ctx context.Context, req *pb.RevokeCertRequest) (*pb.RevokeCertResponse, error) {
if req.Mount == "" || req.Serial == "" {
return nil, status.Error(codes.InvalidArgument, "mount and serial are required")
}
resp, err := cs.caHandleRequest(ctx, req.Mount, "revoke-cert", &engine.Request{
Operation: "revoke-cert",
CallerInfo: cs.callerInfo(ctx),
Data: map[string]interface{}{"serial": req.Serial},
})
if err != nil {
return nil, err
}
serial, _ := resp.Data["serial"].(string)
var revokedAt *timestamppb.Timestamp
if s, ok := resp.Data["revoked_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, s); err == nil {
revokedAt = timestamppb.New(t)
}
}
cs.s.logger.Info("audit: certificate revoked", "mount", req.Mount, "serial", serial, "username", callerUsername(ctx))
return &pb.RevokeCertResponse{Serial: serial, RevokedAt: revokedAt}, nil
}
func (cs *caServer) DeleteCert(ctx context.Context, req *pb.DeleteCertRequest) (*pb.DeleteCertResponse, error) {
if req.Mount == "" || req.Serial == "" {
return nil, status.Error(codes.InvalidArgument, "mount and serial are required")
}
_, err := cs.caHandleRequest(ctx, req.Mount, "delete-cert", &engine.Request{
Operation: "delete-cert",
CallerInfo: cs.callerInfo(ctx),
Data: map[string]interface{}{"serial": req.Serial},
})
if err != nil {
return nil, err
}
cs.s.logger.Info("audit: certificate deleted", "mount", req.Mount, "serial", req.Serial, "username", callerUsername(ctx))
return &pb.DeleteCertResponse{}, nil
}
// --- helpers ---
func certRecordFromData(d map[string]interface{}) *pb.CertRecord {
@@ -429,8 +468,10 @@ func certRecordFromData(d map[string]interface{}) *pb.CertRecord {
profile, _ := d["profile"].(string)
issuedBy, _ := d["issued_by"].(string)
certPEM, _ := d["cert_pem"].(string)
revoked, _ := d["revoked"].(bool)
revokedBy, _ := d["revoked_by"].(string)
sans := toStringSliceFromInterface(d["sans"])
var issuedAt, expiresAt *timestamppb.Timestamp
var issuedAt, expiresAt, revokedAt *timestamppb.Timestamp
if s, ok := d["issued_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, s); err == nil {
issuedAt = timestamppb.New(t)
@@ -441,6 +482,11 @@ func certRecordFromData(d map[string]interface{}) *pb.CertRecord {
expiresAt = timestamppb.New(t)
}
}
if s, ok := d["revoked_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, s); err == nil {
revokedAt = timestamppb.New(t)
}
}
return &pb.CertRecord{
Serial: serial,
Issuer: issuer,
@@ -451,6 +497,9 @@ func certRecordFromData(d map[string]interface{}) *pb.CertRecord {
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
CertPem: []byte(certPEM),
Revoked: revoked,
RevokedAt: revokedAt,
RevokedBy: revokedBy,
}
}