From 863f214c10dd981952b0d9c0002d0f1bc80d1663 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 4 May 2026 00:18:19 +0100 Subject: [PATCH] Add CLI action audit reads --- docs/API.md | 9 + .../v6/internal/subsystems/api-contracts.md | 4 + pkg/pulsecli/actions.go | 255 ++++++++++++++++++ pkg/pulsecli/actions_test.go | 155 +++++++++++ 4 files changed, 423 insertions(+) diff --git a/docs/API.md b/docs/API.md index 0a4f852a9..4432264e7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -157,6 +157,15 @@ PULSE_API_TOKEN=your-token pulse actions plan \ --param mode=graceful \ --reason "Recover after confirmed outage" \ --requested-by agent:oncall-helper + +PULSE_API_TOKEN=your-token pulse actions audit \ + --api-url http://localhost:7655 \ + --resource-id vm:42 \ + --limit 10 + +PULSE_API_TOKEN=your-token pulse actions events \ + --api-url http://localhost:7655 \ + --action-id act_... ``` Request: diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8e13cb5f5..6c3e4d706 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -292,6 +292,10 @@ the canonical monitored-system blocked payload. the canonical `GET /api/resources/{id}/facets` payload to discover resource capability names and parameter schemas before planning, but it must not invent a parallel capability inventory or expose internal handler names. + `pulse actions audit` and `pulse actions events` may read + `GET /api/audit/actions` and `GET /api/audit/actions/{id}/events` as + verification adapters, but they must remain read-only views of the canonical + action audit and lifecycle trail rather than a second audit store. 6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection. `/api/resources/{id}/timeline` and `/api/resources/{id}/facets` must keep resource timelines relationship-aware by opting into the canonical diff --git a/pkg/pulsecli/actions.go b/pkg/pulsecli/actions.go index 4c25d5e8b..b5e1ddd84 100644 --- a/pkg/pulsecli/actions.go +++ b/pkg/pulsecli/actions.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "strings" + "time" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/spf13/cobra" @@ -49,6 +50,22 @@ type actionCapabilitiesOptions struct { ResourceID string } +type actionAuditOptions struct { + APIURL string + Token string + ResourceID string + Since string + Limit int +} + +type actionEventsOptions struct { + APIURL string + Token string + ActionID string + Since string + Limit int +} + type actionCapabilitiesResponse struct { ResourceID string `json:"resourceId"` Count int `json:"count"` @@ -60,6 +77,18 @@ type actionResourceFacetsResponse struct { Capabilities []unified.ResourceCapability `json:"capabilities,omitempty"` } +type actionAuditListResponse struct { + Audits []unified.ActionAuditRecord `json:"audits"` + Count int `json:"count"` + ResourceID string `json:"resourceId,omitempty"` +} + +type actionLifecycleEventsResponse struct { + ActionID string `json:"actionId"` + Events []unified.ActionLifecycleEvent `json:"events"` + Count int `json:"count"` +} + func newActionsCmd(deps *ActionsDeps) *cobra.Command { actionsCmd := &cobra.Command{ Use: "actions", @@ -95,6 +124,8 @@ func newActionsCmd(deps *ActionsDeps) *cobra.Command { actionsCmd.AddCommand(planCmd) actionsCmd.AddCommand(newActionCapabilitiesCmd(deps)) + actionsCmd.AddCommand(newActionAuditCmd(deps)) + actionsCmd.AddCommand(newActionEventsCmd(deps)) return actionsCmd } @@ -120,6 +151,56 @@ func newActionCapabilitiesCmd(deps *ActionsDeps) *cobra.Command { return cmd } +func newActionAuditCmd(deps *ActionsDeps) *cobra.Command { + opts := actionAuditOptions{ + APIURL: strings.TrimSpace(actionGetenv(deps, "PULSE_API_URL")), + Token: strings.TrimSpace(actionGetenv(deps, "PULSE_API_TOKEN")), + Limit: 100, + } + if opts.APIURL == "" { + opts.APIURL = defaultActionsAPIURL + } + + cmd := &cobra.Command{ + Use: "audit", + Short: "List governed action audit records", + RunE: func(cmd *cobra.Command, args []string) error { + return runActionAudit(cmd, deps, opts) + }, + } + cmd.Flags().StringVar(&opts.APIURL, "api-url", opts.APIURL, "Pulse server URL or /api base URL") + cmd.Flags().StringVar(&opts.Token, "token", opts.Token, "Pulse API token; defaults to PULSE_API_TOKEN") + cmd.Flags().StringVar(&opts.ResourceID, "resource-id", "", "canonical unified resource id") + cmd.Flags().StringVar(&opts.Since, "since", "", "RFC3339 lower bound for audit records") + cmd.Flags().IntVar(&opts.Limit, "limit", opts.Limit, "maximum audit records to return") + return cmd +} + +func newActionEventsCmd(deps *ActionsDeps) *cobra.Command { + opts := actionEventsOptions{ + APIURL: strings.TrimSpace(actionGetenv(deps, "PULSE_API_URL")), + Token: strings.TrimSpace(actionGetenv(deps, "PULSE_API_TOKEN")), + Limit: 100, + } + if opts.APIURL == "" { + opts.APIURL = defaultActionsAPIURL + } + + cmd := &cobra.Command{ + Use: "events", + Short: "List lifecycle events for a governed action", + RunE: func(cmd *cobra.Command, args []string) error { + return runActionEvents(cmd, deps, opts) + }, + } + cmd.Flags().StringVar(&opts.APIURL, "api-url", opts.APIURL, "Pulse server URL or /api base URL") + cmd.Flags().StringVar(&opts.Token, "token", opts.Token, "Pulse API token; defaults to PULSE_API_TOKEN") + cmd.Flags().StringVar(&opts.ActionID, "action-id", "", "governed action id") + cmd.Flags().StringVar(&opts.Since, "since", "", "RFC3339 lower bound for lifecycle events") + cmd.Flags().IntVar(&opts.Limit, "limit", opts.Limit, "maximum lifecycle events to return") + return cmd +} + func runActionPlan(cmd *cobra.Command, deps *ActionsDeps, opts actionPlanOptions) error { token := strings.TrimSpace(opts.Token) if token == "" { @@ -239,6 +320,122 @@ func runActionCapabilities(cmd *cobra.Command, deps *ActionsDeps, opts actionCap return nil } +func runActionAudit(cmd *cobra.Command, deps *ActionsDeps, opts actionAuditOptions) error { + token := strings.TrimSpace(opts.Token) + if token == "" { + return fmt.Errorf("api token is required (use --token or PULSE_API_TOKEN)") + } + + query, err := actionAuditQuery(opts.ResourceID, opts.Since, opts.Limit) + if err != nil { + return err + } + endpoint, err := actionAPIEndpoint(opts.APIURL, "/audit/actions") + if err != nil { + return err + } + endpoint.RawQuery = query.Encode() + + httpReq, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("failed to build action audit request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + resp, err := actionHTTPClient(deps).Do(httpReq) + if err != nil { + return fmt.Errorf("action audit request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxActionPlanResponseBytes, "action audit response") + if err != nil { + return fmt.Errorf("failed to read action audit response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return actionStatusError("action audit request", resp.Status, respBody) + } + + var audits actionAuditListResponse + if err := decodeJSONBytes(respBody, &audits); err != nil { + return fmt.Errorf("failed to decode action audit response: %w", err) + } + if audits.Audits == nil { + audits.Audits = []unified.ActionAuditRecord{} + } + audits.Count = len(audits.Audits) + + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(audits); err != nil { + return fmt.Errorf("failed to write action audit response: %w", err) + } + return nil +} + +func runActionEvents(cmd *cobra.Command, deps *ActionsDeps, opts actionEventsOptions) error { + token := strings.TrimSpace(opts.Token) + if token == "" { + return fmt.Errorf("api token is required (use --token or PULSE_API_TOKEN)") + } + + actionID := strings.TrimSpace(opts.ActionID) + if actionID == "" { + return fmt.Errorf("actionId is required (use --action-id)") + } + + query, err := actionAuditQuery("", opts.Since, opts.Limit) + if err != nil { + return err + } + endpoint, err := actionAPIEndpoint(opts.APIURL, "/audit/actions/"+url.PathEscape(actionID)+"/events") + if err != nil { + return err + } + endpoint.RawQuery = query.Encode() + + httpReq, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("failed to build action lifecycle request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + + resp, err := actionHTTPClient(deps).Do(httpReq) + if err != nil { + return fmt.Errorf("action lifecycle request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxActionPlanResponseBytes, "action lifecycle response") + if err != nil { + return fmt.Errorf("failed to read action lifecycle response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return actionStatusError("action lifecycle request", resp.Status, respBody) + } + + var events actionLifecycleEventsResponse + if err := decodeJSONBytes(respBody, &events); err != nil { + return fmt.Errorf("failed to decode action lifecycle response: %w", err) + } + if strings.TrimSpace(events.ActionID) == "" { + events.ActionID = actionID + } + if events.Events == nil { + events.Events = []unified.ActionLifecycleEvent{} + } + events.Count = len(events.Events) + + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(events); err != nil { + return fmt.Errorf("failed to write action lifecycle response: %w", err) + } + return nil +} + func buildActionRequest(cmd *cobra.Command, opts actionPlanOptions) (unified.ActionRequest, error) { var req unified.ActionRequest if strings.TrimSpace(opts.RequestFile) != "" { @@ -443,6 +640,64 @@ func actionResourceFacetsEndpoint(raw, resourceID string) (string, error) { return parsed.String(), nil } +func actionAPIEndpoint(raw, apiPath string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("api url is required (use --api-url or PULSE_API_URL)") + } + if !strings.HasPrefix(apiPath, "/") { + return nil, fmt.Errorf("api path must start with /") + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid api url: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("invalid api url: scheme must be http or https") + } + if parsed.Host == "" { + return nil, fmt.Errorf("invalid api url: host is required") + } + + apiPath = strings.TrimRight(apiPath, "/") + fullPath := "/api" + apiPath + path := strings.TrimRight(parsed.Path, "/") + switch { + case path == "": + parsed.Path = fullPath + case path == fullPath || strings.HasSuffix(path, fullPath): + parsed.Path = path + case path == "/api" || strings.HasSuffix(path, "/api"): + parsed.Path = path + apiPath + default: + parsed.Path = path + fullPath + } + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed, nil +} + +func actionAuditQuery(resourceID, since string, limit int) (url.Values, error) { + if limit <= 0 { + return nil, fmt.Errorf("limit must be greater than zero") + } + + query := url.Values{} + if resourceID = unified.CanonicalResourceID(resourceID); resourceID != "" { + query.Set("resourceId", resourceID) + } + if since = strings.TrimSpace(since); since != "" { + parsed, err := time.Parse(time.RFC3339, since) + if err != nil { + return nil, fmt.Errorf("since must be RFC3339: %w", err) + } + query.Set("since", parsed.UTC().Format(time.RFC3339)) + } + query.Set("limit", fmt.Sprintf("%d", limit)) + return query, nil +} + func actionHTTPClient(deps *ActionsDeps) HTTPDoer { if deps != nil && deps.HTTPClient != nil { return deps.HTTPClient diff --git a/pkg/pulsecli/actions_test.go b/pkg/pulsecli/actions_test.go index b60f98653..ed0353b66 100644 --- a/pkg/pulsecli/actions_test.go +++ b/pkg/pulsecli/actions_test.go @@ -200,6 +200,161 @@ func TestActionsCapabilitiesCommandRequiresToken(t *testing.T) { } } +func TestActionsAuditCommandFetchesActionAudits(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/api/audit/actions" { + t.Fatalf("path = %s, want /api/audit/actions", r.URL.Path) + } + if got := r.URL.Query().Get("resourceId"); got != "vm:42" { + t.Fatalf("resourceId query = %q", got) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("limit query = %q", got) + } + if got := r.URL.Query().Get("since"); got != "2026-05-03T10:00:00Z" { + t.Fatalf("since query = %q", got) + } + receivedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(actionAuditListResponse{ + Audits: []unified.ActionAuditRecord{ + { + ID: "act_test", + CreatedAt: now, + UpdatedAt: now, + State: unified.ActionStatePlanned, + Request: unified.ActionRequest{ + RequestID: "req-1", + ResourceID: "vm:42", + CapabilityName: "restart", + Reason: "Recover", + RequestedBy: "agent:oncall-helper", + }, + Plan: unified.ActionPlan{ + ActionID: "act_test", + RequestID: "req-1", + Allowed: true, + ApprovalPolicy: unified.ApprovalAdmin, + PlannedAt: now, + ExpiresAt: now.Add(5 * time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", + }, + }, + }, + Count: 1, + ResourceID: "vm:42", + }) + })) + defer server.Close() + + cmd := newTestActionsRootCommand(map[string]string{ + "PULSE_API_TOKEN": "test-token", + "PULSE_API_URL": server.URL + "/api", + }) + cmd.SetArgs([]string{ + "actions", "audit", + "--resource-id", "vm:42", + "--limit", "5", + "--since", "2026-05-03T11:00:00+01:00", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute actions audit: %v", err) + } + if receivedAuth != "Bearer test-token" { + t.Fatalf("Authorization = %q", receivedAuth) + } + + var audits actionAuditListResponse + if err := json.Unmarshal(out.Bytes(), &audits); err != nil { + t.Fatalf("decode command output: %v\n%s", err, out.String()) + } + if audits.ResourceID != "vm:42" || audits.Count != 1 || audits.Audits[0].ID != "act_test" { + t.Fatalf("audit output = %+v", audits) + } +} + +func TestActionsEventsCommandFetchesLifecycleEvents(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/api/audit/actions/act_test/events" { + t.Fatalf("path = %s, want /api/audit/actions/act_test/events", r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "2" { + t.Fatalf("limit query = %q", got) + } + receivedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(actionLifecycleEventsResponse{ + ActionID: "act_test", + Events: []unified.ActionLifecycleEvent{ + { + ActionID: "act_test", + Timestamp: now, + State: unified.ActionStatePlanned, + Actor: "agent:oncall-helper", + Message: "Plan created", + }, + }, + Count: 1, + }) + })) + defer server.Close() + + cmd := newTestActionsRootCommand(map[string]string{ + "PULSE_API_TOKEN": "test-token", + "PULSE_API_URL": server.URL + "/api", + }) + cmd.SetArgs([]string{ + "actions", "events", + "--action-id", "act_test", + "--limit", "2", + }) + var out bytes.Buffer + cmd.SetOut(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute actions events: %v", err) + } + if receivedAuth != "Bearer test-token" { + t.Fatalf("Authorization = %q", receivedAuth) + } + + var events actionLifecycleEventsResponse + if err := json.Unmarshal(out.Bytes(), &events); err != nil { + t.Fatalf("decode command output: %v\n%s", err, out.String()) + } + if events.ActionID != "act_test" || events.Count != 1 || events.Events[0].Message != "Plan created" { + t.Fatalf("events output = %+v", events) + } +} + +func TestActionsEventsCommandRequiresActionID(t *testing.T) { + cmd := newTestActionsRootCommand(map[string]string{ + "PULSE_API_TOKEN": "test-token", + "PULSE_API_URL": "http://127.0.0.1:7655", + }) + cmd.SetArgs([]string{"actions", "events"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "actionId is required") { + t.Fatalf("expected action id error, got %v", err) + } +} + func TestActionsPlanCommandUsesRequestFileFromStdin(t *testing.T) { var received unified.ActionRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {