diff --git a/cmd/audit_logs.go b/cmd/audit_logs.go new file mode 100644 index 00000000..23230ee6 --- /dev/null +++ b/cmd/audit_logs.go @@ -0,0 +1,253 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +type AuditLogsService interface { + ListAutoPaging(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] +} + +type AuditLogsCmd struct { + auditLogs AuditLogsService +} + +type AuditLogsSearchInput struct { + Start string + End string + Search string + Method string + ExcludeMethod string + IncludeGet bool + Service string + AuthStrategy string + UserIDs []string + Limit int + Output string +} + +const auditLogsMaxPageSize = 100 + +func (c AuditLogsCmd) Search(ctx context.Context, in AuditLogsSearchInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.Limit < 1 { + return fmt.Errorf("--limit must be positive") + } + var err error + start := time.Now().UTC().Add(-24 * time.Hour) + if in.Start != "" { + start, _, err = parseAuditLogTime(in.Start) + if err != nil { + return fmt.Errorf("--start: %w", err) + } + } + end := time.Now().UTC() + if in.End != "" { + var dateOnly bool + end, dateOnly, err = parseAuditLogTime(in.End) + if err != nil { + return fmt.Errorf("--end: %w", err) + } + // End is exclusive; a date-only end means the whole of that day. + if dateOnly { + end = end.Add(24 * time.Hour) + } + } + if !start.Before(end) { + return fmt.Errorf("--start must be before --end") + } + + params := kernel.AuditLogListParams{Start: start, End: end} + if in.Search != "" { + params.Search = kernel.String(in.Search) + } + if in.Method != "" { + params.Method = kernel.String(in.Method) + } + // The API accepts a single exclude_method. When the default GET exclusion + // stacks with a user-provided one, GET goes server-side (it drops the most + // rows) and the user's method is filtered client-side. + excludeGetByDefault := in.Method == "" && !in.IncludeGet + var clientExclude string + serverExclude := in.ExcludeMethod + if excludeGetByDefault && !strings.EqualFold(in.ExcludeMethod, "GET") { + clientExclude = in.ExcludeMethod + serverExclude = "GET" + } + if serverExclude != "" { + params.ExcludeMethod = kernel.String(serverExclude) + } + if in.Service != "" { + params.Service = kernel.String(in.Service) + } + if in.AuthStrategy != "" { + params.AuthStrategy = kernel.String(in.AuthStrategy) + } + if len(in.UserIDs) > 0 { + params.SearchUserID = in.UserIDs + } + params.Limit = kernel.Int(int64(min(in.Limit, auditLogsMaxPageSize))) + + entries := make([]kernel.AuditLogEntry, 0) + hasMore := false + pager := c.auditLogs.ListAutoPaging(ctx, params) + for pager.Next() { + entry := pager.Current() + if clientExclude != "" && strings.EqualFold(entry.Method, clientExclude) { + continue + } + entries = append(entries, entry) + if len(entries) >= in.Limit { + hasMore = peekForMore(pager, clientExclude) + break + } + } + if err := pager.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSONSlice(entries) + } + + if len(entries) == 0 { + pterm.Info.Println("No audit log entries found") + return nil + } + + table := pterm.TableData{{"Timestamp", "Method", "Status", "Path", "User", "Duration (ms)", "Client IP"}} + for _, entry := range entries { + table = append(table, []string{ + util.FormatLocal(entry.Timestamp), + entry.Method, + strconv.FormatInt(entry.Status, 10), + entry.Path, + formatAuditLogUser(entry), + strconv.FormatInt(entry.DurationMs, 10), + entry.ClientIP, + }) + } + PrintTableNoPad(table, true) + + if hasMore { + pterm.Info.Printf("Showing first %d results; increase --limit or narrow the search window\n", in.Limit) + } + return nil +} + +// peekForMore reports whether entries matching the client-side exclusion +// remain past the limit. The scan is capped at one page so a long run of +// excluded entries can't trigger unbounded fetching; hitting the cap assumes +// more may match. +func peekForMore(pager *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry], clientExclude string) bool { + for peeked := 0; peeked < auditLogsMaxPageSize; peeked++ { + if !pager.Next() { + return false + } + if clientExclude == "" || !strings.EqualFold(pager.Current().Method, clientExclude) { + return true + } + } + return true +} + +func parseAuditLogTime(value string) (t time.Time, dateOnly bool, err error) { + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t, false, nil + } + if t, err := time.Parse("2006-01-02", value); err == nil { + return t, true, nil + } + return time.Time{}, false, fmt.Errorf("invalid time %q (expected RFC3339 like 2026-07-01T15:04:05Z or a date like 2026-07-01)", value) +} + +func formatAuditLogUser(entry kernel.AuditLogEntry) string { + if entry.Email != "" { + return entry.Email + } + if entry.UserID != "" { + return entry.UserID + } + return "-" +} + +func getAuditLogsHandler(cmd *cobra.Command) AuditLogsCmd { + client := getKernelClient(cmd) + return AuditLogsCmd{auditLogs: &client.AuditLogs} +} + +func runAuditLogsSearch(cmd *cobra.Command, args []string) error { + c := getAuditLogsHandler(cmd) + start, _ := cmd.Flags().GetString("start") + end, _ := cmd.Flags().GetString("end") + search, _ := cmd.Flags().GetString("search") + method, _ := cmd.Flags().GetString("method") + excludeMethod, _ := cmd.Flags().GetString("exclude-method") + includeGet, _ := cmd.Flags().GetBool("include-get") + service, _ := cmd.Flags().GetString("service") + authStrategy, _ := cmd.Flags().GetString("auth-strategy") + userIDs, _ := cmd.Flags().GetStringArray("user-id") + limit, _ := cmd.Flags().GetInt("limit") + output, _ := cmd.Flags().GetString("output") + + return c.Search(cmd.Context(), AuditLogsSearchInput{ + Start: start, + End: end, + Search: search, + Method: method, + ExcludeMethod: excludeMethod, + IncludeGet: includeGet, + Service: service, + AuthStrategy: authStrategy, + UserIDs: userIDs, + Limit: limit, + Output: output, + }) +} + +var auditLogsCmd = &cobra.Command{ + Use: "audit-logs", + Aliases: []string{"audit-log", "auditlogs", "auditlog"}, + Short: "Search audit logs", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var auditLogsSearchCmd = &cobra.Command{ + Use: "search", + Short: "Search audit logs within a time window", + Long: "Search audit logs within a bounded time window.\n\nGET requests are excluded by default; pass --include-get to include them, or --method GET to see only them.\n\nThe API limits searches to a 30-day window and returns up to 100 records per page. Not recommended for bulk export.", + Args: cobra.NoArgs, + RunE: runAuditLogsSearch, +} + +func init() { + addJSONOutputFlag(auditLogsSearchCmd) + auditLogsSearchCmd.Flags().String("start", "", "Start of the search window, RFC3339 or YYYY-MM-DD (default: 24 hours ago)") + auditLogsSearchCmd.Flags().String("end", "", "End of the search window, RFC3339 or YYYY-MM-DD inclusive (default: now)") + auditLogsSearchCmd.Flags().String("search", "", "Free-text search") + auditLogsSearchCmd.Flags().String("method", "", "Filter by HTTP method (e.g. GET)") + auditLogsSearchCmd.Flags().String("exclude-method", "", "Exclude an HTTP method") + auditLogsSearchCmd.Flags().Bool("include-get", false, "Include GET requests, which are excluded by default") + auditLogsSearchCmd.Flags().String("service", "", "Filter by service") + auditLogsSearchCmd.Flags().String("auth-strategy", "", "Filter by authentication strategy") + auditLogsSearchCmd.Flags().StringArray("user-id", nil, "Filter by user ID (repeatable)") + auditLogsSearchCmd.Flags().Int("limit", 100, "Maximum number of results to return") + + auditLogsCmd.AddCommand(auditLogsSearchCmd) + rootCmd.AddCommand(auditLogsCmd) +} diff --git a/cmd/audit_logs_test.go b/cmd/audit_logs_test.go new file mode 100644 index 00000000..5c813e5a --- /dev/null +++ b/cmd/audit_logs_test.go @@ -0,0 +1,309 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type FakeAuditLogsService struct { + ListAutoPagingFunc func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] +} + +func (f *FakeAuditLogsService) ListAutoPaging(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + if f.ListAutoPagingFunc != nil { + return f.ListAutoPagingFunc(ctx, query, opts...) + } + return auditLogPager() +} + +func auditLogPager(entries ...kernel.AuditLogEntry) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + page := &pagination.PageTokenPagination[kernel.AuditLogEntry]{Items: entries} + page.SetPageConfig(nil, &http.Response{Header: http.Header{}}) + return pagination.NewPageTokenPaginationAutoPager(page, nil) +} + +func auditLogEntryFromJSON(raw string) kernel.AuditLogEntry { + var entry kernel.AuditLogEntry + if err := json.Unmarshal([]byte(raw), &entry); err != nil { + panic(err) + } + return entry +} + +func sampleAuditLogEntry(method, path string) kernel.AuditLogEntry { + return auditLogEntryFromJSON(`{"timestamp":"2026-07-01T12:00:00Z","method":"` + method + `","status":201,"path":"` + path + `","route":"/browsers","domain":"api.onkernel.com","email":"dev@example.com","user_id":"user_123","auth_strategy":"api_key","client_ip":"203.0.113.7","user_agent":"kernel-cli","duration_ms":42}`) +} + +func TestAuditLogsSearchBuildsParamsAndPrintsTable(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), query.Start) + assert.Equal(t, time.Date(2026, 7, 2, 15, 0, 0, 0, time.UTC), query.End) + assert.Equal(t, "browsers", query.Search.Value) + assert.Equal(t, "POST", query.Method.Value) + assert.Equal(t, "OPTIONS", query.ExcludeMethod.Value) + assert.Equal(t, "api", query.Service.Value) + assert.Equal(t, "api_key", query.AuthStrategy.Value) + assert.Equal(t, []string{"user_123", "user_456"}, query.SearchUserID) + assert.Equal(t, int64(50), query.Limit.Value) + return auditLogPager(sampleAuditLogEntry("POST", "/browsers")) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{ + Start: "2026-07-01", + End: "2026-07-02T15:00:00Z", + Search: "browsers", + Method: "POST", + ExcludeMethod: "OPTIONS", + Service: "api", + AuthStrategy: "api_key", + UserIDs: []string{"user_123", "user_456"}, + Limit: 50, + }) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "/browsers") + assert.Contains(t, out, "POST") + assert.Contains(t, out, "201") + assert.Contains(t, out, "dev@example.com") + assert.Contains(t, out, "203.0.113.7") +} + +func TestAuditLogsSearchRejectsInvalidStart(t *testing.T) { + c := AuditLogsCmd{auditLogs: &FakeAuditLogsService{}} + + err := c.Search(context.Background(), AuditLogsSearchInput{Start: "yesterday", Limit: 100}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--start") + assert.Contains(t, err.Error(), "invalid time") +} + +func TestAuditLogsSearchRejectsStartAfterEnd(t *testing.T) { + c := AuditLogsCmd{auditLogs: &FakeAuditLogsService{}} + + err := c.Search(context.Background(), AuditLogsSearchInput{ + Start: "2026-07-02", + End: "2026-07-01", + Limit: 100, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--start must be before --end") +} + +func TestAuditLogsSearchDefaultsWindowToLast24Hours(t *testing.T) { + capturePtermOutput(t) + var gotStart, gotEnd time.Time + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + gotStart = query.Start + gotEnd = query.End + return auditLogPager() + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Limit: 100}) + require.NoError(t, err) + assert.WithinDuration(t, time.Now().UTC().Add(-24*time.Hour), gotStart, time.Minute) + assert.WithinDuration(t, time.Now().UTC(), gotEnd, time.Minute) +} + +func TestAuditLogsSearchDateOnlyEndCoversWholeDay(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), query.Start) + assert.Equal(t, time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), query.End) + return auditLogPager() + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Start: "2026-07-01", End: "2026-07-01", Limit: 100}) + require.NoError(t, err) +} + +func TestAuditLogsSearchNoTruncationNoticeWhenResultsEndAtLimit(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + return auditLogPager(sampleAuditLogEntry("POST", "/first"), sampleAuditLogEntry("POST", "/second")) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Start: "2026-07-01", Limit: 2}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "/second") + assert.NotContains(t, out, "Showing first") +} + +func TestAuditLogsSearchExcludesGetByDefault(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, "GET", query.ExcludeMethod.Value) + return auditLogPager() + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Limit: 100}) + require.NoError(t, err) +} + +func TestAuditLogsSearchIncludeGetDisablesDefaultExclusion(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.False(t, query.ExcludeMethod.Valid()) + return auditLogPager() + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{IncludeGet: true, Limit: 100}) + require.NoError(t, err) +} + +func TestAuditLogsSearchMethodDisablesDefaultExclusion(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, "GET", query.Method.Value) + assert.False(t, query.ExcludeMethod.Valid()) + return auditLogPager() + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Method: "GET", Limit: 100}) + require.NoError(t, err) +} + +func TestAuditLogsSearchExcludeMethodStacksWithDefaultGetExclusion(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, "GET", query.ExcludeMethod.Value) + return auditLogPager(sampleAuditLogEntry("POST", "/posted"), sampleAuditLogEntry("DELETE", "/deleted")) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{ExcludeMethod: "post", Limit: 100}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "/deleted") + assert.NotContains(t, out, "/posted") +} + +func TestAuditLogsSearchIncludeGetWithExcludeMethodSendsUserExclusion(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, "POST", query.ExcludeMethod.Value) + return auditLogPager() + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{IncludeGet: true, ExcludeMethod: "POST", Limit: 100}) + require.NoError(t, err) +} + +func TestAuditLogsSearchLimitTruncatesResults(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + assert.Equal(t, int64(2), query.Limit.Value) + return auditLogPager( + sampleAuditLogEntry("POST", "/first"), + sampleAuditLogEntry("POST", "/second"), + sampleAuditLogEntry("POST", "/third"), + ) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Start: "2026-07-01", Limit: 2}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "/first") + assert.Contains(t, out, "/second") + assert.NotContains(t, out, "/third") + assert.Contains(t, out, "Showing first 2 results") +} + +func TestAuditLogsSearchNoTruncationNoticeWhenOnlyExcludedEntriesRemain(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + return auditLogPager(sampleAuditLogEntry("DELETE", "/deleted"), sampleAuditLogEntry("POST", "/posted"), sampleAuditLogEntry("POST", "/posted-too")) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{ExcludeMethod: "POST", Limit: 1}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "/deleted") + assert.NotContains(t, out, "Showing first") +} + +func TestAuditLogsSearchTruncationNoticeWhenMatchRemainsPastExcludedEntries(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + return auditLogPager(sampleAuditLogEntry("DELETE", "/first"), sampleAuditLogEntry("POST", "/posted"), sampleAuditLogEntry("DELETE", "/second")) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{ExcludeMethod: "POST", Limit: 1}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Showing first 1 results") +} + +func TestAuditLogsSearchPrintsEmptyMessage(t *testing.T) { + buf := capturePtermOutput(t) + c := AuditLogsCmd{auditLogs: &FakeAuditLogsService{}} + + err := c.Search(context.Background(), AuditLogsSearchInput{Start: "2026-07-01", Limit: 100}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "No audit log entries found") +} + +func TestAuditLogsSearchPropagatesAPIError(t *testing.T) { + fake := &FakeAuditLogsService{ + ListAutoPagingFunc: func(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry] { + return pagination.NewPageTokenPaginationAutoPager[kernel.AuditLogEntry](nil, errors.New("boom")) + }, + } + c := AuditLogsCmd{auditLogs: fake} + + err := c.Search(context.Background(), AuditLogsSearchInput{Start: "2026-07-01", Limit: 100}) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +}