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:
@@ -304,6 +304,10 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng
|
||||
return e.handleSignCSR(ctx, req)
|
||||
case "import-root":
|
||||
return e.handleImportRoot(ctx, req)
|
||||
case "revoke-cert":
|
||||
return e.handleRevokeCert(ctx, req)
|
||||
case "delete-cert":
|
||||
return e.handleDeleteCert(ctx, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation)
|
||||
}
|
||||
@@ -831,19 +835,23 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng
|
||||
return nil, fmt.Errorf("ca: parse cert record: %w", err)
|
||||
}
|
||||
|
||||
return &engine.Response{
|
||||
Data: map[string]interface{}{
|
||||
"serial": record.Serial,
|
||||
"issuer": record.Issuer,
|
||||
"cn": record.CN,
|
||||
"sans": record.SANs,
|
||||
"profile": record.Profile,
|
||||
"cert_pem": record.CertPEM,
|
||||
"issued_by": record.IssuedBy,
|
||||
"issued_at": record.IssuedAt.Format(time.RFC3339),
|
||||
"expires_at": record.ExpiresAt.Format(time.RFC3339),
|
||||
},
|
||||
}, nil
|
||||
data := map[string]interface{}{
|
||||
"serial": record.Serial,
|
||||
"issuer": record.Issuer,
|
||||
"cn": record.CN,
|
||||
"sans": record.SANs,
|
||||
"profile": record.Profile,
|
||||
"cert_pem": record.CertPEM,
|
||||
"issued_by": record.IssuedBy,
|
||||
"issued_at": record.IssuedAt.Format(time.RFC3339),
|
||||
"expires_at": record.ExpiresAt.Format(time.RFC3339),
|
||||
"revoked": record.Revoked,
|
||||
}
|
||||
if record.Revoked {
|
||||
data["revoked_at"] = record.RevokedAt.Format(time.RFC3339)
|
||||
data["revoked_by"] = record.RevokedBy
|
||||
}
|
||||
return &engine.Response{Data: data}, nil
|
||||
}
|
||||
|
||||
func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
@@ -1119,6 +1127,99 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *CAEngine) handleRevokeCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serial, _ := req.Data["serial"].(string)
|
||||
if serial == "" {
|
||||
serial = req.Path
|
||||
}
|
||||
if serial == "" {
|
||||
return nil, fmt.Errorf("ca: serial is required")
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json")
|
||||
if err != nil {
|
||||
if errors.Is(err, barrier.ErrNotFound) {
|
||||
return nil, ErrCertNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("ca: load cert record: %w", err)
|
||||
}
|
||||
|
||||
var record CertRecord
|
||||
if err := json.Unmarshal(recordData, &record); err != nil {
|
||||
return nil, fmt.Errorf("ca: parse cert record: %w", err)
|
||||
}
|
||||
|
||||
if record.Revoked {
|
||||
return nil, fmt.Errorf("ca: certificate is already revoked")
|
||||
}
|
||||
|
||||
record.Revoked = true
|
||||
record.RevokedAt = time.Now()
|
||||
record.RevokedBy = req.CallerInfo.Username
|
||||
|
||||
updated, err := json.Marshal(&record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: marshal cert record: %w", err)
|
||||
}
|
||||
if err := e.barrier.Put(ctx, e.mountPath+"certs/"+serial+".json", updated); err != nil {
|
||||
return nil, fmt.Errorf("ca: store cert record: %w", err)
|
||||
}
|
||||
|
||||
return &engine.Response{
|
||||
Data: map[string]interface{}{
|
||||
"serial": serial,
|
||||
"revoked_at": record.RevokedAt.Format(time.RFC3339),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *CAEngine) handleDeleteCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serial, _ := req.Data["serial"].(string)
|
||||
if serial == "" {
|
||||
serial = req.Path
|
||||
}
|
||||
if serial == "" {
|
||||
return nil, fmt.Errorf("ca: serial is required")
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Verify the record exists before deleting.
|
||||
_, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json")
|
||||
if err != nil {
|
||||
if errors.Is(err, barrier.ErrNotFound) {
|
||||
return nil, ErrCertNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("ca: load cert record: %w", err)
|
||||
}
|
||||
|
||||
if err := e.barrier.Delete(ctx, e.mountPath+"certs/"+serial+".json"); err != nil {
|
||||
return nil, fmt.Errorf("ca: delete cert record: %w", err)
|
||||
}
|
||||
|
||||
return &engine.Response{
|
||||
Data: map[string]interface{}{"ok": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func defaultCAConfig() *CAConfig {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package ca
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CAConfig is the CA engine configuration stored in the barrier.
|
||||
type CAConfig struct {
|
||||
@@ -27,11 +29,14 @@ type IssuerConfig struct {
|
||||
type CertRecord struct {
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RevokedAt time.Time `json:"revoked_at,omitempty"`
|
||||
Serial string `json:"serial"`
|
||||
Issuer string `json:"issuer"`
|
||||
CN string `json:"cn"`
|
||||
Profile string `json:"profile"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
IssuedBy string `json:"issued_by"`
|
||||
RevokedBy string `json:"revoked_by,omitempty"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
Revoked bool `json:"revoked,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user