Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions cmd/audit/audit.go
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 20 additions & 0 deletions cmd/audit/audit_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
29 changes: 29 additions & 0 deletions internal/models/audit_event.go
Original file line number Diff line number Diff line change
@@ -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"`
}
60 changes: 60 additions & 0 deletions internal/service/audit_event.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions internal/service/audit_event_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading