From a0645a81c76c7a6afac6160d77b7c7c87f5fc0ae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:03:59 +0200 Subject: [PATCH] feat(audit): add command to list team audit events Add `audit list` (alias `audit-logs`) to fetch paginated team audit events from the API, with source, action, and search filters. --- cmd/audit/audit.go | 61 ++++++++++++++++++++++++++++ cmd/audit/audit_test.go | 20 +++++++++ cmd/root.go | 2 + internal/models/audit_event.go | 29 +++++++++++++ internal/service/audit_event.go | 60 +++++++++++++++++++++++++++ internal/service/audit_event_test.go | 43 ++++++++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 cmd/audit/audit.go create mode 100644 cmd/audit/audit_test.go create mode 100644 internal/models/audit_event.go create mode 100644 internal/service/audit_event.go create mode 100644 internal/service/audit_event_test.go diff --git a/cmd/audit/audit.go b/cmd/audit/audit.go new file mode 100644 index 0000000..aa53f33 --- /dev/null +++ b/cmd/audit/audit.go @@ -0,0 +1,61 @@ +package audit + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/coollabsio/coolify-cli/internal/cli" + "github.com/coollabsio/coolify-cli/internal/output" + "github.com/coollabsio/coolify-cli/internal/service" +) + +func NewAuditCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "audit", + Aliases: []string{"audit-logs"}, + Short: "View team audit logs", + } + cmd.AddCommand(newListCommand()) + return cmd +} + +func newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List audit events for the current team", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := cli.GetAPIClient(cmd) + if err != nil { + return fmt.Errorf("failed to get API client: %w", err) + } + + source, _ := cmd.Flags().GetString("source") + action, _ := cmd.Flags().GetString("action") + search, _ := cmd.Flags().GetString("search") + perPage, _ := cmd.Flags().GetInt("per-page") + page, _ := cmd.Flags().GetInt("page") + + result, err := service.NewAuditEventService(client).List(cmd.Context(), service.AuditEventListOptions{ + Source: source, Action: action, Search: search, PerPage: perPage, Page: page, + }) + if err != nil { + return err + } + + format, _ := cmd.Flags().GetString("format") + formatter, err := output.NewFormatter(format, output.Options{}) + if err != nil { + return fmt.Errorf("failed to create formatter: %w", err) + } + return formatter.Format(result.Data) + }, + } + + cmd.Flags().String("source", "", "Filter by source (ui, api, mcp, webhook)") + cmd.Flags().String("action", "", "Filter by action") + cmd.Flags().String("search", "", "Search events, actors, and resources") + cmd.Flags().Int("per-page", 25, "Events per page (maximum 100)") + cmd.Flags().Int("page", 1, "Page number") + return cmd +} diff --git a/cmd/audit/audit_test.go b/cmd/audit/audit_test.go new file mode 100644 index 0000000..255d0a1 --- /dev/null +++ b/cmd/audit/audit_test.go @@ -0,0 +1,20 @@ +package audit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuditCommandRegistersListAndFilters(t *testing.T) { + cmd := NewAuditCommand() + list, _, err := cmd.Find([]string{"list"}) + require.NoError(t, err) + assert.Equal(t, "list", list.Name()) + assert.NotNil(t, list.Flags().Lookup("source")) + assert.NotNil(t, list.Flags().Lookup("action")) + assert.NotNil(t, list.Flags().Lookup("search")) + assert.NotNil(t, list.Flags().Lookup("per-page")) + assert.NotNil(t, list.Flags().Lookup("page")) +} diff --git a/cmd/root.go b/cmd/root.go index 0ebb4d7..e4fd95d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/viper" "github.com/coollabsio/coolify-cli/cmd/application" + "github.com/coollabsio/coolify-cli/cmd/audit" "github.com/coollabsio/coolify-cli/cmd/cloudinit" "github.com/coollabsio/coolify-cli/cmd/cloudtoken" "github.com/coollabsio/coolify-cli/cmd/completion" @@ -99,6 +100,7 @@ func init() { // repo for development but are deliberately not added here, so they cannot // be invoked from the public CLI. rootCmd.AddCommand(application.NewAppCommand()) + rootCmd.AddCommand(audit.NewAuditCommand()) rootCmd.AddCommand(cloudinit.NewCloudInitCommand()) rootCmd.AddCommand(cloudtoken.NewCloudTokenCommand()) rootCmd.AddCommand(completion.NewCompletionsCommand()) diff --git a/internal/models/audit_event.go b/internal/models/audit_event.go new file mode 100644 index 0000000..cd9cf0f --- /dev/null +++ b/internal/models/audit_event.go @@ -0,0 +1,29 @@ +package models + +// AuditEvent is an authenticated team activity record. +type AuditEvent struct { + ID int `json:"id" table:"-"` + Event string `json:"event"` + Source string `json:"source"` + Action string `json:"action"` + ActorName string `json:"actor_name" table:"actor"` + ActorEmail string `json:"actor_email" table:"actor_email"` + ActorTokenName string `json:"actor_token_name" table:"api_token"` + ResourceType string `json:"resource_type"` + ResourceUUID string `json:"resource_uuid"` + ResourceName string `json:"resource_name"` + Description string `json:"description"` + Metadata map[string]any `json:"metadata" table:"-"` + IPAddress string `json:"ip_address" table:"-"` + UserAgent string `json:"user_agent" table:"-"` + CreatedAt string `json:"created_at" table:"created_at"` +} + +// AuditEventsPage is a page returned by the audit-events API. +type AuditEventsPage struct { + CurrentPage int `json:"current_page"` + Data []AuditEvent `json:"data"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} diff --git a/internal/service/audit_event.go b/internal/service/audit_event.go new file mode 100644 index 0000000..58522ce --- /dev/null +++ b/internal/service/audit_event.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +// AuditEventListOptions filters audit events for the current token's team. +type AuditEventListOptions struct { + Source string + Action string + Search string + PerPage int + Page int +} + +// AuditEventService handles audit event queries. +type AuditEventService struct { + client *api.Client +} + +func NewAuditEventService(client *api.Client) *AuditEventService { + return &AuditEventService{client: client} +} + +func (s *AuditEventService) List(ctx context.Context, options AuditEventListOptions) (*models.AuditEventsPage, error) { + query := url.Values{} + if options.Source != "" { + query.Set("source", options.Source) + } + if options.Action != "" { + query.Set("action", options.Action) + } + if options.Search != "" { + query.Set("search", options.Search) + } + if options.PerPage > 0 { + query.Set("per_page", strconv.Itoa(options.PerPage)) + } + if options.Page > 0 { + query.Set("page", strconv.Itoa(options.Page)) + } + + endpoint := "audit-events" + if encoded := query.Encode(); encoded != "" { + endpoint += "?" + encoded + } + + var response models.AuditEventsPage + if err := s.client.Get(ctx, endpoint, &response); err != nil { + return nil, fmt.Errorf("failed to list audit events: %w", err) + } + + return &response, nil +} diff --git a/internal/service/audit_event_test.go b/internal/service/audit_event_test.go new file mode 100644 index 0000000..d3aac4e --- /dev/null +++ b/internal/service/audit_event_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +func TestAuditEventServiceList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/audit-events", r.URL.Path) + assert.Equal(t, "api", r.URL.Query().Get("source")) + assert.Equal(t, "updated", r.URL.Query().Get("action")) + assert.Equal(t, "website app", r.URL.Query().Get("search")) + assert.Equal(t, "50", r.URL.Query().Get("per_page")) + assert.Equal(t, "2", r.URL.Query().Get("page")) + _ = json.NewEncoder(w).Encode(models.AuditEventsPage{ + CurrentPage: 2, + Data: []models.AuditEvent{{Event: "api.application.updated", ActorEmail: "admin@example.com"}}, + LastPage: 2, + PerPage: 50, + Total: 51, + }) + })) + defer server.Close() + + result, err := NewAuditEventService(api.NewClient(server.URL, "token")).List(context.Background(), AuditEventListOptions{ + Source: "api", Action: "updated", Search: "website app", PerPage: 50, Page: 2, + }) + require.NoError(t, err) + require.Len(t, result.Data, 1) + assert.Equal(t, "api.application.updated", result.Data[0].Event) + assert.Equal(t, "admin@example.com", result.Data[0].ActorEmail) + assert.Equal(t, 51, result.Total) +}