|
| 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