diff --git a/README.md b/README.md
index 2371056..5f79dab 100644
--- a/README.md
+++ b/README.md
@@ -175,7 +175,7 @@ Built for **platform engineering and DevOps teams** managing 10–500+ 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.
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 2042d78..e8c760e 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -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
diff --git a/cmd/cli/main.go b/cmd/cli/main.go
index 1a8e8c4..74e147c 100644
--- a/cmd/cli/main.go
+++ b/cmd/cli/main.go
@@ -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
diff --git a/cmd/server/main.go b/cmd/server/main.go
index a95ab69..a84119d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -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 {
diff --git a/docs/architecture.md b/docs/architecture.md
index 0c3413f..97d114c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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).
diff --git a/docs/compliance-nist.md b/docs/compliance-nist.md
index 573acdc..af8a4a8 100644
--- a/docs/compliance-nist.md
+++ b/docs/compliance-nist.md
@@ -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)
diff --git a/docs/compliance-pci-dss.md b/docs/compliance-pci-dss.md
index 68b7a7c..0bd8510 100644
--- a/docs/compliance-pci-dss.md
+++ b/docs/compliance-pci-dss.md
@@ -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.
diff --git a/docs/compliance-soc2.md b/docs/compliance-soc2.md
index 57df228..8f81acb 100644
--- a/docs/compliance-soc2.md
+++ b/docs/compliance-soc2.md
@@ -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**:
diff --git a/docs/concepts.md b/docs/concepts.md
index e171e73..2f1aff3 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -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.
diff --git a/docs/features.md b/docs/features.md
index 4d6cb3a..a915b06 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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
diff --git a/internal/api/handler/bulk_revocation.go b/internal/api/handler/bulk_revocation.go
new file mode 100644
index 0000000..6dd4f8f
--- /dev/null
+++ b/internal/api/handler/bulk_revocation.go
@@ -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)
+}
diff --git a/internal/api/handler/bulk_revocation_handler_test.go b/internal/api/handler/bulk_revocation_handler_test.go
new file mode 100644
index 0000000..bb61765
--- /dev/null
+++ b/internal/api/handler/bulk_revocation_handler_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/router/router.go b/internal/api/router/router.go
index 38c0f02..af2378a 100644
--- a/internal/api/router/router.go
+++ b/internal/api/router/router.go
@@ -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))
diff --git a/internal/cli/client.go b/internal/cli/client.go
index 2b926f0..337da5a 100644
--- a/internal/cli/client.go
+++ b/internal/cli/client.go
@@ -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)
diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go
index 33d8ca2..de2b68c 100644
--- a/internal/cli/client_test.go
+++ b/internal/cli/client_test.go
@@ -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" {
diff --git a/internal/domain/revocation.go b/internal/domain/revocation.go
index 592b8c6..80735ff 100644
--- a/internal/domain/revocation.go
+++ b/internal/domain/revocation.go
@@ -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 {
diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go
index 38660c2..c76ce4d 100644
--- a/internal/integration/lifecycle_test.go
+++ b/internal/integration/lifecycle_test.go
@@ -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)
diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go
index 57c6975..9f60489 100644
--- a/internal/integration/negative_test.go
+++ b/internal/integration/negative_test.go
@@ -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)
diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
index 5da3df4..d960be0 100644
--- a/internal/mcp/tools.go
+++ b/internal/mcp/tools.go
@@ -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 ──────────────────────────────────────────────────────
diff --git a/internal/mcp/types.go b/internal/mcp/types.go
index add8e2a..ac4d228 100644
--- a/internal/mcp/types.go
+++ b/internal/mcp/types.go
@@ -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
diff --git a/internal/service/bulk_revocation.go b/internal/service/bulk_revocation.go
new file mode 100644
index 0000000..be4d1f8
--- /dev/null
+++ b/internal/service/bulk_revocation.go
@@ -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
+}
diff --git a/internal/service/bulk_revocation_test.go b/internal/service/bulk_revocation_test.go
new file mode 100644
index 0000000..d01588c
--- /dev/null
+++ b/internal/service/bulk_revocation_test.go
@@ -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)
+ }
+}
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
index d33e179..df3d498 100644
--- a/web/src/api/client.test.ts
+++ b/web/src/api/client.test.ts
@@ -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 ─────────────────────────────────────────
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 1fa66a3..00d7b88 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -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