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

@@ -98,6 +98,14 @@ func (m *mockVault) ListCerts(ctx context.Context, token, mount string) ([]CertS
return nil, nil
}
func (m *mockVault) RevokeCert(ctx context.Context, token, mount, serial string) error {
return nil
}
func (m *mockVault) DeleteCert(ctx context.Context, token, mount, serial string) error {
return nil
}
func (m *mockVault) Close() error { return nil }
// newTestWebServer builds a WebServer wired to the given mock, suitable for unit tests.

View File

@@ -329,6 +329,9 @@ type CertDetail struct {
IssuedAt string
ExpiresAt string
CertPEM string
Revoked bool
RevokedAt string
RevokedBy string
}
// GetCert retrieves a full certificate record by serial number.
@@ -349,6 +352,8 @@ func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string)
Profile: rec.Profile,
IssuedBy: rec.IssuedBy,
CertPEM: string(rec.CertPem),
Revoked: rec.Revoked,
RevokedBy: rec.RevokedBy,
}
if rec.IssuedAt != nil {
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
@@ -356,9 +361,24 @@ func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string)
if rec.ExpiresAt != nil {
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.RevokedAt != nil {
cd.RevokedAt = rec.RevokedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return cd, nil
}
// RevokeCert marks a certificate as revoked.
func (c *VaultClient) RevokeCert(ctx context.Context, token, mount, serial string) error {
_, err := c.ca.RevokeCert(withToken(ctx, token), &pb.RevokeCertRequest{Mount: mount, Serial: serial})
return err
}
// DeleteCert permanently removes a certificate record.
func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial string) error {
_, err := c.ca.DeleteCert(withToken(ctx, token), &pb.DeleteCertRequest{Mount: mount, Serial: serial})
return err
}
// CertSummary holds lightweight certificate metadata for list views.
type CertSummary struct {
Serial string

View File

@@ -43,6 +43,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleCertDetail))
r.Get("/cert/{serial}/download", ws.requireAuth(ws.handleCertDownload))
r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleCertRevoke))
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleCertDelete))
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
})
}
@@ -531,6 +533,11 @@ func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
}
// Stream a tgz archive containing the private key (PKCS8) and certificate.
// Extend the write deadline before streaming so that slow gRPC backends
// don't consume the server WriteTimeout before we start writing.
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Now().Add(60 * time.Second))
filename := issuedCert.Serial + ".tgz"
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
@@ -621,6 +628,70 @@ func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request)
_, _ = w.Write([]byte(cert.CertPEM))
}
func (ws *WebServer) handleCertRevoke(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
if err := ws.vault.RevokeCert(r.Context(), token, mountName, serial); err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/pki/cert/"+serial, http.StatusSeeOther)
}
func (ws *WebServer) handleCertDelete(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findCAMount(r, token)
if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
// Fetch the cert to get the issuer for the redirect.
cert, certErr := ws.vault.GetCert(r.Context(), token, mountName, serial)
if err := ws.vault.DeleteCert(r.Context(), token, mountName, serial); err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
if certErr == nil && cert != nil {
http.Redirect(w, r, "/pki/issuer/"+cert.Issuer, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/pki", http.StatusSeeOther)
}
func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)

View File

@@ -37,6 +37,8 @@ type vaultBackend interface {
SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error)
GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error)
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
RevokeCert(ctx context.Context, token, mount, serial string) error
DeleteCert(ctx context.Context, token, mount, serial string) error
Close() error
}
@@ -98,6 +100,12 @@ func (lw *loggingResponseWriter) WriteHeader(code int) {
lw.ResponseWriter.WriteHeader(code)
}
// Unwrap returns the underlying ResponseWriter so that http.ResponseController
// can reach it to set deadlines and perform other extended operations.
func (lw *loggingResponseWriter) Unwrap() http.ResponseWriter {
return lw.ResponseWriter
}
// Start starts the web server. It blocks until the server is closed.
func (ws *WebServer) Start() error {
r := chi.NewRouter()