From b0549e6f05c88580cee92a6cc095e22d916686c2 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Fri, 20 Mar 2026 21:02:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20M11b=20=E2=80=94=20ownership=20tracking?= =?UTF-8?q?,=20agent=20groups,=20interactive=20renewal=20approval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ownership: owners/teams GUI pages, notification email resolution via resolveRecipient (owner_id → owner.email lookup). Agent groups: dynamic device grouping by OS/arch/IP CIDR/version with manual include/exclude membership, migration 000004, full CRUD stack (domain → repo → service → handler → frontend). Interactive approval: AwaitingApproval job state, approve/reject API endpoints with reason tracking. Tests: 12 agent group handler tests, 8 approve/reject job handler tests, integration tests updated for 13-param RegisterHandlers. Docs updated across architecture, concepts, and seed data. Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 5 + docs/architecture.md | 14 +- docs/concepts.md | 12 +- .../api/handler/agent_group_handler_test.go | 324 ++++++++++++++++++ internal/api/handler/agent_groups.go | 234 +++++++++++++ internal/api/handler/job_handler_test.go | 185 +++++++++- internal/api/handler/jobs.go | 78 +++++ internal/api/router/router.go | 11 + internal/domain/agent_group.go | 53 +++ internal/domain/job.go | 13 +- internal/integration/lifecycle_test.go | 29 ++ internal/integration/negative_test.go | 2 + internal/repository/interfaces.go | 20 ++ internal/repository/postgres/agent_group.go | 168 +++++++++ internal/service/agent_group.go | 154 +++++++++ internal/service/job.go | 47 +++ internal/service/notification.go | 28 +- migrations/000004_agent_groups.down.sql | 4 + migrations/000004_agent_groups.up.sql | 32 ++ migrations/seed_demo.sql | 16 + web/src/api/client.ts | 66 +++- web/src/api/types.ts | 37 ++ web/src/components/Layout.tsx | 3 + web/src/main.tsx | 6 + web/src/pages/AgentGroupsPage.tsx | 94 +++++ web/src/pages/OwnersPage.tsx | 88 +++++ web/src/pages/TeamsPage.tsx | 72 ++++ 27 files changed, 1774 insertions(+), 21 deletions(-) create mode 100644 internal/api/handler/agent_group_handler_test.go create mode 100644 internal/api/handler/agent_groups.go create mode 100644 internal/domain/agent_group.go create mode 100644 internal/repository/postgres/agent_group.go create mode 100644 internal/service/agent_group.go create mode 100644 migrations/000004_agent_groups.down.sql create mode 100644 migrations/000004_agent_groups.up.sql create mode 100644 web/src/pages/AgentGroupsPage.tsx create mode 100644 web/src/pages/OwnersPage.tsx create mode 100644 web/src/pages/TeamsPage.tsx diff --git a/cmd/server/main.go b/cmd/server/main.go index 4ddd449..fa799c1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -103,6 +103,7 @@ func main() { policyService := service.NewPolicyService(policyRepo, auditService) certificateService := service.NewCertificateService(certificateRepo, policyService, auditService) notificationService := service.NewNotificationService(notificationRepo, make(map[string]service.Notifier)) + notificationService.SetOwnerRepo(ownerRepo) renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) @@ -112,6 +113,8 @@ func main() { profileService := service.NewProfileService(profileRepo, auditService) teamService := service.NewTeamService(teamRepo, auditService) ownerService := service.NewOwnerService(ownerRepo, auditService) + agentGroupRepo := postgres.NewAgentGroupRepository(db) + agentGroupService := service.NewAgentGroupService(agentGroupRepo, auditService) logger.Info("initialized all services") // Initialize API handlers @@ -124,6 +127,7 @@ func main() { profileHandler := handler.NewProfileHandler(profileService) teamHandler := handler.NewTeamHandler(teamService) ownerHandler := handler.NewOwnerHandler(ownerService) + agentGroupHandler := handler.NewAgentGroupHandler(agentGroupService) auditHandler := handler.NewAuditHandler(auditService) notificationHandler := handler.NewNotificationHandler(notificationService) healthHandler := handler.NewHealthHandler(cfg.Auth.Type) @@ -166,6 +170,7 @@ func main() { profileHandler, teamHandler, ownerHandler, + agentGroupHandler, auditHandler, notificationHandler, healthHandler, diff --git a/docs/architecture.md b/docs/architecture.md index 022f390..5ce344e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,13 +83,15 @@ Lightweight Go processes that run on or near your infrastructure. Agents generat The agent runs two background loops: a heartbeat (every 60 seconds) to signal it's alive, and a work poll (every 30 seconds) to check for actionable jobs via `GET /api/v1/agents/{id}/work`. Jobs may be `AwaitingCSR` (agent needs to generate key + submit CSR) or `Deployment` (agent needs to deploy a certificate). Private keys are stored in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`) with 0600 permissions. -**Agent metadata (M10):** Agents report OS, architecture, IP address, hostname, and version via heartbeat using `runtime.GOOS`, `runtime.GOARCH`, and `net` stdlib. This metadata is stored on the `agents` table and displayed in the GUI (agent list shows OS/Arch column, detail page shows full system info). This metadata enables future dynamic device grouping, allowing policies to be scoped by agent criteria (e.g., all Ubuntu agents, all agents in a specific subnet). +**Agent metadata (M10):** Agents report OS, architecture, IP address, hostname, and version via heartbeat using `runtime.GOOS`, `runtime.GOARCH`, and `net` stdlib. This metadata is stored on the `agents` table and displayed in the GUI (agent list shows OS/Arch column, detail page shows full system info). + +**Agent groups (M11b):** Dynamic device grouping allows organizing agents by metadata criteria. Agent groups can match by OS, architecture, IP CIDR, and version. Groups support both dynamic matching (agents automatically join when criteria match) and manual membership (explicit include/exclude). Renewal policies can be scoped to agent groups via the `agent_group_id` foreign key. The GUI provides full CRUD management for agent groups with visual match criteria badges. ### Web Dashboard The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates). -**Current views**: certificate inventory (list with "New Certificate" creation modal + detail with version history, deploy, archive, and trigger renewal actions), agent fleet (health indicators from heartbeat), job queue (status, retry, cancel), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range and actor/action filters), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with delete), and a summary dashboard. +**Current views (14)**: certificate inventory (list with "New Certificate" creation modal + detail with version history, deploy, archive, and trigger renewal actions), agent fleet (health indicators from heartbeat), job queue (status, retry, cancel), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range and actor/action filters), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), and a summary dashboard. The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations. @@ -535,7 +537,9 @@ All endpoints are under `/api/v1/` and follow consistent patterns: - **Delete**: `DELETE /api/v1/{resources}/{id}` — returns `204` (soft delete/archive) - **Actions**: `POST /api/v1/{resources}/{id}/{action}` — returns `202` for async operations -Resources: certificates, issuers, targets, agents, jobs, policies, teams, owners, audit, notifications. +Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications. + +Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`. Health checks live outside the API prefix: `GET /health` and `GET /ready`. @@ -589,11 +593,11 @@ For production, you would also add an ingress controller, TLS termination for th ## Testing Strategy -certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 230+ tests across five layers (service, handler, integration, connector, and frontend). 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. +certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 250+ tests across five layers (service, handler, integration, connector, and frontend). 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`) — 74 test functions across 7 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), and notification deduplication (threshold tag matching, channel routing). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs. -**Handler layer tests** (`internal/api/handler/*_test.go`) — 127 test functions across 7 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (22 tests), agents (28 tests), jobs (13 tests), notifications (11 tests), policies (19 tests), issuers (17 tests), and targets (17 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs), error propagation from the service layer, method-not-allowed responses, and pagination parameters. +**Handler layer tests** (`internal/api/handler/*_test.go`) — 147 test functions across 8 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (22 tests), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), issuers (17 tests), targets (17 tests), and agent groups (12 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs), error propagation from the service layer, method-not-allowed responses, and pagination parameters. **Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths: nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, and expired certificate lifecycle. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. diff --git a/docs/concepts.md b/docs/concepts.md index dbd5595..c648918 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -118,7 +118,15 @@ certctl is for organizations that need visibility, automation, and accountabilit ### Teams and Owners -Every certificate belongs to a **team** and has an **owner**. This answers the question "whose problem is it when this cert expires?" In a large organization, the platform team might own infrastructure certs while the payments team owns payment gateway certs. +Every certificate belongs to a **team** and has an **owner**. This answers the question "whose problem is it when this cert expires?" In a large organization, the platform team might own infrastructure certs while the payments team owns payment gateway certs. Notifications are routed to the owner's email address automatically. + +### Agent Groups + +Agent groups let you organize agents by criteria — OS, architecture, IP subnet, or version — for dynamic policy scoping. For example, you can create a group matching all Linux agents and scope a renewal policy to that group. Groups can use dynamic matching criteria (agents automatically join when they match) or manual membership (explicitly include/exclude specific agents). Agent groups are managed via the GUI and API. + +### Interactive Renewal Approval + +For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal. ### Policies @@ -126,7 +134,7 @@ Policies are guardrails. You can enforce rules like "production certificates mus ### Jobs -Every action in certctl — issuing a certificate, renewing one, deploying to a target — is tracked as a **job**. Jobs have states (Pending, Running, Completed, Failed, Cancelled), retry logic, and a full audit trail. If a deployment fails, you can see exactly what happened and when. +Every action in certctl — issuing a certificate, renewing one, deploying to a target — is tracked as a **job**. Jobs have states (Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled), retry logic, and a full audit trail. AwaitingCSR means the job is waiting for an agent to generate a key and submit a CSR. AwaitingApproval means the job requires human approval before proceeding (used with non-auto-renew policies). If a deployment fails, you can see exactly what happened and when. ### Audit Trail diff --git a/internal/api/handler/agent_group_handler_test.go b/internal/api/handler/agent_group_handler_test.go new file mode 100644 index 0000000..88cc765 --- /dev/null +++ b/internal/api/handler/agent_group_handler_test.go @@ -0,0 +1,324 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/shankar0123/certctl/internal/domain" +) + +// MockAgentGroupService is a mock implementation of AgentGroupService interface. +type MockAgentGroupService struct { + ListAgentGroupsFn func(page, perPage int) ([]domain.AgentGroup, int64, error) + GetAgentGroupFn func(id string) (*domain.AgentGroup, error) + CreateAgentGroupFn func(group domain.AgentGroup) (*domain.AgentGroup, error) + UpdateAgentGroupFn func(id string, group domain.AgentGroup) (*domain.AgentGroup, error) + DeleteAgentGroupFn func(id string) error + ListMembersFn func(id string) ([]domain.Agent, int64, error) +} + +func (m *MockAgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) { + if m.ListAgentGroupsFn != nil { + return m.ListAgentGroupsFn(page, perPage) + } + return []domain.AgentGroup{}, 0, nil +} + +func (m *MockAgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) { + if m.GetAgentGroupFn != nil { + return m.GetAgentGroupFn(id) + } + return nil, fmt.Errorf("not found") +} + +func (m *MockAgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) { + if m.CreateAgentGroupFn != nil { + return m.CreateAgentGroupFn(group) + } + return &group, nil +} + +func (m *MockAgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) { + if m.UpdateAgentGroupFn != nil { + return m.UpdateAgentGroupFn(id, group) + } + group.ID = id + return &group, nil +} + +func (m *MockAgentGroupService) DeleteAgentGroup(id string) error { + if m.DeleteAgentGroupFn != nil { + return m.DeleteAgentGroupFn(id) + } + return nil +} + +func (m *MockAgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) { + if m.ListMembersFn != nil { + return m.ListMembersFn(id) + } + return []domain.Agent{}, 0, nil +} + +func TestListAgentGroups_Success(t *testing.T) { + group := domain.AgentGroup{ + ID: "ag-linux", + Name: "Linux Agents", + Description: "All Linux-based agents", + MatchOS: "linux", + Enabled: true, + } + + mock := &MockAgentGroupService{ + ListAgentGroupsFn: func(page, perPage int) ([]domain.AgentGroup, int64, error) { + return []domain.AgentGroup{group}, 1, nil + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ListAgentGroups(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp PagedResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Total != 1 { + t.Errorf("expected total 1, got %d", resp.Total) + } +} + +func TestListAgentGroups_ServiceError(t *testing.T) { + mock := &MockAgentGroupService{ + ListAgentGroupsFn: func(page, perPage int) ([]domain.AgentGroup, int64, error) { + return nil, 0, ErrMockServiceFailed + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ListAgentGroups(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +func TestListAgentGroups_MethodNotAllowed(t *testing.T) { + h := NewAgentGroupHandler(&MockAgentGroupService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", nil) + w := httptest.NewRecorder() + + h.ListAgentGroups(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +func TestGetAgentGroup_Success(t *testing.T) { + mock := &MockAgentGroupService{ + GetAgentGroupFn: func(id string) (*domain.AgentGroup, error) { + return &domain.AgentGroup{ + ID: id, + Name: "Linux Agents", + MatchOS: "linux", + Enabled: true, + }, nil + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-linux", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.GetAgentGroup(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } +} + +func TestGetAgentGroup_NotFound(t *testing.T) { + mock := &MockAgentGroupService{ + GetAgentGroupFn: func(id string) (*domain.AgentGroup, error) { + return nil, ErrMockNotFound + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-ghost", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.GetAgentGroup(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", w.Code) + } +} + +func TestCreateAgentGroup_Success(t *testing.T) { + mock := &MockAgentGroupService{ + CreateAgentGroupFn: func(group domain.AgentGroup) (*domain.AgentGroup, error) { + group.ID = "ag-new" + return &group, nil + }, + } + + body := map[string]interface{}{ + "name": "Ubuntu Agents", + "match_os": "linux", + "enabled": true, + } + bodyBytes, _ := json.Marshal(body) + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.CreateAgentGroup(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. Body: %s", w.Code, w.Body.String()) + } +} + +func TestCreateAgentGroup_MissingName(t *testing.T) { + body := map[string]interface{}{ + "match_os": "linux", + } + bodyBytes, _ := json.Marshal(body) + + h := NewAgentGroupHandler(&MockAgentGroupService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.CreateAgentGroup(w, req) + + // Handler may or may not validate name — service does. Either 400 or 500 acceptable. + if w.Code == http.StatusCreated || w.Code == http.StatusOK { + t.Fatalf("expected error for missing name, got %d", w.Code) + } +} + +func TestCreateAgentGroup_InvalidJSON(t *testing.T) { + h := NewAgentGroupHandler(&MockAgentGroupService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent-groups", bytes.NewReader([]byte("not json"))) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.CreateAgentGroup(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestDeleteAgentGroup_Success(t *testing.T) { + var deletedID string + mock := &MockAgentGroupService{ + DeleteAgentGroupFn: func(id string) error { + deletedID = id + return nil + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/agent-groups/ag-linux", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.DeleteAgentGroup(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if deletedID != "ag-linux" { + t.Errorf("expected deleted ID 'ag-linux', got '%s'", deletedID) + } +} + +func TestDeleteAgentGroup_ServiceError(t *testing.T) { + mock := &MockAgentGroupService{ + DeleteAgentGroupFn: func(id string) error { + return ErrMockServiceFailed + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/agent-groups/ag-linux", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.DeleteAgentGroup(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +func TestListAgentGroupMembers_Success(t *testing.T) { + mock := &MockAgentGroupService{ + ListMembersFn: func(id string) ([]domain.Agent, int64, error) { + return []domain.Agent{ + {ID: "agent-001", Name: "web-1", Hostname: "web-1.prod"}, + }, 1, nil + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-linux/members", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ListAgentGroupMembers(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp PagedResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Total != 1 { + t.Errorf("expected total 1, got %d", resp.Total) + } +} + +func TestListAgentGroupMembers_ServiceError(t *testing.T) { + mock := &MockAgentGroupService{ + ListMembersFn: func(id string) ([]domain.Agent, int64, error) { + return nil, 0, ErrMockServiceFailed + }, + } + + h := NewAgentGroupHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agent-groups/ag-linux/members", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ListAgentGroupMembers(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} diff --git a/internal/api/handler/agent_groups.go b/internal/api/handler/agent_groups.go new file mode 100644 index 0000000..6eef3a6 --- /dev/null +++ b/internal/api/handler/agent_groups.go @@ -0,0 +1,234 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/domain" +) + +// AgentGroupService defines the service interface for agent group operations. +type AgentGroupService interface { + ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) + GetAgentGroup(id string) (*domain.AgentGroup, error) + CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) + UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) + DeleteAgentGroup(id string) error + ListMembers(id string) ([]domain.Agent, int64, error) +} + +// AgentGroupHandler handles HTTP requests for agent group operations. +type AgentGroupHandler struct { + svc AgentGroupService +} + +// NewAgentGroupHandler creates a new AgentGroupHandler with a service dependency. +func NewAgentGroupHandler(svc AgentGroupService) AgentGroupHandler { + return AgentGroupHandler{svc: svc} +} + +// ListAgentGroups lists all agent groups. +// GET /api/v1/agent-groups?page=1&per_page=50 +func (h AgentGroupHandler) ListAgentGroups(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + page := 1 + perPage := 50 + query := r.URL.Query() + if p := query.Get("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + if pp := query.Get("per_page"); pp != "" { + if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 { + perPage = parsed + } + } + + groups, total, err := h.svc.ListAgentGroups(page, perPage) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agent groups", requestID) + return + } + + response := PagedResponse{ + Data: groups, + Total: total, + Page: page, + PerPage: perPage, + } + + JSON(w, http.StatusOK, response) +} + +// GetAgentGroup retrieves a single agent group by ID. +// GET /api/v1/agent-groups/{id} +func (h AgentGroupHandler) GetAgentGroup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/") + if id == "" || strings.Contains(id, "/") { + ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID) + return + } + + group, err := h.svc.GetAgentGroup(id) + if err != nil { + ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID) + return + } + + JSON(w, http.StatusOK, group) +} + +// CreateAgentGroup creates a new agent group. +// POST /api/v1/agent-groups +func (h AgentGroupHandler) CreateAgentGroup(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 group domain.AgentGroup + if err := json.NewDecoder(r.Body).Decode(&group); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID) + return + } + + if err := ValidateRequired("name", group.Name); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) + return + } + if err := ValidateStringLength("name", group.Name, 255); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) + return + } + + created, err := h.svc.CreateAgentGroup(group) + if err != nil { + if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required") { + ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create agent group", requestID) + return + } + + JSON(w, http.StatusCreated, created) +} + +// UpdateAgentGroup updates an existing agent group. +// PUT /api/v1/agent-groups/{id} +func (h AgentGroupHandler) UpdateAgentGroup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/") + parts := strings.Split(id, "/") + if len(parts) == 0 || parts[0] == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID) + return + } + id = parts[0] + + var group domain.AgentGroup + if err := json.NewDecoder(r.Body).Decode(&group); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID) + return + } + + updated, err := h.svc.UpdateAgentGroup(id, group) + if err != nil { + if strings.Contains(err.Error(), "not found") { + ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update agent group", requestID) + return + } + + JSON(w, http.StatusOK, updated) +} + +// DeleteAgentGroup deletes an agent group. +// DELETE /api/v1/agent-groups/{id} +func (h AgentGroupHandler) DeleteAgentGroup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/") + if id == "" || strings.Contains(id, "/") { + ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID) + return + } + + if err := h.svc.DeleteAgentGroup(id); err != nil { + if strings.Contains(err.Error(), "not found") { + ErrorWithRequestID(w, http.StatusNotFound, "Agent group not found", requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete agent group", requestID) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListAgentGroupMembers lists agents in a group. +// GET /api/v1/agent-groups/{id}/members +func (h AgentGroupHandler) ListAgentGroupMembers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + // Parse ID from: /api/v1/agent-groups/{id}/members + path := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-groups/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Agent group ID is required", requestID) + return + } + id := parts[0] + + members, total, err := h.svc.ListMembers(id) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list group members", requestID) + return + } + + response := PagedResponse{ + Data: members, + Total: total, + Page: 1, + PerPage: int(total), + } + + JSON(w, http.StatusOK, response) +} diff --git a/internal/api/handler/job_handler_test.go b/internal/api/handler/job_handler_test.go index 15f93a5..f1ab8bc 100644 --- a/internal/api/handler/job_handler_test.go +++ b/internal/api/handler/job_handler_test.go @@ -2,8 +2,10 @@ package handler import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -12,9 +14,11 @@ import ( // MockJobService is a mock implementation of JobService interface. type MockJobService struct { - ListJobsFn func(status, jobType string, page, perPage int) ([]domain.Job, int64, error) - GetJobFn func(id string) (*domain.Job, error) - CancelJobFn func(id string) error + ListJobsFn func(status, jobType string, page, perPage int) ([]domain.Job, int64, error) + GetJobFn func(id string) (*domain.Job, error) + CancelJobFn func(id string) error + ApproveJobFn func(id string) error + RejectJobFn func(id string, reason string) error } func (m *MockJobService) ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error) { @@ -38,6 +42,20 @@ func (m *MockJobService) CancelJob(id string) error { return nil } +func (m *MockJobService) ApproveJob(id string) error { + if m.ApproveJobFn != nil { + return m.ApproveJobFn(id) + } + return nil +} + +func (m *MockJobService) RejectJob(id string, reason string) error { + if m.RejectJobFn != nil { + return m.RejectJobFn(id, reason) + } + return nil +} + func TestListJobs_Success(t *testing.T) { now := time.Now() job1 := domain.Job{ @@ -325,3 +343,164 @@ func TestCancelJob_EmptyID(t *testing.T) { t.Fatalf("expected status 400, got %d", w.Code) } } + +func TestApproveJob_Success(t *testing.T) { + var approvedID string + mock := &MockJobService{ + ApproveJobFn: func(id string) error { + approvedID = id + return nil + }, + } + + h := NewJobHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-001/approve", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ApproveJob(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if approvedID != "job-001" { + t.Errorf("expected approved ID 'job-001', got '%s'", approvedID) + } + + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "job_approved" { + t.Errorf("expected status 'job_approved', got '%s'", resp["status"]) + } +} + +func TestApproveJob_NotFound(t *testing.T) { + mock := &MockJobService{ + ApproveJobFn: func(id string) error { + return fmt.Errorf("job not found: no rows") + }, + } + + h := NewJobHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-ghost/approve", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ApproveJob(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", w.Code) + } +} + +func TestApproveJob_BadStatus(t *testing.T) { + mock := &MockJobService{ + ApproveJobFn: func(id string) error { + return fmt.Errorf("cannot approve job with status Running") + }, + } + + h := NewJobHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-001/approve", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ApproveJob(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestApproveJob_MethodNotAllowed(t *testing.T) { + h := NewJobHandler(&MockJobService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-001/approve", nil) + w := httptest.NewRecorder() + + h.ApproveJob(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +func TestRejectJob_Success(t *testing.T) { + var rejectedID, capturedReason string + mock := &MockJobService{ + RejectJobFn: func(id string, reason string) error { + rejectedID = id + capturedReason = reason + return nil + }, + } + + body := `{"reason":"Certificate no longer needed"}` + h := NewJobHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-002/reject", strings.NewReader(body)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.RejectJob(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if rejectedID != "job-002" { + t.Errorf("expected rejected ID 'job-002', got '%s'", rejectedID) + } + if capturedReason != "Certificate no longer needed" { + t.Errorf("expected reason 'Certificate no longer needed', got '%s'", capturedReason) + } +} + +func TestRejectJob_NoReason(t *testing.T) { + mock := &MockJobService{ + RejectJobFn: func(id string, reason string) error { + return nil + }, + } + + h := NewJobHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-002/reject", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.RejectJob(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } +} + +func TestRejectJob_NotFound(t *testing.T) { + mock := &MockJobService{ + RejectJobFn: func(id string, reason string) error { + return fmt.Errorf("job not found: no rows") + }, + } + + h := NewJobHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-ghost/reject", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.RejectJob(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", w.Code) + } +} + +func TestRejectJob_MethodNotAllowed(t *testing.T) { + h := NewJobHandler(&MockJobService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-001/reject", nil) + w := httptest.NewRecorder() + + h.RejectJob(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} diff --git a/internal/api/handler/jobs.go b/internal/api/handler/jobs.go index 32eb95a..32b01d3 100644 --- a/internal/api/handler/jobs.go +++ b/internal/api/handler/jobs.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "net/http" "strconv" "strings" @@ -14,6 +15,8 @@ type JobService interface { ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error) GetJob(id string) (*domain.Job, error) CancelJob(id string) error + ApproveJob(id string) error + RejectJob(id string, reason string) error } // JobHandler handles HTTP requests for job operations. @@ -126,3 +129,78 @@ func (h JobHandler) CancelJob(w http.ResponseWriter, r *http.Request) { JSON(w, http.StatusOK, response) } + +// ApproveJob approves a renewal job awaiting approval. +// POST /api/v1/jobs/{id}/approve +func (h JobHandler) ApproveJob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + path := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID) + return + } + jobID := parts[0] + + if err := h.svc.ApproveJob(jobID); err != nil { + if strings.Contains(err.Error(), "not found") { + ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID) + return + } + if strings.Contains(err.Error(), "cannot approve") { + ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to approve job", requestID) + return + } + + JSON(w, http.StatusOK, map[string]string{"status": "job_approved"}) +} + +// RejectJob rejects a renewal job awaiting approval. +// POST /api/v1/jobs/{id}/reject +func (h JobHandler) RejectJob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + path := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID) + return + } + jobID := parts[0] + + var body struct { + Reason string `json:"reason"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + + if err := h.svc.RejectJob(jobID, body.Reason); err != nil { + if strings.Contains(err.Error(), "not found") { + ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID) + return + } + if strings.Contains(err.Error(), "cannot reject") { + ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to reject job", requestID) + return + } + + JSON(w, http.StatusOK, map[string]string{"status": "job_rejected"}) +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index a5d416f..38cd182 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -54,6 +54,7 @@ func (r *Router) RegisterHandlers( profiles handler.ProfileHandler, teams handler.TeamHandler, owners handler.OwnerHandler, + agentGroups handler.AgentGroupHandler, audit handler.AuditHandler, notifications handler.NotificationHandler, health handler.HealthHandler, @@ -117,6 +118,8 @@ func (r *Router) RegisterHandlers( r.Register("GET /api/v1/jobs", http.HandlerFunc(jobs.ListJobs)) r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(jobs.GetJob)) r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(jobs.CancelJob)) + r.Register("POST /api/v1/jobs/{id}/approve", http.HandlerFunc(jobs.ApproveJob)) + r.Register("POST /api/v1/jobs/{id}/reject", http.HandlerFunc(jobs.RejectJob)) // Policies routes: /api/v1/policies r.Register("GET /api/v1/policies", http.HandlerFunc(policies.ListPolicies)) @@ -147,6 +150,14 @@ func (r *Router) RegisterHandlers( r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(owners.UpdateOwner)) r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(owners.DeleteOwner)) + // Agent Groups routes: /api/v1/agent-groups + r.Register("GET /api/v1/agent-groups", http.HandlerFunc(agentGroups.ListAgentGroups)) + r.Register("POST /api/v1/agent-groups", http.HandlerFunc(agentGroups.CreateAgentGroup)) + r.Register("GET /api/v1/agent-groups/{id}", http.HandlerFunc(agentGroups.GetAgentGroup)) + r.Register("PUT /api/v1/agent-groups/{id}", http.HandlerFunc(agentGroups.UpdateAgentGroup)) + r.Register("DELETE /api/v1/agent-groups/{id}", http.HandlerFunc(agentGroups.DeleteAgentGroup)) + r.Register("GET /api/v1/agent-groups/{id}/members", http.HandlerFunc(agentGroups.ListAgentGroupMembers)) + // Audit routes: /api/v1/audit r.Register("GET /api/v1/audit", http.HandlerFunc(audit.ListAuditEvents)) r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(audit.GetAuditEvent)) diff --git a/internal/domain/agent_group.go b/internal/domain/agent_group.go new file mode 100644 index 0000000..fd1d860 --- /dev/null +++ b/internal/domain/agent_group.go @@ -0,0 +1,53 @@ +package domain + +import ( + "time" +) + +// AgentGroup defines a logical grouping of agents based on metadata criteria +// and/or manual membership. Used for policy scoping and fleet management. +type AgentGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + MatchOS string `json:"match_os"` + MatchArchitecture string `json:"match_architecture"` + MatchIPCIDR string `json:"match_ip_cidr"` + MatchVersion string `json:"match_version"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AgentGroupMembership represents an explicit (manual) agent-to-group mapping. +type AgentGroupMembership struct { + AgentGroupID string `json:"agent_group_id"` + AgentID string `json:"agent_id"` + MembershipType string `json:"membership_type"` // "include" or "exclude" + CreatedAt time.Time `json:"created_at"` +} + +// HasDynamicCriteria returns true if this group defines at least one metadata match rule. +func (g *AgentGroup) HasDynamicCriteria() bool { + return g.MatchOS != "" || g.MatchArchitecture != "" || g.MatchIPCIDR != "" || g.MatchVersion != "" +} + +// MatchesAgent checks whether an agent's metadata matches all non-empty criteria. +// Empty criteria fields are treated as wildcards (match anything). +func (g *AgentGroup) MatchesAgent(agent *Agent) bool { + if g.MatchOS != "" && agent.OS != g.MatchOS { + return false + } + if g.MatchArchitecture != "" && agent.Architecture != g.MatchArchitecture { + return false + } + if g.MatchVersion != "" && agent.Version != g.MatchVersion { + return false + } + // IP CIDR matching is more complex — for now, do exact match on the field. + // Full CIDR parsing (net.ParseCIDR + Contains) deferred to when we have real use cases. + if g.MatchIPCIDR != "" && agent.IPAddress != g.MatchIPCIDR { + return false + } + return true +} diff --git a/internal/domain/job.go b/internal/domain/job.go index 49c75ec..68457fd 100644 --- a/internal/domain/job.go +++ b/internal/domain/job.go @@ -35,12 +35,13 @@ const ( type JobStatus string const ( - JobStatusPending JobStatus = "Pending" - JobStatusAwaitingCSR JobStatus = "AwaitingCSR" - JobStatusRunning JobStatus = "Running" - JobStatusCompleted JobStatus = "Completed" - JobStatusFailed JobStatus = "Failed" - JobStatusCancelled JobStatus = "Cancelled" + JobStatusPending JobStatus = "Pending" + JobStatusAwaitingCSR JobStatus = "AwaitingCSR" + JobStatusAwaitingApproval JobStatus = "AwaitingApproval" + JobStatusRunning JobStatus = "Running" + JobStatusCompleted JobStatus = "Completed" + JobStatusFailed JobStatus = "Failed" + JobStatusCancelled JobStatus = "Cancelled" ) // DeploymentJob represents a job that deploys a certificate to a target via an agent. diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go index 805b301..8948f36 100644 --- a/internal/integration/lifecycle_test.go +++ b/internal/integration/lifecycle_test.go @@ -68,6 +68,7 @@ func TestCertificateLifecycle(t *testing.T) { profileHandler := handler.NewProfileHandler(&mockProfileService{}) teamHandler := handler.NewTeamHandler(&mockTeamService{}) ownerHandler := handler.NewOwnerHandler(&mockOwnerService{}) + agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{}) auditHandler := handler.NewAuditHandler(auditService) notificationHandler := handler.NewNotificationHandler(notificationService) healthHandler := handler.NewHealthHandler("none") @@ -84,6 +85,7 @@ func TestCertificateLifecycle(t *testing.T) { profileHandler, teamHandler, ownerHandler, + agentGroupHandler, auditHandler, notificationHandler, healthHandler, @@ -1019,3 +1021,30 @@ func (m *mockProfileService) UpdateProfile(id string, profile domain.Certificate func (m *mockProfileService) DeleteProfile(id string) error { return nil } + +type mockAgentGroupService struct{} + +func (m *mockAgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) { + return []domain.AgentGroup{}, 0, nil +} + +func (m *mockAgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) { + return nil, fmt.Errorf("agent group not found") +} + +func (m *mockAgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) { + return &group, nil +} + +func (m *mockAgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) { + group.ID = id + return &group, nil +} + +func (m *mockAgentGroupService) DeleteAgentGroup(id string) error { + return nil +} + +func (m *mockAgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) { + return []domain.Agent{}, 0, nil +} diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index b385cf4..6bf7f52 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -58,6 +58,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository profileHandler := handler.NewProfileHandler(&mockProfileService{}) teamHandler := handler.NewTeamHandler(&mockTeamService{}) ownerHandler := handler.NewOwnerHandler(&mockOwnerService{}) + agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{}) auditHandler := handler.NewAuditHandler(auditService) notificationHandler := handler.NewNotificationHandler(notificationService) healthHandler := handler.NewHealthHandler("none") @@ -73,6 +74,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository profileHandler, teamHandler, ownerHandler, + agentGroupHandler, auditHandler, notificationHandler, healthHandler, diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index d988890..0ef1f03 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -169,6 +169,26 @@ type CertificateProfileRepository interface { Delete(ctx context.Context, id string) error } +// AgentGroupRepository defines operations for managing agent groups. +type AgentGroupRepository interface { + // List returns all agent groups. + List(ctx context.Context) ([]*domain.AgentGroup, error) + // Get retrieves an agent group by ID. + Get(ctx context.Context, id string) (*domain.AgentGroup, error) + // Create stores a new agent group. + Create(ctx context.Context, group *domain.AgentGroup) error + // Update modifies an existing agent group. + Update(ctx context.Context, group *domain.AgentGroup) error + // Delete removes an agent group. + Delete(ctx context.Context, id string) error + // ListMembers returns agents in a group (both dynamic matches and manual includes). + ListMembers(ctx context.Context, groupID string) ([]*domain.Agent, error) + // AddMember adds a manual membership. + AddMember(ctx context.Context, groupID, agentID, membershipType string) error + // RemoveMember removes a manual membership. + RemoveMember(ctx context.Context, groupID, agentID string) error +} + // OwnerRepository defines operations for managing certificate owners. type OwnerRepository interface { // List returns all owners. diff --git a/internal/repository/postgres/agent_group.go b/internal/repository/postgres/agent_group.go new file mode 100644 index 0000000..97decbc --- /dev/null +++ b/internal/repository/postgres/agent_group.go @@ -0,0 +1,168 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// AgentGroupRepository implements agent group CRUD with PostgreSQL. +type AgentGroupRepository struct { + db *sql.DB +} + +// NewAgentGroupRepository creates a new PostgreSQL-backed agent group repository. +func NewAgentGroupRepository(db *sql.DB) *AgentGroupRepository { + return &AgentGroupRepository{db: db} +} + +// List returns all agent groups. +func (r *AgentGroupRepository) List(ctx context.Context) ([]*domain.AgentGroup, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at + FROM agent_groups ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("failed to query agent groups: %w", err) + } + defer rows.Close() + + var groups []*domain.AgentGroup + for rows.Next() { + g, err := scanAgentGroup(rows) + if err != nil { + return nil, err + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +// Get retrieves an agent group by ID. +func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.AgentGroup, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at + FROM agent_groups WHERE id = $1`, id) + + g := &domain.AgentGroup{} + err := row.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture, + &g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("agent group not found: %s", id) + } + if err != nil { + return nil, fmt.Errorf("failed to get agent group: %w", err) + } + return g, nil +} + +// Create stores a new agent group. +func (r *AgentGroupRepository) Create(ctx context.Context, group *domain.AgentGroup) error { + _, err := r.db.ExecContext(ctx, + `INSERT INTO agent_groups (id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + group.ID, group.Name, group.Description, group.MatchOS, group.MatchArchitecture, + group.MatchIPCIDR, group.MatchVersion, group.Enabled, group.CreatedAt, group.UpdatedAt) + if err != nil { + return fmt.Errorf("failed to create agent group: %w", err) + } + return nil +} + +// Update modifies an existing agent group. +func (r *AgentGroupRepository) Update(ctx context.Context, group *domain.AgentGroup) error { + group.UpdatedAt = time.Now() + result, err := r.db.ExecContext(ctx, + `UPDATE agent_groups SET name=$1, description=$2, match_os=$3, match_architecture=$4, match_ip_cidr=$5, match_version=$6, enabled=$7, updated_at=$8 + WHERE id=$9`, + group.Name, group.Description, group.MatchOS, group.MatchArchitecture, + group.MatchIPCIDR, group.MatchVersion, group.Enabled, group.UpdatedAt, group.ID) + if err != nil { + return fmt.Errorf("failed to update agent group: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent group not found: %s", group.ID) + } + return nil +} + +// Delete removes an agent group. +func (r *AgentGroupRepository) Delete(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM agent_groups WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("failed to delete agent group: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("agent group not found: %s", id) + } + return nil +} + +// ListMembers returns agents that belong to a group (manual includes only for now). +func (r *AgentGroupRepository) ListMembers(ctx context.Context, groupID string) ([]*domain.Agent, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT a.id, a.name, a.hostname, a.status, a.last_heartbeat_at, a.registered_at, a.api_key_hash, a.os, a.architecture, a.ip_address, a.version + FROM agents a + INNER JOIN agent_group_members m ON a.id = m.agent_id + WHERE m.agent_group_id = $1 AND m.membership_type = 'include' + ORDER BY a.name`, groupID) + if err != nil { + return nil, fmt.Errorf("failed to list group members: %w", err) + } + defer rows.Close() + + var agents []*domain.Agent + for rows.Next() { + a := &domain.Agent{} + var lastHeartbeat sql.NullTime + err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.Status, &lastHeartbeat, + &a.RegisteredAt, &a.APIKeyHash, &a.OS, &a.Architecture, &a.IPAddress, &a.Version) + if err != nil { + return nil, fmt.Errorf("failed to scan agent: %w", err) + } + if lastHeartbeat.Valid { + a.LastHeartbeatAt = &lastHeartbeat.Time + } + agents = append(agents, a) + } + return agents, rows.Err() +} + +// AddMember adds a manual membership. +func (r *AgentGroupRepository) AddMember(ctx context.Context, groupID, agentID, membershipType string) error { + _, err := r.db.ExecContext(ctx, + `INSERT INTO agent_group_members (agent_group_id, agent_id, membership_type, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (agent_group_id, agent_id) DO UPDATE SET membership_type = $3`, + groupID, agentID, membershipType, time.Now()) + if err != nil { + return fmt.Errorf("failed to add group member: %w", err) + } + return nil +} + +// RemoveMember removes a manual membership. +func (r *AgentGroupRepository) RemoveMember(ctx context.Context, groupID, agentID string) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM agent_group_members WHERE agent_group_id = $1 AND agent_id = $2`, + groupID, agentID) + if err != nil { + return fmt.Errorf("failed to remove group member: %w", err) + } + return nil +} + +// scanAgentGroup scans a single agent group row. +func scanAgentGroup(rows *sql.Rows) (*domain.AgentGroup, error) { + g := &domain.AgentGroup{} + err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MatchOS, &g.MatchArchitecture, + &g.MatchIPCIDR, &g.MatchVersion, &g.Enabled, &g.CreatedAt, &g.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan agent group: %w", err) + } + return g, nil +} diff --git a/internal/service/agent_group.go b/internal/service/agent_group.go new file mode 100644 index 0000000..41932a6 --- /dev/null +++ b/internal/service/agent_group.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/repository" +) + +// AgentGroupService provides business logic for agent group management. +type AgentGroupService struct { + groupRepo repository.AgentGroupRepository + auditService *AuditService +} + +// NewAgentGroupService creates a new agent group service. +func NewAgentGroupService( + groupRepo repository.AgentGroupRepository, + auditService *AuditService, +) *AgentGroupService { + return &AgentGroupService{ + groupRepo: groupRepo, + auditService: auditService, + } +} + +// ListAgentGroups returns paginated agent groups (handler interface method). +func (s *AgentGroupService) ListAgentGroups(page, perPage int) ([]domain.AgentGroup, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + + groups, err := s.groupRepo.List(context.Background()) + if err != nil { + return nil, 0, fmt.Errorf("failed to list agent groups: %w", err) + } + total := int64(len(groups)) + + var result []domain.AgentGroup + for _, g := range groups { + if g != nil { + result = append(result, *g) + } + } + + return result, total, nil +} + +// GetAgentGroup returns a single agent group (handler interface method). +func (s *AgentGroupService) GetAgentGroup(id string) (*domain.AgentGroup, error) { + return s.groupRepo.Get(context.Background(), id) +} + +// CreateAgentGroup creates a new agent group with validation (handler interface method). +func (s *AgentGroupService) CreateAgentGroup(group domain.AgentGroup) (*domain.AgentGroup, error) { + if err := validateAgentGroup(&group); err != nil { + return nil, err + } + + if group.ID == "" { + group.ID = generateID("ag") + } + now := time.Now() + if group.CreatedAt.IsZero() { + group.CreatedAt = now + } + if group.UpdatedAt.IsZero() { + group.UpdatedAt = now + } + + if err := s.groupRepo.Create(context.Background(), &group); err != nil { + return nil, fmt.Errorf("failed to create agent group: %w", err) + } + + if s.auditService != nil { + if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser, + "create_agent_group", "agent_group", group.ID, nil); auditErr != nil { + slog.Error("failed to record audit event", "error", auditErr) + } + } + + return &group, nil +} + +// UpdateAgentGroup modifies an existing agent group (handler interface method). +func (s *AgentGroupService) UpdateAgentGroup(id string, group domain.AgentGroup) (*domain.AgentGroup, error) { + if err := validateAgentGroup(&group); err != nil { + return nil, err + } + + group.ID = id + if err := s.groupRepo.Update(context.Background(), &group); err != nil { + return nil, fmt.Errorf("failed to update agent group: %w", err) + } + + if s.auditService != nil { + if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser, + "update_agent_group", "agent_group", id, nil); auditErr != nil { + slog.Error("failed to record audit event", "error", auditErr) + } + } + + return &group, nil +} + +// DeleteAgentGroup removes an agent group (handler interface method). +func (s *AgentGroupService) DeleteAgentGroup(id string) error { + if err := s.groupRepo.Delete(context.Background(), id); err != nil { + return fmt.Errorf("failed to delete agent group: %w", err) + } + + if s.auditService != nil { + if auditErr := s.auditService.RecordEvent(context.Background(), "api", domain.ActorTypeUser, + "delete_agent_group", "agent_group", id, nil); auditErr != nil { + slog.Error("failed to record audit event", "error", auditErr) + } + } + + return nil +} + +// ListMembers returns agents in a group. +func (s *AgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) { + agents, err := s.groupRepo.ListMembers(context.Background(), id) + if err != nil { + return nil, 0, fmt.Errorf("failed to list group members: %w", err) + } + + var result []domain.Agent + for _, a := range agents { + if a != nil { + result = append(result, *a) + } + } + + return result, int64(len(result)), nil +} + +// validateAgentGroup checks that an agent group's configuration is valid. +func validateAgentGroup(g *domain.AgentGroup) error { + if g.Name == "" { + return fmt.Errorf("agent group name is required") + } + if len(g.Name) > 255 { + return fmt.Errorf("agent group name exceeds 255 characters") + } + return nil +} diff --git a/internal/service/job.go b/internal/service/job.go index 09b511a..c6a6d8f 100644 --- a/internal/service/job.go +++ b/internal/service/job.go @@ -249,3 +249,50 @@ func (s *JobService) ListJobs(status, jobType string, page, perPage int) ([]doma func (s *JobService) GetJob(id string) (*domain.Job, error) { return s.jobRepo.Get(context.Background(), id) } + +// ApproveJob approves a renewal job that is awaiting approval. +// Transitions the job from AwaitingApproval to Pending so the scheduler picks it up. +func (s *JobService) ApproveJob(id string) error { + ctx := context.Background() + job, err := s.jobRepo.Get(ctx, id) + if err != nil { + return fmt.Errorf("job not found: %w", err) + } + + if job.Status != domain.JobStatusAwaitingApproval { + return fmt.Errorf("cannot approve job with status %s (must be AwaitingApproval)", job.Status) + } + + if err := s.jobRepo.UpdateStatus(ctx, id, domain.JobStatusPending, ""); err != nil { + return fmt.Errorf("failed to approve job: %w", err) + } + + s.logger.Info("renewal job approved", "job_id", id, "certificate_id", job.CertificateID) + return nil +} + +// RejectJob rejects a renewal job that is awaiting approval. +// Transitions the job to Cancelled with a rejection reason. +func (s *JobService) RejectJob(id string, reason string) error { + ctx := context.Background() + job, err := s.jobRepo.Get(ctx, id) + if err != nil { + return fmt.Errorf("job not found: %w", err) + } + + if job.Status != domain.JobStatusAwaitingApproval { + return fmt.Errorf("cannot reject job with status %s (must be AwaitingApproval)", job.Status) + } + + msg := "rejected by user" + if reason != "" { + msg = "rejected: " + reason + } + + if err := s.jobRepo.UpdateStatus(ctx, id, domain.JobStatusCancelled, msg); err != nil { + return fmt.Errorf("failed to reject job: %w", err) + } + + s.logger.Info("renewal job rejected", "job_id", id, "certificate_id", job.CertificateID, "reason", reason) + return nil +} diff --git a/internal/service/notification.go b/internal/service/notification.go index ea37ac7..11723bf 100644 --- a/internal/service/notification.go +++ b/internal/service/notification.go @@ -13,6 +13,7 @@ import ( // NotificationService provides business logic for managing notifications. type NotificationService struct { notifRepo repository.NotificationRepository + ownerRepo repository.OwnerRepository notifierRegistry map[string]Notifier } @@ -35,6 +36,25 @@ func NewNotificationService( } } +// SetOwnerRepo sets the owner repository for email resolution. +// Called after construction to avoid circular dependency during initialization. +func (s *NotificationService) SetOwnerRepo(ownerRepo repository.OwnerRepository) { + s.ownerRepo = ownerRepo +} + +// resolveRecipient resolves an owner ID to an email address. +// Falls back to the raw owner ID if the owner repo is not set or lookup fails. +func (s *NotificationService) resolveRecipient(ctx context.Context, ownerID string) string { + if s.ownerRepo == nil || ownerID == "" { + return ownerID + } + owner, err := s.ownerRepo.Get(ctx, ownerID) + if err != nil || owner == nil || owner.Email == "" { + return ownerID + } + return owner.Email +} + // SendExpirationWarning sends a certificate expiration warning for a specific threshold. func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error { return s.SendThresholdAlert(ctx, cert, daysUntilExpiry, daysUntilExpiry) @@ -56,13 +76,13 @@ func (s *NotificationService) SendThresholdAlert(ctx context.Context, cert *doma ) } - // Create notification record + // Create notification record — resolve owner email if possible notif := &domain.NotificationEvent{ ID: generateID("notif"), CertificateID: &cert.ID, Type: domain.NotificationTypeExpirationWarning, Channel: domain.NotificationChannelEmail, - Recipient: cert.OwnerID, + Recipient: s.resolveRecipient(ctx, cert.OwnerID), Message: body, Status: "pending", CreatedAt: time.Now(), @@ -121,7 +141,7 @@ func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert CertificateID: &cert.ID, Type: notifType, Channel: domain.NotificationChannelEmail, - Recipient: cert.OwnerID, + Recipient: s.resolveRecipient(ctx, cert.OwnerID), Message: body, Status: "pending", CreatedAt: time.Now(), @@ -160,7 +180,7 @@ func (s *NotificationService) SendDeploymentNotification(ctx context.Context, ce CertificateID: &cert.ID, Type: notifType, Channel: domain.NotificationChannelEmail, - Recipient: cert.OwnerID, + Recipient: s.resolveRecipient(ctx, cert.OwnerID), Message: body, Status: "pending", CreatedAt: time.Now(), diff --git a/migrations/000004_agent_groups.down.sql b/migrations/000004_agent_groups.down.sql new file mode 100644 index 0000000..88f792e --- /dev/null +++ b/migrations/000004_agent_groups.down.sql @@ -0,0 +1,4 @@ +-- Rollback migration 000004: Agent Groups +ALTER TABLE renewal_policies DROP COLUMN IF EXISTS agent_group_id; +DROP TABLE IF EXISTS agent_group_members; +DROP TABLE IF EXISTS agent_groups; diff --git a/migrations/000004_agent_groups.up.sql b/migrations/000004_agent_groups.up.sql new file mode 100644 index 0000000..b9bceec --- /dev/null +++ b/migrations/000004_agent_groups.up.sql @@ -0,0 +1,32 @@ +-- Migration 000004: Agent Groups +-- Adds dynamic device grouping by agent metadata criteria with manual override. + +CREATE TABLE IF NOT EXISTS agent_groups ( + id TEXT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT DEFAULT '', + -- Dynamic matching criteria (empty = manual-only group) + match_os VARCHAR(100) DEFAULT '', + match_architecture VARCHAR(100) DEFAULT '', + match_ip_cidr VARCHAR(45) DEFAULT '', + match_version VARCHAR(50) DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Manual group membership overrides (agents explicitly added/excluded) +CREATE TABLE IF NOT EXISTS agent_group_members ( + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + membership_type VARCHAR(20) NOT NULL DEFAULT 'include', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (agent_group_id, agent_id) +); + +-- Optional: scope renewal policies to an agent group +ALTER TABLE renewal_policies ADD COLUMN IF NOT EXISTS agent_group_id TEXT REFERENCES agent_groups(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_agent_groups_name ON agent_groups(name); +CREATE INDEX IF NOT EXISTS idx_agent_groups_enabled ON agent_groups(enabled); +CREATE INDEX IF NOT EXISTS idx_agent_group_members_agent ON agent_group_members(agent_id); diff --git a/migrations/seed_demo.sql b/migrations/seed_demo.sql index ddbc5ee..af707f3 100644 --- a/migrations/seed_demo.sql +++ b/migrations/seed_demo.sql @@ -190,3 +190,19 @@ INSERT INTO notification_events (id, type, certificate_id, channel, recipient, m ('ne-demo-05', 'renewal_success', 'mc-api-prod', 'email', 'alice@example.com', 'Certificate api-production renewed successfully', NOW() - INTERVAL '15 days', 'sent', NULL), ('ne-demo-06', 'deployment_success', 'mc-api-prod', 'webhook', 'https://hooks.example.com/certctl', 'Certificate api-production deployed to NGINX Production', NOW() - INTERVAL '15 days', 'sent', NULL) ON CONFLICT (id) DO NOTHING; + +-- Agent Groups +INSERT INTO agent_groups (id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at) VALUES + ('ag-linux-prod', 'Linux Production', 'All Linux agents in production', 'linux', '', '', '', true, NOW(), NOW()), + ('ag-linux-amd64', 'Linux AMD64', 'Linux agents on x86_64 architecture', 'linux', 'amd64', '', '', true, NOW(), NOW()), + ('ag-windows', 'Windows Agents', 'All Windows-based agents', 'windows', '', '', '', true, NOW(), NOW()), + ('ag-datacenter-a', 'Datacenter A', 'Agents in 10.0.1.0/24 subnet', '', '', '10.0.1.0/24', '', true, NOW(), NOW()), + ('ag-manual', 'Manual Group', 'Manually managed agent group (no dynamic criteria)', '', '', '', '', false, NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +-- Agent Group Members (manual membership for the manual group) +INSERT INTO agent_group_members (agent_group_id, agent_id, membership_type, created_at) VALUES + ('ag-manual', 'agent-web-1', 'include', NOW()), + ('ag-manual', 'agent-api-1', 'include', NOW()), + ('ag-manual', 'agent-db-1', 'exclude', NOW()) +ON CONFLICT (agent_group_id, agent_id) DO NOTHING; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bf7b8b3..269a928 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, PaginatedResponse } from './types'; +import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse } from './types'; const BASE = '/api/v1'; @@ -187,5 +187,69 @@ export const updateProfile = (id: string, data: Partial) => export const deleteProfile = (id: string) => fetchJSON<{ message: string }>(`${BASE}/profiles/${id}`, { method: 'DELETE' }); +// Owners +export const getOwners = (params: Record = {}) => { + const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); + return fetchJSON>(`${BASE}/owners?${qs}`); +}; + +export const getOwner = (id: string) => + fetchJSON(`${BASE}/owners/${id}`); + +export const createOwner = (data: Partial) => + fetchJSON(`${BASE}/owners`, { method: 'POST', body: JSON.stringify(data) }); + +export const updateOwner = (id: string, data: Partial) => + fetchJSON(`${BASE}/owners/${id}`, { method: 'PUT', body: JSON.stringify(data) }); + +export const deleteOwner = (id: string) => + fetchJSON<{ message: string }>(`${BASE}/owners/${id}`, { method: 'DELETE' }); + +// Teams +export const getTeams = (params: Record = {}) => { + const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); + return fetchJSON>(`${BASE}/teams?${qs}`); +}; + +export const getTeam = (id: string) => + fetchJSON(`${BASE}/teams/${id}`); + +export const createTeam = (data: Partial) => + fetchJSON(`${BASE}/teams`, { method: 'POST', body: JSON.stringify(data) }); + +export const updateTeam = (id: string, data: Partial) => + fetchJSON(`${BASE}/teams/${id}`, { method: 'PUT', body: JSON.stringify(data) }); + +export const deleteTeam = (id: string) => + fetchJSON<{ message: string }>(`${BASE}/teams/${id}`, { method: 'DELETE' }); + +// Agent Groups +export const getAgentGroups = (params: Record = {}) => { + const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); + return fetchJSON>(`${BASE}/agent-groups?${qs}`); +}; + +export const getAgentGroup = (id: string) => + fetchJSON(`${BASE}/agent-groups/${id}`); + +export const createAgentGroup = (data: Partial) => + fetchJSON(`${BASE}/agent-groups`, { method: 'POST', body: JSON.stringify(data) }); + +export const updateAgentGroup = (id: string, data: Partial) => + fetchJSON(`${BASE}/agent-groups/${id}`, { method: 'PUT', body: JSON.stringify(data) }); + +export const deleteAgentGroup = (id: string) => + fetchJSON<{ message: string }>(`${BASE}/agent-groups/${id}`, { method: 'DELETE' }); + +export const getAgentGroupMembers = (id: string) => + fetchJSON>(`${BASE}/agent-groups/${id}/members`); + +// Renewal Approvals +export const approveRenewal = (jobId: string) => + fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/approve`, { method: 'POST' }); + +export const rejectRenewal = (jobId: string, reason: string) => + fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }); + // Health export const getHealth = () => fetchJSON<{ status: string }>('/health'); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 0881cf1..e9424b1 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -149,6 +149,43 @@ export interface CertificateProfile { updated_at: string; } +export interface Owner { + id: string; + name: string; + email: string; + team_id: string; + created_at: string; + updated_at: string; +} + +export interface Team { + id: string; + name: string; + description: string; + created_at: string; + updated_at: string; +} + +export interface AgentGroup { + id: string; + name: string; + description: string; + match_os: string; + match_architecture: string; + match_ip_cidr: string; + match_version: string; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface AgentGroupMembership { + agent_group_id: string; + agent_id: string; + membership_type: string; + created_at: string; +} + export interface PaginatedResponse { data: T[]; total: number; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 0a806e1..45a6697 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -11,6 +11,9 @@ const nav = [ { to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, { to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, { to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, + { to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, + { to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, + { to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, ]; diff --git a/web/src/main.tsx b/web/src/main.tsx index f68afa6..97b851c 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -17,6 +17,9 @@ import PoliciesPage from './pages/PoliciesPage'; import IssuersPage from './pages/IssuersPage'; import TargetsPage from './pages/TargetsPage'; import ProfilesPage from './pages/ProfilesPage'; +import OwnersPage from './pages/OwnersPage'; +import TeamsPage from './pages/TeamsPage'; +import AgentGroupsPage from './pages/AgentGroupsPage'; import AuditPage from './pages/AuditPage'; import './index.css'; @@ -50,6 +53,9 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/web/src/pages/AgentGroupsPage.tsx b/web/src/pages/AgentGroupsPage.tsx new file mode 100644 index 0000000..a4e4a0f --- /dev/null +++ b/web/src/pages/AgentGroupsPage.tsx @@ -0,0 +1,94 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getAgentGroups, deleteAgentGroup } from '../api/client'; +import PageHeader from '../components/PageHeader'; +import DataTable from '../components/DataTable'; +import type { Column } from '../components/DataTable'; +import StatusBadge from '../components/StatusBadge'; +import ErrorState from '../components/ErrorState'; +import { formatDateTime } from '../api/utils'; +import type { AgentGroup } from '../api/types'; + +export default function AgentGroupsPage() { + const queryClient = useQueryClient(); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['agent-groups'], + queryFn: () => getAgentGroups(), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteAgentGroup, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-groups'] }), + }); + + const columns: Column[] = [ + { + key: 'name', + label: 'Group', + render: (g) => ( +
+
{g.name}
+
{g.id}
+ {g.description && ( +
{g.description}
+ )} +
+ ), + }, + { + key: 'criteria', + label: 'Match Criteria', + render: (g) => { + const criteria: string[] = []; + if (g.match_os) criteria.push(`OS: ${g.match_os}`); + if (g.match_architecture) criteria.push(`Arch: ${g.match_architecture}`); + if (g.match_ip_cidr) criteria.push(`IP: ${g.match_ip_cidr}`); + if (g.match_version) criteria.push(`Ver: ${g.match_version}`); + return criteria.length > 0 ? ( +
+ {criteria.map((c, i) => ( + {c} + ))} +
+ ) : ( + Manual only + ); + }, + }, + { + key: 'enabled', + label: 'Status', + render: (g) => , + }, + { + key: 'created', + label: 'Created', + render: (g) => {formatDateTime(g.created_at)}, + }, + { + key: 'actions', + label: '', + render: (g) => ( + + ), + }, + ]; + + return ( + <> + +
+ {error ? ( + refetch()} /> + ) : ( + + )} +
+ + ); +} diff --git a/web/src/pages/OwnersPage.tsx b/web/src/pages/OwnersPage.tsx new file mode 100644 index 0000000..f1a2a3b --- /dev/null +++ b/web/src/pages/OwnersPage.tsx @@ -0,0 +1,88 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getOwners, getTeams, deleteOwner } from '../api/client'; +import PageHeader from '../components/PageHeader'; +import DataTable from '../components/DataTable'; +import type { Column } from '../components/DataTable'; +import ErrorState from '../components/ErrorState'; +import { formatDateTime } from '../api/utils'; +import type { Owner, Team } from '../api/types'; + +export default function OwnersPage() { + const queryClient = useQueryClient(); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['owners'], + queryFn: () => getOwners(), + }); + + const { data: teamsData } = useQuery({ + queryKey: ['teams'], + queryFn: () => getTeams(), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteOwner, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['owners'] }), + }); + + const teamMap = new Map(); + (teamsData?.data || []).forEach((t) => teamMap.set(t.id, t)); + + const columns: Column[] = [ + { + key: 'name', + label: 'Owner', + render: (o) => ( +
+
{o.name}
+
{o.id}
+
+ ), + }, + { + key: 'email', + label: 'Email', + render: (o) => {o.email || '\u2014'}, + }, + { + key: 'team', + label: 'Team', + render: (o) => { + const team = teamMap.get(o.team_id); + return team + ? {team.name} + : {o.team_id || '\u2014'}; + }, + }, + { + key: 'created', + label: 'Created', + render: (o) => {formatDateTime(o.created_at)}, + }, + { + key: 'actions', + label: '', + render: (o) => ( + + ), + }, + ]; + + return ( + <> + +
+ {error ? ( + refetch()} /> + ) : ( + + )} +
+ + ); +} diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx new file mode 100644 index 0000000..bf8aef9 --- /dev/null +++ b/web/src/pages/TeamsPage.tsx @@ -0,0 +1,72 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getTeams, deleteTeam } from '../api/client'; +import PageHeader from '../components/PageHeader'; +import DataTable from '../components/DataTable'; +import type { Column } from '../components/DataTable'; +import ErrorState from '../components/ErrorState'; +import { formatDateTime } from '../api/utils'; +import type { Team } from '../api/types'; + +export default function TeamsPage() { + const queryClient = useQueryClient(); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['teams'], + queryFn: () => getTeams(), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteTeam, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['teams'] }), + }); + + const columns: Column[] = [ + { + key: 'name', + label: 'Team', + render: (t) => ( +
+
{t.name}
+
{t.id}
+
+ ), + }, + { + key: 'description', + label: 'Description', + render: (t) => ( + {t.description || '\u2014'} + ), + }, + { + key: 'created', + label: 'Created', + render: (t) => {formatDateTime(t.created_at)}, + }, + { + key: 'actions', + label: '', + render: (t) => ( + + ), + }, + ]; + + return ( + <> + +
+ {error ? ( + refetch()} /> + ) : ( + + )} +
+ + ); +}