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
+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" {