Add CLI action audit reads

This commit is contained in:
rcourtman
2026-05-04 00:18:19 +01:00
parent 2c96242487
commit 863f214c10
4 changed files with 423 additions and 0 deletions
+9
View File
@@ -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:
@@ -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
+255
View File
@@ -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
+155
View File
@@ -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) {