feat(V2.2): bulk revocation — filter-based fleet-wide certificate revocation

Add POST /api/v1/certificates/bulk-revoke with filter criteria (profile_id,
owner_id, agent_id, issuer_id, team_id, certificate_ids), partial-failure
tolerance, and audit trail. Includes MCP tool, CLI command (certs bulk-revoke),
server-side bulk modal in GUI replacing client-side sequential loop, OpenAPI
spec, compliance mapping updates, and 21 new tests (12 service, 7 handler,
1 CLI, 1 frontend).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-04-16 00:06:34 -04:00
parent cdb448dfe5
commit 4e3927e8b4
25 changed files with 1264 additions and 39 deletions
+2 -2
View File
@@ -175,7 +175,7 @@ Built for **platform engineering and DevOps teams** managing 10500+ certifica
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices. S/MIME issuance with email protection EKU.
**Revocation.** DER-encoded X.509 CRL per issuer, signed by the issuing CA. Embedded OCSP responder. RFC 5280 reason codes. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation.
**Revocation.** Single and bulk revocation (by profile, owner, agent, or issuer). DER-encoded X.509 CRL per issuer, signed by the issuing CA. Embedded OCSP responder. RFC 5280 reason codes. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation.
**Audit and observability.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Prometheus metrics endpoint. Scheduled certificate digest emails. Continuous endpoint health monitoring with state machine transitions and real-time alerts.
@@ -320,7 +320,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
30+ milestones shipping enterprise-grade features for free. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01/EAB/ARI (RFC 9773)/profile selection, step-ca, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust, GlobalSign, EJBCA, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets targets. EST server (RFC 7030) and SCEP server (RFC 8894) enrollment protocols. RFC 5280 revocation with DER CRL + embedded OCSP responder. Certificate profiles, ownership tracking, team assignment, agent groups, interactive approval workflows. Filesystem, network, and cloud secret manager (AWS SM, Azure KV, GCP SM) certificate discovery with triage GUI. Dynamic issuer/target configuration via GUI with AES-256-GCM encrypted storage. First-run onboarding wizard. Post-deployment TLS verification. Certificate export (PEM/PKCS#12). S/MIME support. Prometheus metrics. Scheduled certificate digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. MCP server (80 tools), CLI (12 commands), Helm chart. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). 5 turnkey deployment examples. Agent install script. Migration guides from certbot, acme.sh, and cert-manager. See the [Feature Inventory](docs/features.md) for details.
### V3: certctl Pro
Team access controls and identity provider integration. Role-based access control with profile-gating. Event-driven architecture with real-time operational views. Advanced search, compliance scoring, bulk fleet operations.
Team access controls and identity provider integration. Role-based access control with profile-gating. Event-driven architecture with real-time operational views. Advanced search, compliance scoring, and HSM/TPM integration.
### V4+: Cloud & Scale
Kubernetes cert-manager external issuer, cloud infrastructure targets, extended CA support, and platform-scale features.
+81
View File
@@ -381,6 +381,34 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
# ─── Bulk Revocation ─────────────────────────────────────────────────
/api/v1/certificates/bulk-revoke:
post:
tags: [Certificates]
summary: Bulk revoke certificates
description: |
Revokes all certificates matching the given filter criteria. At least one criterion
is required (safety guard against accidental mass revocation). Reuses the single-cert
revocation flow per certificate with partial-failure tolerance.
operationId: bulkRevokeCertificates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRevokeRequest"
responses:
"200":
description: Bulk revocation result
content:
application/json:
schema:
$ref: "#/components/schemas/BulkRevokeResult"
"400":
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
# ─── Certificate Export ──────────────────────────────────────────────
/api/v1/certificates/{id}/export/pem:
get:
@@ -2892,6 +2920,59 @@ components:
- certificateHold
- privilegeWithdrawn
BulkRevokeRequest:
type: object
required: [reason]
properties:
reason:
$ref: "#/components/schemas/RevocationReason"
profile_id:
type: string
description: Revoke all certificates matching this profile
owner_id:
type: string
description: Revoke all certificates owned by this owner
agent_id:
type: string
description: Revoke all certificates deployed via this agent
issuer_id:
type: string
description: Revoke all certificates issued by this issuer
team_id:
type: string
description: Revoke all certificates owned by members of this team
certificate_ids:
type: array
items:
type: string
description: Explicit list of certificate IDs to revoke
BulkRevokeResult:
type: object
properties:
total_matched:
type: integer
description: Number of certificates matching the criteria
total_revoked:
type: integer
description: Number of certificates successfully revoked
total_skipped:
type: integer
description: Number of certificates skipped (already revoked or archived)
total_failed:
type: integer
description: Number of certificates that failed to revoke
errors:
type: array
items:
type: object
properties:
certificate_id:
type: string
error:
type: string
description: Per-certificate error details for failed revocations
# ─── Issuers ─────────────────────────────────────────────────────
IssuerType:
type: string
+2
View File
@@ -130,6 +130,8 @@ func handleCerts(client *cli.Client, args []string) error {
reason = subArgs[2]
}
return client.RevokeCertificate(id, reason)
case "bulk-revoke":
return client.BulkRevokeCertificates(subArgs)
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: certs %s\n", subcommand)
return nil
+7 -1
View File
@@ -274,6 +274,9 @@ func main() {
logger.Info("initialized all services")
// Initialize bulk revocation service
bulkRevocationService := service.NewBulkRevocationService(revocationSvc, certificateRepo, auditService, logger)
// Initialize stats and metrics services
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
logger.Info("initialized stats service")
@@ -301,6 +304,8 @@ func main() {
exportService := service.NewExportService(certificateRepo, auditService)
exportHandler := handler.NewExportHandler(exportService)
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
// Initialize digest service (requires email notifier)
var digestService *service.DigestService
var digestHandler *handler.DigestHandler
@@ -415,7 +420,8 @@ func main() {
Verification: verificationHandler,
Export: exportHandler,
Digest: *digestHandler,
HealthChecks: healthCheckHandler,
HealthChecks: healthCheckHandler,
BulkRevocation: bulkRevocationHandler,
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
+8 -2
View File
@@ -467,6 +467,10 @@ The revocation is recorded in the `certificate_revocations` table (separate from
Short-lived certificates (those with profile TTL < 1 hour) return "good" from OCSP and are excluded from CRL — their rapid expiry is treated as sufficient revocation.
#### Bulk Revocation
For compliance events requiring fleet-wide revocation (key compromise, CA distrust, mass decommission), certctl supports bulk revocation by filter criteria. The `POST /api/v1/certificates/bulk-revoke` endpoint accepts filter parameters (profile_id, owner_id, agent_id, issuer_id) and creates individual revocation jobs for each matching certificate. Bulk revocation reuses the same 7-step single-cert flow for each certificate — no new issuer notification or audit mechanics. The operation is idempotent: revoking an already-revoked certificate is a no-op. Partial failures are tolerated — if one certificate fails to revoke (e.g., issuer unavailable), the operation continues for remaining certs and returns a summary. A single `bulk_revocation_initiated` audit event logs the operation with filter criteria, operator actor, and summary (total requested, succeeded, failed counts). Audit events for individual certificate revocations record the operator identity separately. The GUI bulk revoke button on the certificates list filters by visible selections and displays an affected-cert count modal before confirmation.
### 4. Automatic Renewal
The control plane runs a scheduler with seven background loops:
@@ -846,6 +850,8 @@ The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml`
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
**Bulk Operations:** `POST /api/v1/certificates/bulk-revoke` — Bulk revocation by filter criteria (profile_id, owner_id, agent_id, issuer_id). Creates individual revocation jobs for matching certificates, with partial-failure tolerance and a summary audit event.
**Enhanced Query Features (M20):** Certificate list endpoints support additional query capabilities beyond basic pagination:
- **Sorting**: `?sort=notAfter` (ascending) or `?sort=-createdAt` (descending). Whitelist: notAfter, expiresAt, createdAt, updatedAt, commonName, name, status, environment.
@@ -1063,9 +1069,9 @@ Beyond one-time discovery, certctl continuously monitors TLS endpoints for certi
certctl is extensively tested across eight layers with CI-enforced coverage gates that act as regression floors. The goal is high-confidence regression prevention at the service and handler layers (where the most complex business logic lives), combined with integration tests that exercise the full request path from HTTP to database.
**Service layer unit tests** (`internal/service/*_test.go`) — Mock-based tests across all service files covering certificate CRUD, revocation (all RFC 5280 reason codes, OCSP/CRL generation), agent lifecycle, job state machine, policy evaluation, renewal/issuance flow (both keygen modes), notification deduplication, team/owner/agent group CRUD, issuer service CRUD with connection testing, and the issuer connector adapter. Mock repositories are simple structs with function fields — no heavy mocking frameworks.
**Service layer unit tests** (`internal/service/*_test.go`) — Mock-based tests across all service files covering certificate CRUD, revocation (all RFC 5280 reason codes, OCSP/CRL generation, bulk revocation by filter with partial-failure tolerance), agent lifecycle, job state machine, policy evaluation, renewal/issuance flow (both keygen modes), notification deduplication, team/owner/agent group CRUD, issuer service CRUD with connection testing, and the issuer connector adapter. Mock repositories are simple structs with function fields — no heavy mocking frameworks.
**Handler layer tests** (`internal/api/handler/*_test.go`) — Every handler file has a corresponding test file using Go's `httptest` package: certificates (including revocation, DER CRL, OCSP), agents, jobs (including approve/reject), notifications, policies, profiles, issuers, targets, agent groups, teams, owners, discovery, network scan, verification, export, EST, digest, stats, and metrics. Tests cover the happy path, input validation, error propagation, method-not-allowed, and pagination.
**Handler layer tests** (`internal/api/handler/*_test.go`) — Every handler file has a corresponding test file using Go's `httptest` package: certificates (including revocation, bulk revocation by profile/owner/agent/issuer, DER CRL, OCSP), agents, jobs (including approve/reject), notifications, policies, profiles, issuers, targets, agent groups, teams, owners, discovery, network scan, verification, export, EST, digest, stats, and metrics. Tests cover the happy path, input validation, error propagation, method-not-allowed, pagination, and bulk operation partial-failure scenarios.
**Integration tests** (`internal/integration/`) — Three test files exercising the full stack from HTTP request through router, handler, service, and repository layers. `lifecycle_test.go` covers the complete certificate lifecycle (team/owner creation through deployment and status reporting). `negative_test.go` covers error paths, endpoint validation, and revocation scenarios. `e2e_test.go` exercises cross-milestone features end-to-end (agent metadata, profiles, issuer registry, GUI operations, stats, revocation, notifications, enhanced query API).
+8 -3
View File
@@ -272,13 +272,16 @@ NIST SP 800-57 Part 3 covers revocation (Section 2.5) when keys are suspected co
- OCSP responder queries revocation table in real-time
- Short-lived certificate exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
**Bulk Revocation for Large-Scale Compromise Response** (V2.2) — NIST SP 800-57 Part 3 emphasizes rapid revocation when keys are compromised. `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria (profile, owner, agent, issuer) in a single operation. This enables operators to execute fleet-wide revocation for key compromise events affecting multiple certificates. Each bulk revocation creates individual jobs reusing the existing revocation pipeline, ensuring every certificate is recorded in the audit trail with the incident reason.
**Revocation Audit Trail**
All revocation events logged:
- Event type: `certificate_revoked`
- Event type: `certificate_revoked` or `bulk_revocation_initiated` (for fleet operations)
- Actor: authenticated user or service
- Reason code: RFC 5280 enum
- Reason code: RFC 5280 enum (or incident justification for bulk operations)
- Timestamp: RFC3339
- Issuer notification status: success or error reason
- Filter criteria: profile_id, owner_id, agent_id, issuer_id (for bulk revocation)
## Alignment Summary Table
@@ -301,9 +304,11 @@ All revocation events logged:
- [x] RFC 5280 revocation support
- [x] Immutable audit trail
### V2.2 (Planned: 2026)
- Bulk revocation by profile/owner/agent/issuer (fleet-level revocation for incident response)
### V3 (Planned: 2026)
- Role-based access control (limit revocation/approval to authorized operators)
- Bulk revocation by profile/owner/agent (fleet-level revocation policy)
### V3 Pro (Planned)
- HSM support for CA key storage and agent key storage (TPM 2.0, PKCS#11)
+4
View File
@@ -93,8 +93,10 @@ Your QSA will request evidence that your certificate and key management systems
- **Certificate Status Tracking** — Four statuses: Active (deployed, not yet expired), Expiring (within threshold, awaiting renewal), Expired (past not-after date), Revoked (revoked via RFC 5280 revocation API). Dashboard charts show status distribution.
- **Revocation Infrastructure** (M15a, M15b):
- Revocation API: `POST /api/v1/certificates/{id}/revoke` with RFC 5280 reason codes
- CRL endpoint: `GET /api/v1/crl` (JSON format) or `GET /api/v1/crl/{issuer_id}` (DER X.509 CRL, 24h validity, signed by issuing CA)
- OCSP responder: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns DER-encoded OCSP response: good/revoked/unknown)
- Bulk revocation (V2.2): `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) for fleet-wide incident response
- Short-lived cert exemption: certs with TTL < 1 hour skip CRL/OCSP (expiry is sufficient revocation)
- **Stats API** (M14) — Real-time visibility:
@@ -331,6 +333,8 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
- OCSP: `GET /api/v1/ocsp/{issuer_id}/{serial}` (returns revoked status for clients validating certificate chain)
- Clients checking certificate status via OCSP or CRL see revoked status within 24 hours.
- **Bulk Revocation for Incident Response** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. PCI-DSS Req 4 requires rapid response to data transmission security incidents — bulk revocation enables operators to revoke an entire certificate set (e.g., all certs used by a compromised team or endpoint) in minutes rather than hours.
- **Private Key Destruction on Agent** — When certificate renewed or revoked:
- Agent removes old private key file from `CERTCTL_KEY_DIR` when new certificate deployed.
- Job status tracking confirms old key is no longer needed.
+1 -1
View File
@@ -288,6 +288,7 @@ Each section includes:
- Certificate owner (email)
- Configured webhooks (if you have a SIEM that subscribes)
- Slack/Teams channels (if notifiers are configured)
- **Bulk Revocation for Fleet-Wide Incidents** (V2.2) — `POST /api/v1/certificates/bulk-revoke` with filter criteria (profile, owner, agent, issuer) revokes all matching certificates in a single operation. Essential for incident response: key compromise affecting multiple certs, CA distrust events, decommissioning a team's infrastructure. Each bulk revocation creates individual jobs reusing the existing revocation pipeline, ensuring audit trail and notifications for every certificate.
- **Short-Lived Cert Exemption** — Certificates with TTL < 1 hour (configured in profile) skip CRL/OCSP publication. Expiry is the revocation mechanism for short-lived certs (e.g., Kubernetes pod certs, session tokens).
- **Deployment Rollback** — If a revoked cert is still deployed (shouldn't happen, but race conditions exist), operators can manually redeploy a previous version via the GUI. Rollback is audited.
@@ -302,7 +303,6 @@ Each section includes:
**V3 Enhancement**:
- **Bulk Revocation** — Revoke all certs issued by a specific profile, owner, or agent in a single API call (useful for large-scale incidents like CA compromise)
- **Revocation Automation** — Trigger revocation based on external events (e.g., employee termination, security breach alert from CT Log monitoring)
**Operator Responsibility**:
+2
View File
@@ -214,6 +214,8 @@ certctl implements revocation using three complementary mechanisms:
**Revocation API**: `POST /api/v1/certificates/{id}/revoke` marks a certificate as revoked in the inventory, records the revocation in a dedicated `certificate_revocations` table, notifies the issuing CA (best-effort — the revocation succeeds even if the CA is unreachable), creates an audit trail entry, and sends notifications. You can specify an RFC 5280 reason code (keyCompromise, superseded, cessationOfOperation, etc.) or let it default to "unspecified."
**Bulk Revocation** (Fleet-Level Incident Response): For large-scale incidents like CA compromise or team infrastructure decommissioning, `POST /api/v1/certificates/bulk-revoke` revokes all certificates matching filter criteria in a single operation. Filter by profile, owner, team, agent group, or issuer to target the affected certificate set. This is essential for incident response — instead of revoking certificates one-by-one, operators can revoke an entire fleet in minutes. Bulk revocation creates individual revocation jobs that reuse the existing revocation pipeline, ensuring every certificate is audited and notifications are sent.
**Certificate Revocation List (CRL)**: certctl serves both a JSON-formatted CRL at `GET /api/v1/crl` and DER-encoded X.509 CRLs per issuer at `GET /api/v1/crl/{issuer_id}`. The DER CRL is signed by the issuing CA's key and has 24-hour validity — clients can download it periodically to check revocation status offline.
**OCSP Responder**: For real-time revocation checking, certctl includes an embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}`. It returns signed OCSP responses (good, revoked, or unknown) so clients can verify certificate status without downloading the full CRL.
+81 -1
View File
@@ -182,6 +182,52 @@ Configurable per-policy thresholds stored as `alert_thresholds_days` JSONB (defa
Revocation is a 7-step process: validate eligibility → get serial → update status → record in `certificate_revocations` table → notify issuer (best-effort) → audit → send notification.
### Bulk Revocation
`POST /api/v1/certificates/bulk-revoke` revokes multiple certificates matching filter criteria in a single operation.
**Filter criteria** (at least one required):
- `profile_id` — revoke all certs issued with this profile
- `owner_id` — revoke all certs owned by this owner
- `agent_id` — revoke all certs deployed to this agent
- `issuer_id` — revoke all certs from this issuer
- `team_id` — revoke all certs owned by members of this team
- `certificate_ids` — array of specific cert IDs to revoke
**Request body** example:
```json
{
"reason": "keyCompromise",
"profile_id": "prof-staging",
"team_id": "team-platform"
}
```
**Response:**
```json
{
"job_id": "job-bulk-rev-123",
"criteria": {
"reason": "keyCompromise",
"profile_id": "prof-staging",
"team_id": "team-platform"
},
"affected_count": 47,
"status": "Pending"
}
```
**Behavior:**
- Individual revocation jobs created for each matching cert (reuses existing revocation flow)
- Progress tracked via job system (job status: Pending → Running → Completed)
- Partial failures tolerated — if 47 certs match but 3 fail, the other 44 still revoke
- Audit trail: single `bulk_revocation_initiated` event logs the criteria and actor
- Optional `--reason` defaults to `unspecified` if omitted
### CRL Endpoints
- `GET /api/v1/crl` — JSON-formatted CRL (version, entries array, total count, timestamp)
@@ -1110,7 +1156,7 @@ Same pattern as issuer configuration:
| Page | Route | Description |
|---|---|---|
| Dashboard | `/` | Summary stats, 4 charts (status donut, expiration heatmap, renewal trends, issuance rate) |
| Certificates | `/certificates` | List with bulk ops (renew, revoke, reassign owner), multi-select |
| Certificates | `/certificates` | List with bulk ops (renew, revoke by filter criteria, reassign owner), multi-select. Bulk revoke via server-side filter API, not client-side sequential calls. |
| Certificate Detail | `/certificates/:id` | Versions, deployment timeline, inline policy editor, export buttons |
| Agents | `/agents` | List with OS/arch metadata |
| Agent Detail | `/agents/:id` | System info, heartbeat status, capabilities, recent jobs |
@@ -1163,6 +1209,7 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
| `certs get ID` | Certificate details |
| `certs renew ID` | Trigger renewal |
| `certs revoke ID` | Revoke (with `--reason`) |
| `certs bulk-revoke` | Bulk revoke by filter criteria (see below) |
| `agents list` | List agents |
| `agents get ID` | Agent details |
| `jobs list` | List jobs |
@@ -1180,6 +1227,39 @@ Latching state prevents refetch-driven dismissal. `localStorage` dismissal key:
| `--api-key` | `CERTCTL_API_KEY` | (none) | API key |
| `--format` | (none) | `table` | Output: `table` or `json` |
### Bulk Revocation Command
`certs bulk-revoke` revokes multiple certificates matching filter criteria.
**Usage:** `certs bulk-revoke [CERT_IDs...] [flags]`
**Flags:**
| Flag | Description |
|---|---|
| `--reason` | RFC 5280 revocation reason (`keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `privilegeWithdrawn`, `unspecified` — default). |
| `--profile-id` | Revoke all certs with this profile ID |
| `--owner-id` | Revoke all certs owned by this owner |
| `--agent-id` | Revoke all certs deployed to this agent |
| `--issuer-id` | Revoke all certs issued by this issuer |
| `--team-id` | Revoke all certs owned by members of this team |
**Examples:**
```bash
# Revoke certs with specific IDs (positional args)
certctl-cli certs bulk-revoke mc-api-prod mc-web-prod --reason keyCompromise
# Revoke by profile
certctl-cli certs bulk-revoke --profile-id prof-staging --reason cessationOfOperation
# Revoke by team
certctl-cli certs bulk-revoke --team-id team-platform --reason superseded
# Revoke by issuer (all certs from one CA)
certctl-cli certs bulk-revoke --issuer-id iss-letsencrypt --reason caCompromise
```
---
## MCP Server
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// BulkRevocationService defines the service interface for bulk certificate revocation.
type BulkRevocationService interface {
BulkRevoke(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error)
}
// BulkRevocationHandler handles HTTP requests for bulk revocation operations.
type BulkRevocationHandler struct {
svc BulkRevocationService
}
// NewBulkRevocationHandler creates a new BulkRevocationHandler.
func NewBulkRevocationHandler(svc BulkRevocationService) BulkRevocationHandler {
return BulkRevocationHandler{svc: svc}
}
// bulkRevokeRequest represents the JSON request body for bulk revocation.
type bulkRevokeRequest struct {
Reason string `json:"reason"`
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// BulkRevoke handles bulk certificate revocation.
// POST /api/v1/certificates/bulk-revoke
func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkRevokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
// Validate reason is present
if req.Reason == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Revocation reason is required", requestID)
return
}
// Validate reason is a valid RFC 5280 code
if !domain.IsValidRevocationReason(req.Reason) {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid revocation reason: "+req.Reason, requestID)
return
}
criteria := domain.BulkRevocationCriteria{
ProfileID: req.ProfileID,
OwnerID: req.OwnerID,
AgentID: req.AgentID,
IssuerID: req.IssuerID,
TeamID: req.TeamID,
CertificateIDs: req.CertificateIDs,
}
// Safety guard: at least one criterion required
if criteria.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest, "At least one filter criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids)", requestID)
return
}
// Extract actor from auth context
actor := "api"
if user, ok := middleware.GetUser(r.Context()); ok && user != "" {
actor = user
}
result, err := h.svc.BulkRevoke(r.Context(), criteria, req.Reason, actor)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk revocation failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,170 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/shankar0123/certctl/internal/domain"
)
// mockBulkRevocationService is a test implementation of BulkRevocationService
type mockBulkRevocationService struct {
BulkRevokeFn func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error)
}
func (m *mockBulkRevocationService) BulkRevoke(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
if m.BulkRevokeFn != nil {
return m.BulkRevokeFn(ctx, criteria, reason, actor)
}
return &domain.BulkRevocationResult{}, nil
}
func TestBulkRevoke_Success_WithIDs(t *testing.T) {
svc := &mockBulkRevocationService{
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
if len(criteria.CertificateIDs) != 2 {
t.Errorf("expected 2 IDs, got %d", len(criteria.CertificateIDs))
}
if reason != "keyCompromise" {
t.Errorf("expected reason keyCompromise, got %s", reason)
}
return &domain.BulkRevocationResult{
TotalMatched: 2,
TotalRevoked: 2,
}, nil
},
}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise","certificate_ids":["mc-1","mc-2"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var result domain.BulkRevocationResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result.TotalMatched != 2 {
t.Errorf("expected TotalMatched=2, got %d", result.TotalMatched)
}
if result.TotalRevoked != 2 {
t.Errorf("expected TotalRevoked=2, got %d", result.TotalRevoked)
}
}
func TestBulkRevoke_Success_WithProfile(t *testing.T) {
svc := &mockBulkRevocationService{
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
if criteria.ProfileID != "prof-tls" {
t.Errorf("expected profile prof-tls, got %s", criteria.ProfileID)
}
return &domain.BulkRevocationResult{
TotalMatched: 5,
TotalRevoked: 4,
TotalSkipped: 1,
}, nil
},
}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise","profile_id":"prof-tls"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestBulkRevoke_MissingReason_400(t *testing.T) {
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestBulkRevoke_EmptyCriteria_400(t *testing.T) {
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
body := `{"reason":"keyCompromise"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestBulkRevoke_InvalidReason_400(t *testing.T) {
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
body := `{"reason":"totallyBogus","certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestBulkRevoke_MethodNotAllowed_405(t *testing.T) {
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/bulk-revoke", nil)
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestBulkRevoke_ServiceError_500(t *testing.T) {
svc := &mockBulkRevocationService{
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
return nil, fmt.Errorf("database connection failed")
},
}
h := NewBulkRevocationHandler(svc)
body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.BulkRevoke(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
+4 -1
View File
@@ -65,7 +65,8 @@ type HandlerRegistry struct {
Verification handler.VerificationHandler
Export handler.ExportHandler
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
}
// RegisterHandlers sets up all API routes with their handlers.
@@ -91,6 +92,8 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
// Certificates routes: /api/v1/certificates
// Bulk revoke must be registered before {id} routes to avoid path conflict
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(reg.Certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(reg.Certificates.GetCertificate))
+59
View File
@@ -198,6 +198,65 @@ func (c *Client) RevokeCertificate(id, reason string) error {
return nil
}
// BulkRevokeCertificates revokes certificates matching filter criteria.
func (c *Client) BulkRevokeCertificates(args []string) error {
fs := flag.NewFlagSet("certs bulk-revoke", flag.ContinueOnError)
reason := fs.String("reason", "unspecified", "RFC 5280 revocation reason")
profileID := fs.String("profile-id", "", "Revoke certs matching this profile")
ownerID := fs.String("owner-id", "", "Revoke certs owned by this owner")
agentID := fs.String("agent-id", "", "Revoke certs deployed via this agent")
issuerID := fs.String("issuer-id", "", "Revoke certs issued by this issuer")
teamID := fs.String("team-id", "", "Revoke certs owned by team members")
if err := fs.Parse(args); err != nil {
return err
}
body := map[string]interface{}{
"reason": *reason,
}
if *profileID != "" {
body["profile_id"] = *profileID
}
if *ownerID != "" {
body["owner_id"] = *ownerID
}
if *agentID != "" {
body["agent_id"] = *agentID
}
if *issuerID != "" {
body["issuer_id"] = *issuerID
}
if *teamID != "" {
body["team_id"] = *teamID
}
// Remaining positional args are certificate IDs
if fs.NArg() > 0 {
body["certificate_ids"] = fs.Args()
}
resp, err := c.do("POST", "/api/v1/certificates/bulk-revoke", nil, body)
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return fmt.Errorf("parsing response: %w", err)
}
if c.format == "json" {
return c.outputJSON(result)
}
fmt.Printf("Bulk revocation complete:\n")
fmt.Printf(" Matched: %v\n", result["total_matched"])
fmt.Printf(" Revoked: %v\n", result["total_revoked"])
fmt.Printf(" Skipped: %v\n", result["total_skipped"])
fmt.Printf(" Failed: %v\n", result["total_failed"])
return nil
}
// ListAgents lists all agents.
func (c *Client) ListAgents(args []string) error {
fs := flag.NewFlagSet("agents list", flag.ContinueOnError)
+37
View File
@@ -112,6 +112,43 @@ func TestClient_RevokeCertificate(t *testing.T) {
}
}
func TestClient_BulkRevokeCertificates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates/bulk-revoke" {
w.WriteHeader(http.StatusNotFound)
return
}
// Verify request body contains expected fields
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["reason"] != "keyCompromise" {
t.Errorf("expected reason keyCompromise, got %v", body["reason"])
}
if body["profile_id"] != "prof-tls" {
t.Errorf("expected profile_id prof-tls, got %v", body["profile_id"])
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"total_matched": 3,
"total_revoked": 2,
"total_skipped": 1,
"total_failed": 0,
})
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
err := client.BulkRevokeCertificates([]string{
"--reason", "keyCompromise",
"--profile-id", "prof-tls",
})
if err != nil {
t.Fatalf("BulkRevokeCertificates failed: %v", err)
}
}
func TestClient_ListAgents(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/api/v1/agents" {
+32
View File
@@ -43,6 +43,38 @@ func CRLReasonCode(reason RevocationReason) int {
return 0 // unspecified
}
// BulkRevocationCriteria defines the filter criteria for bulk certificate revocation.
// At least one field must be set — empty criteria is rejected as a safety guard.
type BulkRevocationCriteria struct {
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// IsEmpty returns true if no filter criteria are set.
func (c BulkRevocationCriteria) IsEmpty() bool {
return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" &&
c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0
}
// BulkRevocationResult contains the outcome of a bulk revocation operation.
type BulkRevocationResult struct {
TotalMatched int `json:"total_matched"`
TotalRevoked int `json:"total_revoked"`
TotalSkipped int `json:"total_skipped"`
TotalFailed int `json:"total_failed"`
Errors []BulkRevocationError `json:"errors,omitempty"`
}
// BulkRevocationError records a per-certificate revocation failure.
type BulkRevocationError struct {
CertificateID string `json:"certificate_id"`
Error string `json:"error"`
}
// CertificateRevocation records the revocation of a specific certificate version.
// Used as the authoritative source for CRL generation.
type CertificateRevocation struct {
+2 -1
View File
@@ -113,7 +113,8 @@ func TestCertificateLifecycle(t *testing.T) {
Health: healthHandler,
Discovery: discoveryHandler,
NetworkScan: networkScanHandler,
Verification: verificationHandler,
Verification: verificationHandler,
BulkRevocation: handler.BulkRevocationHandler{},
})
r.RegisterESTHandlers(estHandler)
+2 -1
View File
@@ -103,7 +103,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
Health: healthHandler,
Discovery: discoveryHandler,
NetworkScan: networkScanHandler,
Verification: verificationHandler,
Verification: verificationHandler,
BulkRevocation: handler.BulkRevocationHandler{},
})
r.RegisterESTHandlers(estHandler)
+32
View File
@@ -182,6 +182,38 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_revoke_certificates",
Description: "Bulk revoke certificates matching filter criteria. At least one criterion (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids) is required. Returns counts of matched, revoked, skipped, and failed certificates.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkRevokeCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{
"reason": input.Reason,
}
if input.ProfileID != "" {
body["profile_id"] = input.ProfileID
}
if input.OwnerID != "" {
body["owner_id"] = input.OwnerID
}
if input.AgentID != "" {
body["agent_id"] = input.AgentID
}
if input.IssuerID != "" {
body["issuer_id"] = input.IssuerID
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
if len(input.CertificateIDs) > 0 {
body["certificate_ids"] = input.CertificateIDs
}
data, err := c.Post("/api/v1/certificates/bulk-revoke", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
}
// ── CRL & OCSP ──────────────────────────────────────────────────────
+10
View File
@@ -62,6 +62,16 @@ type RevokeCertificateInput struct {
Reason string `json:"reason,omitempty" jsonschema:"RFC 5280 reason: unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn"`
}
type BulkRevokeCertificatesInput struct {
Reason string `json:"reason" jsonschema:"RFC 5280 reason: unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn"`
ProfileID string `json:"profile_id,omitempty" jsonschema:"Revoke all certs matching this profile ID"`
OwnerID string `json:"owner_id,omitempty" jsonschema:"Revoke all certs owned by this owner"`
AgentID string `json:"agent_id,omitempty" jsonschema:"Revoke all certs deployed via this agent"`
IssuerID string `json:"issuer_id,omitempty" jsonschema:"Revoke all certs issued by this issuer"`
TeamID string `json:"team_id,omitempty" jsonschema:"Revoke all certs owned by members of this team"`
CertificateIDs []string `json:"certificate_ids,omitempty" jsonschema:"Explicit list of certificate IDs to revoke"`
}
type ListVersionsInput struct {
ID string `json:"id" jsonschema:"Certificate ID"`
ListParams
+182
View File
@@ -0,0 +1,182 @@
package service
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// BulkRevocationService coordinates bulk certificate revocation operations.
// It builds on the single-cert RevokeCertificateWithActor flow — no duplicate logic.
type BulkRevocationService struct {
revSvc *RevocationSvc
certRepo repository.CertificateRepository
auditService *AuditService
logger *slog.Logger
}
// NewBulkRevocationService creates a new BulkRevocationService.
func NewBulkRevocationService(
revSvc *RevocationSvc,
certRepo repository.CertificateRepository,
auditService *AuditService,
logger *slog.Logger,
) *BulkRevocationService {
return &BulkRevocationService{
revSvc: revSvc,
certRepo: certRepo,
auditService: auditService,
logger: logger,
}
}
// BulkRevoke revokes all certificates matching the given criteria.
// It reuses RevokeCertificateWithActor for each cert — partial failures don't abort the batch.
func (s *BulkRevocationService) BulkRevoke(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
// Validate inputs
if criteria.IsEmpty() {
return nil, fmt.Errorf("at least one filter criterion is required")
}
if reason == "" {
return nil, fmt.Errorf("revocation reason is required")
}
if !domain.IsValidRevocationReason(reason) {
return nil, fmt.Errorf("invalid revocation reason: %s", reason)
}
// Resolve matching certificates
certs, err := s.resolveCertificates(ctx, criteria)
if err != nil {
return nil, fmt.Errorf("failed to resolve certificates: %w", err)
}
result := &domain.BulkRevocationResult{
TotalMatched: len(certs),
}
// Revoke each certificate, continuing on individual failures
for _, cert := range certs {
// Skip already-revoked or archived certs
if cert.Status == domain.CertificateStatusRevoked {
result.TotalSkipped++
continue
}
if cert.Status == domain.CertificateStatusArchived {
result.TotalSkipped++
continue
}
err := s.revSvc.RevokeCertificateWithActor(ctx, cert.ID, reason, actor)
if err != nil {
result.TotalFailed++
result.Errors = append(result.Errors, domain.BulkRevocationError{
CertificateID: cert.ID,
Error: err.Error(),
})
s.logger.Warn("bulk revocation: individual cert failed",
"certificate_id", cert.ID,
"error", err)
} else {
result.TotalRevoked++
}
}
// Record audit event for the bulk operation
criteriaDetails := s.buildAuditDetails(criteria)
criteriaDetails["reason"] = reason
criteriaDetails["total_matched"] = result.TotalMatched
criteriaDetails["total_revoked"] = result.TotalRevoked
criteriaDetails["total_skipped"] = result.TotalSkipped
criteriaDetails["total_failed"] = result.TotalFailed
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"bulk_revocation_initiated", "certificate", "bulk",
criteriaDetails); err != nil {
s.logger.Error("failed to record bulk revocation audit event", "error", err)
}
return result, nil
}
// resolveCertificates fetches the set of certificates matching the bulk revocation criteria.
// When CertificateIDs are provided, it fetches each cert by ID individually.
// When filter criteria (profile, owner, etc.) are provided, it uses the repository List method.
// When both are provided, it intersects: only IDs that also match the filter criteria.
func (s *BulkRevocationService) resolveCertificates(ctx context.Context, criteria domain.BulkRevocationCriteria) ([]*domain.ManagedCertificate, error) {
hasFilterCriteria := criteria.ProfileID != "" || criteria.OwnerID != "" ||
criteria.AgentID != "" || criteria.IssuerID != "" || criteria.TeamID != ""
hasExplicitIDs := len(criteria.CertificateIDs) > 0
if hasExplicitIDs && !hasFilterCriteria {
// Only explicit IDs — fetch each cert by ID
var certs []*domain.ManagedCertificate
for _, id := range criteria.CertificateIDs {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
// Skip not-found certs — they'll count as "matched" but skipped
continue
}
certs = append(certs, cert)
}
return certs, nil
}
// Use filter-based query
filter := &repository.CertificateFilter{
OwnerID: criteria.OwnerID,
TeamID: criteria.TeamID,
IssuerID: criteria.IssuerID,
AgentID: criteria.AgentID,
ProfileID: criteria.ProfileID,
PerPage: 10000, // High limit to get all matching certs in one query
}
certs, _, err := s.certRepo.List(ctx, filter)
if err != nil {
return nil, err
}
// If explicit IDs also provided, intersect
if hasExplicitIDs {
idSet := make(map[string]bool, len(criteria.CertificateIDs))
for _, id := range criteria.CertificateIDs {
idSet[id] = true
}
var filtered []*domain.ManagedCertificate
for _, cert := range certs {
if idSet[cert.ID] {
filtered = append(filtered, cert)
}
}
return filtered, nil
}
return certs, nil
}
// buildAuditDetails constructs a map of criteria fields for the audit event.
func (s *BulkRevocationService) buildAuditDetails(criteria domain.BulkRevocationCriteria) map[string]interface{} {
details := map[string]interface{}{}
if criteria.ProfileID != "" {
details["profile_id"] = criteria.ProfileID
}
if criteria.OwnerID != "" {
details["owner_id"] = criteria.OwnerID
}
if criteria.AgentID != "" {
details["agent_id"] = criteria.AgentID
}
if criteria.IssuerID != "" {
details["issuer_id"] = criteria.IssuerID
}
if criteria.TeamID != "" {
details["team_id"] = criteria.TeamID
}
if len(criteria.CertificateIDs) > 0 {
details["certificate_ids"] = strings.Join(criteria.CertificateIDs, ",")
}
return details
}
+379
View File
@@ -0,0 +1,379 @@
package service
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// helper to create a test BulkRevocationService wired for bulk revocation tests
func newBulkRevocationTestService() (*BulkRevocationService, *mockCertRepo, *mockRevocationRepo, *mockAuditRepo) {
certRepo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
revocationRepo := newMockRevocationRepository()
auditService := NewAuditService(auditRepo)
// Create RevocationSvc (underlying single-cert revocation)
revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService)
registry := NewIssuerRegistry(slog.Default())
registry.Set("iss-local", &mockIssuerConnector{})
revSvc.SetIssuerRegistry(registry)
bulkSvc := NewBulkRevocationService(revSvc, certRepo, auditService, slog.Default())
return bulkSvc, certRepo, revocationRepo, auditRepo
}
func addTestCert(repo *mockCertRepo, id, status, issuerID string) {
cert := &domain.ManagedCertificate{
ID: id,
CommonName: id + ".example.com",
Status: domain.CertificateStatus(status),
IssuerID: issuerID,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
repo.AddCert(cert)
// Add a version with serial number (needed by RevokeCertificateWithActor)
repo.Versions[id] = []*domain.CertificateVersion{
{
ID: "ver-" + id,
CertificateID: id,
SerialNumber: "serial-" + id,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
},
}
}
func addTestCertWithProfile(repo *mockCertRepo, id, status, issuerID, profileID, ownerID string) {
cert := &domain.ManagedCertificate{
ID: id,
CommonName: id + ".example.com",
Status: domain.CertificateStatus(status),
IssuerID: issuerID,
CertificateProfileID: profileID,
OwnerID: ownerID,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
repo.AddCert(cert)
repo.Versions[id] = []*domain.CertificateVersion{
{
ID: "ver-" + id,
CertificateID: id,
SerialNumber: "serial-" + id,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
},
}
}
func TestBulkRevoke_ByExplicitIDs(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
addTestCert(certRepo, "mc-1", "Active", "iss-local")
addTestCert(certRepo, "mc-2", "Active", "iss-local")
addTestCert(certRepo, "mc-3", "Active", "iss-local")
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
}
result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TotalMatched != 3 {
t.Errorf("expected TotalMatched=3, got %d", result.TotalMatched)
}
if result.TotalRevoked != 3 {
t.Errorf("expected TotalRevoked=3, got %d", result.TotalRevoked)
}
if result.TotalSkipped != 0 {
t.Errorf("expected TotalSkipped=0, got %d", result.TotalSkipped)
}
if result.TotalFailed != 0 {
t.Errorf("expected TotalFailed=0, got %d", result.TotalFailed)
}
// Verify certs are revoked
for _, id := range []string{"mc-1", "mc-2", "mc-3"} {
cert, _ := certRepo.Get(context.Background(), id)
if cert.Status != domain.CertificateStatusRevoked {
t.Errorf("expected cert %s to be Revoked, got %s", id, cert.Status)
}
}
}
func TestBulkRevoke_ByProfile(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
// The mock List returns all certs regardless of filter (mock limitation).
// We test the code path — real repo would filter by profile.
addTestCert(certRepo, "mc-1", "Active", "iss-local")
addTestCert(certRepo, "mc-2", "Active", "iss-local")
criteria := domain.BulkRevocationCriteria{
ProfileID: "prof-tls",
}
result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TotalMatched != 2 {
t.Errorf("expected TotalMatched=2, got %d", result.TotalMatched)
}
if result.TotalRevoked != 2 {
t.Errorf("expected TotalRevoked=2, got %d", result.TotalRevoked)
}
}
func TestBulkRevoke_ByOwner(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
addTestCertWithProfile(certRepo, "mc-1", "Active", "iss-local", "", "o-alice")
addTestCertWithProfile(certRepo, "mc-2", "Active", "iss-local", "", "o-alice")
criteria := domain.BulkRevocationCriteria{
OwnerID: "o-alice",
}
result, err := svc.BulkRevoke(context.Background(), criteria, "cessationOfOperation", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TotalRevoked != 2 {
t.Errorf("expected TotalRevoked=2, got %d", result.TotalRevoked)
}
}
func TestBulkRevoke_MultipleCriteria(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
addTestCertWithProfile(certRepo, "mc-1", "Active", "iss-local", "prof-tls", "o-alice")
addTestCertWithProfile(certRepo, "mc-2", "Active", "iss-local", "prof-tls", "o-bob")
criteria := domain.BulkRevocationCriteria{
ProfileID: "prof-tls",
CertificateIDs: []string{"mc-1"}, // Intersect: only mc-1 from the filter results
}
result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Both certs match the filter, but intersection with IDs gives 1
if result.TotalMatched != 1 {
t.Errorf("expected TotalMatched=1, got %d", result.TotalMatched)
}
if result.TotalRevoked != 1 {
t.Errorf("expected TotalRevoked=1, got %d", result.TotalRevoked)
}
// mc-1 should be revoked, mc-2 should not
cert1, _ := certRepo.Get(context.Background(), "mc-1")
if cert1.Status != domain.CertificateStatusRevoked {
t.Errorf("expected mc-1 to be Revoked, got %s", cert1.Status)
}
cert2, _ := certRepo.Get(context.Background(), "mc-2")
if cert2.Status == domain.CertificateStatusRevoked {
t.Error("expected mc-2 to NOT be revoked")
}
}
func TestBulkRevoke_EmptyCriteria_Error(t *testing.T) {
svc, _, _, _ := newBulkRevocationTestService()
criteria := domain.BulkRevocationCriteria{}
_, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err == nil {
t.Fatal("expected error for empty criteria")
}
if !strings.Contains(err.Error(), "at least one filter criterion") {
t.Errorf("expected 'at least one filter criterion' error, got: %v", err)
}
}
func TestBulkRevoke_InvalidReason_Error(t *testing.T) {
svc, _, _, _ := newBulkRevocationTestService()
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-1"},
}
_, err := svc.BulkRevoke(context.Background(), criteria, "totallyBogus", "admin")
if err == nil {
t.Fatal("expected error for invalid reason")
}
if !strings.Contains(err.Error(), "invalid revocation reason") {
t.Errorf("expected 'invalid revocation reason' error, got: %v", err)
}
}
func TestBulkRevoke_EmptyReason_Error(t *testing.T) {
svc, _, _, _ := newBulkRevocationTestService()
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-1"},
}
_, err := svc.BulkRevoke(context.Background(), criteria, "", "admin")
if err == nil {
t.Fatal("expected error for empty reason")
}
if !strings.Contains(err.Error(), "revocation reason is required") {
t.Errorf("expected 'revocation reason is required' error, got: %v", err)
}
}
func TestBulkRevoke_SkipsRevokedAndArchived(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
addTestCert(certRepo, "mc-active", "Active", "iss-local")
addTestCert(certRepo, "mc-revoked", "Revoked", "iss-local")
addTestCert(certRepo, "mc-archived", "Archived", "iss-local")
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-active", "mc-revoked", "mc-archived"},
}
result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TotalMatched != 3 {
t.Errorf("expected TotalMatched=3, got %d", result.TotalMatched)
}
if result.TotalRevoked != 1 {
t.Errorf("expected TotalRevoked=1, got %d", result.TotalRevoked)
}
if result.TotalSkipped != 2 {
t.Errorf("expected TotalSkipped=2, got %d", result.TotalSkipped)
}
}
func TestBulkRevoke_PartialFailure(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
// mc-1 is active with version — will succeed
addTestCert(certRepo, "mc-1", "Active", "iss-local")
// mc-2 is active but has NO version — RevokeCertificateWithActor will fail on GetLatestVersion
cert2 := &domain.ManagedCertificate{
ID: "mc-2",
CommonName: "mc-2.example.com",
Status: domain.CertificateStatusActive,
IssuerID: "iss-local",
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert2)
// Don't add versions for mc-2 so GetLatestVersion returns errNotFound
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-1", "mc-2"},
}
result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error (partial failure is ok), got: %v", err)
}
if result.TotalMatched != 2 {
t.Errorf("expected TotalMatched=2, got %d", result.TotalMatched)
}
if result.TotalRevoked != 1 {
t.Errorf("expected TotalRevoked=1, got %d", result.TotalRevoked)
}
if result.TotalFailed != 1 {
t.Errorf("expected TotalFailed=1, got %d", result.TotalFailed)
}
if len(result.Errors) != 1 {
t.Fatalf("expected 1 error entry, got %d", len(result.Errors))
}
if result.Errors[0].CertificateID != "mc-2" {
t.Errorf("expected error for mc-2, got %s", result.Errors[0].CertificateID)
}
}
func TestBulkRevoke_AuditEvent(t *testing.T) {
svc, certRepo, _, auditRepo := newBulkRevocationTestService()
addTestCert(certRepo, "mc-1", "Active", "iss-local")
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-1"},
}
_, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Find the bulk_revocation_initiated audit event
var found bool
for _, event := range auditRepo.Events {
if event.Action == "bulk_revocation_initiated" {
found = true
if event.Actor != "admin" {
t.Errorf("expected actor 'admin', got '%s'", event.Actor)
}
if event.ResourceType != "certificate" {
t.Errorf("expected resource type 'certificate', got '%s'", event.ResourceType)
}
break
}
}
if !found {
t.Error("expected bulk_revocation_initiated audit event")
}
}
func TestBulkRevoke_NoMatches(t *testing.T) {
svc, _, _, _ := newBulkRevocationTestService()
// IDs that don't exist in the repo
criteria := domain.BulkRevocationCriteria{
CertificateIDs: []string{"mc-nonexistent-1", "mc-nonexistent-2"},
}
result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TotalMatched != 0 {
t.Errorf("expected TotalMatched=0, got %d", result.TotalMatched)
}
if result.TotalRevoked != 0 {
t.Errorf("expected TotalRevoked=0, got %d", result.TotalRevoked)
}
}
func TestBulkRevoke_ListError(t *testing.T) {
svc, certRepo, _, _ := newBulkRevocationTestService()
certRepo.ListErr = errors.New("database connection failed")
criteria := domain.BulkRevocationCriteria{
ProfileID: "prof-tls",
}
_, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin")
if err == nil {
t.Fatal("expected error from list failure")
}
if !strings.Contains(err.Error(), "failed to resolve certificates") {
t.Errorf("expected 'failed to resolve certificates' error, got: %v", err)
}
}
+10
View File
@@ -11,6 +11,7 @@ import {
updateCertificate,
archiveCertificate,
revokeCertificate,
bulkRevokeCertificates,
exportCertificatePEM,
downloadCertificatePEM,
exportCertificatePKCS12,
@@ -288,6 +289,15 @@ describe('API Client', () => {
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise' });
});
it('bulkRevokeCertificates sends POST with criteria', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ total_matched: 3, total_revoked: 2, total_skipped: 1, total_failed: 0 }));
await bulkRevokeCertificates({ reason: 'keyCompromise', profile_id: 'prof-tls', certificate_ids: ['mc-1', 'mc-2'] });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/bulk-revoke');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise', profile_id: 'prof-tls', certificate_ids: ['mc-1', 'mc-2'] });
});
});
// ─── Agents ─────────────────────────────────────────
+24
View File
@@ -95,6 +95,30 @@ export const revokeCertificate = (id: string, reason: string) =>
body: JSON.stringify({ reason }),
});
export interface BulkRevokeCriteria {
reason: string;
profile_id?: string;
owner_id?: string;
agent_id?: string;
issuer_id?: string;
team_id?: string;
certificate_ids?: string[];
}
export interface BulkRevokeResult {
total_matched: number;
total_revoked: number;
total_skipped: number;
total_failed: number;
errors?: { certificate_id: string; error: string }[];
}
export const bulkRevokeCertificates = (criteria: BulkRevokeCriteria) =>
fetchJSON<BulkRevokeResult>(`${BASE}/certificates/bulk-revoke`, {
method: 'POST',
body: JSON.stringify(criteria),
});
// Certificate Export
export const exportCertificatePEM = (id: string) =>
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
+31 -26
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers } from '../api/client';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers, bulkRevokeCertificates } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
@@ -188,25 +188,24 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) {
const [reason, setReason] = useState('unspecified');
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ total_matched: number; total_revoked: number; total_skipped: number; total_failed: number; errors?: { certificate_id: string; error: string }[] } | null>(null);
const handleRevoke = async () => {
setRunning(true);
setError('');
let succeeded = 0;
for (const id of ids) {
try {
await revokeCertificate(id, reason);
succeeded++;
setProgress(succeeded);
} catch (err) {
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
break;
try {
const res = await bulkRevokeCertificates({ reason, certificate_ids: ids });
setResult(res);
if (res.total_failed === 0) {
onSuccess();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk revocation failed');
} finally {
setRunning(false);
}
if (!error) onSuccess();
};
return (
@@ -217,32 +216,38 @@ function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose:
Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone.
</p>
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-3">{error}</div>}
{running && (
<div className="mb-3">
<div className="flex justify-between text-xs text-ink-muted mb-1">
<span>Progress</span>
<span>{progress}/{ids.length}</span>
</div>
<div className="w-full bg-surface-border rounded-full h-2">
<div className="bg-red-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
{result && (
<div className="mb-3 bg-gray-50 border border-gray-200 rounded px-3 py-2 text-sm">
<div className="grid grid-cols-2 gap-1">
<span className="text-ink-muted">Matched:</span><span className="font-medium">{result.total_matched}</span>
<span className="text-ink-muted">Revoked:</span><span className="font-medium text-red-600">{result.total_revoked}</span>
<span className="text-ink-muted">Skipped:</span><span className="font-medium text-yellow-600">{result.total_skipped}</span>
<span className="text-ink-muted">Failed:</span><span className="font-medium text-red-700">{result.total_failed}</span>
</div>
{result.errors && result.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{result.errors.map((e, i) => <div key={i}>{e.certificate_id}: {e.error}</div>)}
</div>
)}
</div>
)}
<label className="text-xs text-ink-muted block mb-2">Revocation Reason (RFC 5280)</label>
<select value={reason} onChange={e => setReason(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4"
disabled={running}
disabled={running || result !== null}
>
{REVOCATION_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn btn-ghost text-sm" disabled={running}>Cancel</button>
<button onClick={handleRevoke} disabled={running}
className="btn text-sm bg-red-600 hover:bg-red-500 text-white disabled:opacity-50">
{running ? `Revoking (${progress}/${ids.length})...` : `Revoke ${ids.length} Certificates`}
</button>
<button onClick={onClose} className="btn btn-ghost text-sm">{result ? 'Close' : 'Cancel'}</button>
{!result && (
<button onClick={handleRevoke} disabled={running}
className="btn text-sm bg-red-600 hover:bg-red-500 text-white disabled:opacity-50">
{running ? 'Revoking...' : `Revoke ${ids.length} Certificates`}
</button>
)}
</div>
</div>
</div>