Skip to content

Commit 7bf3a66

Browse files
yummybombclaude
andcommitted
Add audit-logs search command
New `kernel audit-logs search` wrapping GET /audit-logs: 24h default window (RFC3339 or YYYY-MM-DD bounds), filter flags mapping to the API, GET requests excluded by default with --include-get to opt back in and --exclude-method stacking on top, table/json output, and page-token pagination capped by --limit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b5b726e commit 7bf3a66

2 files changed

Lines changed: 523 additions & 0 deletions

File tree

cmd/audit_logs.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
"time"
9+
10+
"github.com/kernel/cli/pkg/util"
11+
"github.com/kernel/kernel-go-sdk"
12+
"github.com/kernel/kernel-go-sdk/option"
13+
"github.com/kernel/kernel-go-sdk/packages/pagination"
14+
"github.com/pterm/pterm"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
type AuditLogsService interface {
19+
ListAutoPaging(ctx context.Context, query kernel.AuditLogListParams, opts ...option.RequestOption) *pagination.PageTokenPaginationAutoPager[kernel.AuditLogEntry]
20+
}
21+
22+
type AuditLogsCmd struct {
23+
auditLogs AuditLogsService
24+
}
25+
26+
type AuditLogsSearchInput struct {
27+
Start string
28+
End string
29+
Search string
30+
Method string
31+
ExcludeMethod string
32+
IncludeGet bool
33+
Service string
34+
AuthStrategy string
35+
UserIDs []string
36+
Limit int
37+
Output string
38+
}
39+
40+
const auditLogsMaxPageSize = 100
41+
42+
func (c AuditLogsCmd) Search(ctx context.Context, in AuditLogsSearchInput) error {
43+
if err := validateJSONOutput(in.Output); err != nil {
44+
return err
45+
}
46+
if in.Limit < 1 {
47+
return fmt.Errorf("--limit must be positive")
48+
}
49+
var err error
50+
start := time.Now().UTC().Add(-24 * time.Hour)
51+
if in.Start != "" {
52+
start, _, err = parseAuditLogTime(in.Start)
53+
if err != nil {
54+
return fmt.Errorf("--start: %w", err)
55+
}
56+
}
57+
end := time.Now().UTC()
58+
if in.End != "" {
59+
var dateOnly bool
60+
end, dateOnly, err = parseAuditLogTime(in.End)
61+
if err != nil {
62+
return fmt.Errorf("--end: %w", err)
63+
}
64+
// End is exclusive; a date-only end means the whole of that day.
65+
if dateOnly {
66+
end = end.Add(24 * time.Hour)
67+
}
68+
}
69+
if !start.Before(end) {
70+
return fmt.Errorf("--start must be before --end")
71+
}
72+
73+
params := kernel.AuditLogListParams{Start: start, End: end}
74+
if in.Search != "" {
75+
params.Search = kernel.String(in.Search)
76+
}
77+
if in.Method != "" {
78+
params.Method = kernel.String(in.Method)
79+
}
80+
// The API accepts a single exclude_method. When the default GET exclusion
81+
// stacks with a user-provided one, GET goes server-side (it drops the most
82+
// rows) and the user's method is filtered client-side.
83+
excludeGetByDefault := in.Method == "" && !in.IncludeGet
84+
var clientExclude string
85+
switch {
86+
case in.ExcludeMethod == "":
87+
if excludeGetByDefault {
88+
params.ExcludeMethod = kernel.String("GET")
89+
}
90+
case excludeGetByDefault && !strings.EqualFold(in.ExcludeMethod, "GET"):
91+
params.ExcludeMethod = kernel.String("GET")
92+
clientExclude = in.ExcludeMethod
93+
default:
94+
params.ExcludeMethod = kernel.String(in.ExcludeMethod)
95+
}
96+
if in.Service != "" {
97+
params.Service = kernel.String(in.Service)
98+
}
99+
if in.AuthStrategy != "" {
100+
params.AuthStrategy = kernel.String(in.AuthStrategy)
101+
}
102+
if len(in.UserIDs) > 0 {
103+
params.SearchUserID = in.UserIDs
104+
}
105+
pageSize := auditLogsMaxPageSize
106+
if in.Limit < pageSize {
107+
pageSize = in.Limit
108+
}
109+
params.Limit = kernel.Int(int64(pageSize))
110+
111+
entries := make([]kernel.AuditLogEntry, 0)
112+
hasMore := false
113+
pager := c.auditLogs.ListAutoPaging(ctx, params)
114+
for pager.Next() {
115+
entry := pager.Current()
116+
if clientExclude != "" && strings.EqualFold(entry.Method, clientExclude) {
117+
continue
118+
}
119+
entries = append(entries, entry)
120+
if len(entries) >= in.Limit {
121+
hasMore = pager.Next()
122+
break
123+
}
124+
}
125+
if err := pager.Err(); err != nil {
126+
return util.CleanedUpSdkError{Err: err}
127+
}
128+
129+
if in.Output == "json" {
130+
return util.PrintPrettyJSONSlice(entries)
131+
}
132+
133+
if len(entries) == 0 {
134+
pterm.Info.Println("No audit log entries found")
135+
return nil
136+
}
137+
138+
table := pterm.TableData{{"Timestamp", "Method", "Status", "Path", "User", "Duration (ms)", "Client IP"}}
139+
for _, entry := range entries {
140+
table = append(table, []string{
141+
util.FormatLocal(entry.Timestamp),
142+
entry.Method,
143+
strconv.FormatInt(entry.Status, 10),
144+
entry.Path,
145+
formatAuditLogUser(entry),
146+
strconv.FormatInt(entry.DurationMs, 10),
147+
entry.ClientIP,
148+
})
149+
}
150+
PrintTableNoPad(table, true)
151+
152+
if hasMore {
153+
pterm.Info.Printf("Showing first %d results; increase --limit or narrow the search window\n", in.Limit)
154+
}
155+
return nil
156+
}
157+
158+
func parseAuditLogTime(value string) (t time.Time, dateOnly bool, err error) {
159+
if t, err := time.Parse(time.RFC3339, value); err == nil {
160+
return t, false, nil
161+
}
162+
if t, err := time.Parse("2006-01-02", value); err == nil {
163+
return t, true, nil
164+
}
165+
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)
166+
}
167+
168+
func formatAuditLogUser(entry kernel.AuditLogEntry) string {
169+
if entry.Email != "" {
170+
return entry.Email
171+
}
172+
if entry.UserID != "" {
173+
return entry.UserID
174+
}
175+
return "-"
176+
}
177+
178+
func getAuditLogsHandler(cmd *cobra.Command) AuditLogsCmd {
179+
client := getKernelClient(cmd)
180+
return AuditLogsCmd{auditLogs: &client.AuditLogs}
181+
}
182+
183+
func runAuditLogsSearch(cmd *cobra.Command, args []string) error {
184+
c := getAuditLogsHandler(cmd)
185+
start, _ := cmd.Flags().GetString("start")
186+
end, _ := cmd.Flags().GetString("end")
187+
search, _ := cmd.Flags().GetString("search")
188+
method, _ := cmd.Flags().GetString("method")
189+
excludeMethod, _ := cmd.Flags().GetString("exclude-method")
190+
includeGet, _ := cmd.Flags().GetBool("include-get")
191+
service, _ := cmd.Flags().GetString("service")
192+
authStrategy, _ := cmd.Flags().GetString("auth-strategy")
193+
userIDs, _ := cmd.Flags().GetStringArray("user-id")
194+
limit, _ := cmd.Flags().GetInt("limit")
195+
output, _ := cmd.Flags().GetString("output")
196+
197+
return c.Search(cmd.Context(), AuditLogsSearchInput{
198+
Start: start,
199+
End: end,
200+
Search: search,
201+
Method: method,
202+
ExcludeMethod: excludeMethod,
203+
IncludeGet: includeGet,
204+
Service: service,
205+
AuthStrategy: authStrategy,
206+
UserIDs: userIDs,
207+
Limit: limit,
208+
Output: output,
209+
})
210+
}
211+
212+
var auditLogsCmd = &cobra.Command{
213+
Use: "audit-logs",
214+
Aliases: []string{"audit-log", "auditlogs", "auditlog"},
215+
Short: "Search audit logs",
216+
Run: func(cmd *cobra.Command, args []string) {
217+
_ = cmd.Help()
218+
},
219+
}
220+
221+
var auditLogsSearchCmd = &cobra.Command{
222+
Use: "search",
223+
Short: "Search audit logs within a time window",
224+
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.",
225+
Args: cobra.NoArgs,
226+
RunE: runAuditLogsSearch,
227+
}
228+
229+
func init() {
230+
addJSONOutputFlag(auditLogsSearchCmd)
231+
auditLogsSearchCmd.Flags().String("start", "", "Start of the search window, RFC3339 or YYYY-MM-DD (default: 24 hours ago)")
232+
auditLogsSearchCmd.Flags().String("end", "", "End of the search window, RFC3339 or YYYY-MM-DD inclusive (default: now)")
233+
auditLogsSearchCmd.Flags().String("search", "", "Free-text search")
234+
auditLogsSearchCmd.Flags().String("method", "", "Filter by HTTP method (e.g. GET)")
235+
auditLogsSearchCmd.Flags().String("exclude-method", "", "Exclude an HTTP method")
236+
auditLogsSearchCmd.Flags().Bool("include-get", false, "Include GET requests, which are excluded by default")
237+
auditLogsSearchCmd.Flags().String("service", "", "Filter by service")
238+
auditLogsSearchCmd.Flags().String("auth-strategy", "", "Filter by authentication strategy")
239+
auditLogsSearchCmd.Flags().StringArray("user-id", nil, "Filter by user ID (repeatable)")
240+
auditLogsSearchCmd.Flags().Int("limit", 100, "Maximum number of results to return")
241+
242+
auditLogsCmd.AddCommand(auditLogsSearchCmd)
243+
rootCmd.AddCommand(auditLogsCmd)
244+
}

0 commit comments

Comments
 (0)