-
Notifications
You must be signed in to change notification settings - Fork 11
Add audit-logs search command #193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.