diff --git a/cmd/kosli/apiKey.go b/cmd/kosli/apiKey.go new file mode 100644 index 000000000..ffc155051 --- /dev/null +++ b/cmd/kosli/apiKey.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "time" +) + +// apiKeyResponse models the JSON returned by the create and rotate endpoints. +// The key value is only ever returned once, at creation/rotation time. +type apiKeyResponse struct { + Id string `json:"id"` + Key string `json:"key"` + Description string `json:"description"` + CreatedAt float64 `json:"created_at"` + ExpiresAt float64 `json:"expires_at"` + GracePeriodExpiresAt float64 `json:"grace_period_expires_at,omitempty"` +} + +// apiKeyMetadata models a single entry returned by the list endpoint. The list +// endpoint returns metadata only — the secret key value is never included. +type apiKeyMetadata struct { + Id string `json:"id"` + Description string `json:"description"` + CreatedAt float64 `json:"created_at"` + ExpiresAt float64 `json:"expires_at"` + LastUsedAt float64 `json:"last_used_at"` +} + +// parseExpiresAt converts a user-supplied --expires-at value into a Unix +// (epoch-second) timestamp. It accepts a bare epoch integer, or one of the +// date/time layouts below (interpreted as UTC). An empty string returns 0. +func parseExpiresAt(value string) (int64, error) { + if value == "" { + return 0, nil + } + + if epoch, err := strconv.ParseInt(value, 10, 64); err == nil { + return epoch, nil + } + + formats := []string{ + "2006-1-2", + "2006-1-2 15:04:05", + time.RFC3339, + } + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return t.UTC().Unix(), nil + } + } + + return 0, fmt.Errorf("invalid --expires-at value %q: expected an epoch timestamp or a date like '2006-01-02', '2006-01-02 15:04:05', or an RFC3339 timestamp", value) +} + +// printApiKeyAsTable renders a single api key (the create response) as a table. +func printApiKeyAsTable(raw string, out io.Writer, page int) error { + var key apiKeyResponse + if err := json.Unmarshal([]byte(raw), &key); err != nil { + return err + } + + rows, err := apiKeyTableRows(key) + if err != nil { + return err + } + tabFormattedPrint(out, []string{}, rows) + return nil +} + +// printApiKeysAsTable renders one or more api keys (the rotate response) as +// table blocks separated by a blank line. +func printApiKeysAsTable(raw string, out io.Writer, page int) error { + var keys []apiKeyResponse + if err := json.Unmarshal([]byte(raw), &keys); err != nil { + return err + } + + for i, key := range keys { + if i > 0 { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + rows, err := apiKeyTableRows(key) + if err != nil { + return err + } + tabFormattedPrint(out, []string{}, rows) + } + return nil +} + +// optionalTimestamp formats an epoch timestamp, returning "N/A" when it is +// unset (nil, or a zero value meaning "never"/"not set"). +func optionalTimestamp(epoch interface{}) (string, error) { + switch v := epoch.(type) { + case nil: + return "N/A", nil + case float64: + if v == 0 { + return "N/A", nil + } + case int64: + if v == 0 { + return "N/A", nil + } + } + return formattedTimestamp(epoch, false) +} + +// apiKeyTableRows builds the key:value rows describing a single api key. +func apiKeyTableRows(key apiKeyResponse) ([]string, error) { + createdAt, err := formattedTimestamp(key.CreatedAt, false) + if err != nil { + return nil, err + } + expiresAt, err := optionalTimestamp(key.ExpiresAt) + if err != nil { + return nil, err + } + + rows := []string{} + rows = append(rows, fmt.Sprintf("ID:\t%s", key.Id)) + rows = append(rows, fmt.Sprintf("Key:\t%s", key.Key)) + rows = append(rows, fmt.Sprintf("Description:\t%s", key.Description)) + rows = append(rows, fmt.Sprintf("Created At:\t%s", createdAt)) + rows = append(rows, fmt.Sprintf("Expires At:\t%s", expiresAt)) + if key.GracePeriodExpiresAt != 0 { + gracePeriodExpiresAt, err := formattedTimestamp(key.GracePeriodExpiresAt, false) + if err != nil { + return nil, err + } + rows = append(rows, fmt.Sprintf("Old Key Valid Until:\t%s", gracePeriodExpiresAt)) + } + return rows, nil +} diff --git a/cmd/kosli/apiKey_test.go b/cmd/kosli/apiKey_test.go new file mode 100644 index 000000000..2351e045c --- /dev/null +++ b/cmd/kosli/apiKey_test.go @@ -0,0 +1,452 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/maxcnunes/httpfake" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// apiKeyFixture reads an api-key response fixture from testdata/service-account. +// The fixtures hold the canonical API response bodies so the response contract +// lives in one place (see the README there). +func apiKeyFixture(t *testing.T, name string) string { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "service-account", name)) + require.NoError(t, err, "failed to read fixture %s", name) + return string(body) +} + +func TestPrintApiKeyAsTable(t *testing.T) { + // The API returns timestamps as floating-point epoch seconds (with a + // fractional part), so the response struct/table rendering must accept them. + raw := `{"id":"key-1","key":"sk_secret_value","description":"ci key","created_at":1780584129.6878593,"expires_at":0,"grace_period_expires_at":1780670529.5}` + + var buf bytes.Buffer + err := printApiKeyAsTable(raw, &buf, 0) + require.NoError(t, err) + + out := buf.String() + require.Contains(t, out, "key-1") + require.Contains(t, out, "sk_secret_value") + require.Contains(t, out, "ci key") + require.Contains(t, out, "Old Key Valid Until") + // expires_at of 0 means "no expiry" and must render as N/A, not epoch zero + require.Regexp(t, `Expires At:\s+N/A`, out) + require.NotContains(t, out, "1970") +} + +func TestPrintApiKeysAsTable(t *testing.T) { + // The rotate command aggregates one or more rotated keys into a JSON array. + raw := `[{"id":"key-1","key":"sk_one","description":"first","created_at":1780584129.5,"expires_at":0},` + + `{"id":"key-2","key":"sk_two","description":"second","created_at":1780584130.5,"expires_at":0}]` + + var buf bytes.Buffer + err := printApiKeysAsTable(raw, &buf, 0) + require.NoError(t, err) + + out := buf.String() + require.Contains(t, out, "key-1") + require.Contains(t, out, "sk_one") + require.Contains(t, out, "key-2") + require.Contains(t, out, "sk_two") +} + +func TestPrintApiKeysListAsTable(t *testing.T) { + // The list endpoint returns key metadata only (no secret key value). + raw := `[{"id":"key-1","description":"first","created_at":1780584129.5,"expires_at":0,"last_used_at":0},` + + `{"id":"key-2","description":"second","created_at":1780584130.5,"expires_at":0,"last_used_at":0}]` + + var buf bytes.Buffer + err := printApiKeysListAsTable(raw, &buf, 0) + require.NoError(t, err) + + out := buf.String() + for _, want := range []string{"ID", "DESCRIPTION", "CREATED", "EXPIRES", "LAST USED", "key-1", "first", "key-2", "second"} { + require.Contains(t, out, want) + } + // expires_at and last_used_at of 0 must render as N/A, not epoch zero (1970) + require.Contains(t, out, "N/A") + require.NotContains(t, out, "1970") +} + +func TestParseExpiresAt(t *testing.T) { + tests := []struct { + name string + input string + want int64 + wantErr bool + }{ + {name: "empty returns zero", input: "", want: 0}, + {name: "bare epoch is passed through", input: "1798675200", want: 1798675200}, + {name: "date only is parsed as UTC midnight", input: "2026-06-04", want: time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC).Unix()}, + {name: "date with unpadded day/month is parsed", input: "2026-6-5", want: time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).Unix()}, + {name: "date and time is parsed as UTC", input: "2026-06-04 15:04:05", want: time.Date(2026, 6, 4, 15, 4, 5, 0, time.UTC).Unix()}, + {name: "RFC3339 is parsed", input: "2026-06-04T15:04:05Z", want: time.Date(2026, 6, 4, 15, 4, 5, 0, time.UTC).Unix()}, + {name: "invalid value errors", input: "not-a-date", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseExpiresAt(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +// Define the suite, and absorb the built-in basic suite functionality from testify. +type ApiKeyCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *ApiKeyCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *ApiKeyCommandTestSuite) TestCreateApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "create builds the right url and payload (dry-run)", + cmd: "create api-key --service-account test-sa --description 'ci key' --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user/test-sa/api-keys.*"description": "ci key"`, + }, + { + wantError: false, + name: "create with a date --expires-at converts to an epoch timestamp (dry-run)", + cmd: "create api-key --service-account test-sa --description 'ci key' --expires-at 2026-12-31 --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)"description": "ci key".*"expires_at": 1798675200`, + }, + { + wantError: false, + name: "the api-key alias (ak) and -s shorthand work", + cmd: "create ak -s test-sa --description 'ci key' --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys`, + }, + { + wantError: true, + name: "create fails when --service-account is missing", + cmd: "create api-key --description 'ci key'" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"service-account\" not set\n", + }, + { + wantError: true, + name: "create fails when --description is missing", + cmd: "create api-key --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"description\" not set\n", + }, + { + wantError: true, + name: "create fails with an invalid --expires-at value", + cmd: "create api-key --service-account test-sa --description 'ci key' --expires-at not-a-date --dry-run" + suite.defaultKosliArguments, + goldenRegex: `Error: invalid --expires-at value`, + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ApiKeyCommandTestSuite) TestRotateApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "rotate without --grace-period-hours sends an empty payload (server owns the default)", + cmd: "rotate api-key key-123 --service-account test-sa --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123/rotate.*real run:\s*\{\}`, + }, + { + wantError: false, + name: "rotate honours a custom --grace-period-hours (dry-run)", + cmd: "rotate api-key key-123 --service-account test-sa --grace-period-hours 48 --dry-run" + suite.defaultKosliArguments, + goldenRegex: `"grace_period_hours": 48`, + }, + { + wantError: false, + name: "the api-key alias (ak) and -s shorthand work", + cmd: "rotate ak key-123 -s test-sa --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123/rotate`, + }, + { + wantError: false, + name: "the -g and -e shorthands work (dry-run)", + cmd: "rotate ak key-123 -s test-sa -g 1 -e 2026-6-5 --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)"grace_period_hours": 1.*"expires_at": 1780617600`, + }, + { + wantError: false, + name: "rotate accepts multiple KEY-IDs (dry-run)", + cmd: "rotate api-key key-1 key-2 --service-account test-sa --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)api-keys/key-1/rotate.*api-keys/key-2/rotate`, + }, + { + wantError: true, + name: "rotate fails when KEY-ID argument is missing", + cmd: "rotate api-key --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: requires at least 1 arg(s), only received 0\n", + }, + { + wantError: true, + name: "rotate fails when --service-account is missing", + cmd: "rotate api-key key-123" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"service-account\" not set\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "delete without confirmation (empty stdin) is cancelled and makes no call", + cmd: "delete api-key key-123 --service-account test-sa" + suite.defaultKosliArguments, + golden: "Are you sure you want to delete API key(s) key-123 for service account test-sa? [y/N]\ndeletion of API key(s) key-123 was cancelled\n", + }, + { + wantError: false, + name: "delete accepts multiple KEY-IDs (dry-run)", + cmd: "delete api-key key-1 key-2 --service-account test-sa --assume-yes --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)api-keys/key-1.*api-keys/key-2`, + }, + { + wantError: false, + name: "delete with --assume-yes and --dry-run builds the right url", + cmd: "delete api-key key-123 --service-account test-sa --assume-yes --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123`, + }, + { + wantError: false, + name: "the --yes alias bypasses confirmation too", + cmd: "delete api-key key-123 --service-account test-sa --yes --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123`, + }, + { + wantError: false, + name: "the api-key alias (ak), -s and -y shorthands work", + cmd: "delete ak key-123 -s test-sa -y --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123`, + }, + { + wantError: true, + name: "delete fails when KEY-ID argument is missing", + cmd: "delete api-key --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: requires at least 1 arg(s), only received 0\n", + }, + { + wantError: true, + name: "delete fails when --service-account is missing", + cmd: "delete api-key key-123" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"service-account\" not set\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestApiKeysSuccessOutput stubs successful (2xx) API responses to verify that +// create/list/rotate render the server's response on the happy path. +func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "created_api_key.json")) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys"). + Reply(200). + BodyString(apiKeyFixture(suite.T(), "listed_api_keys.json")) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1/rotate"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json")) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: false, + name: "create prints the new key value", + cmd: "create api-key -s test-sa -d ci --output json" + args, + goldenRegex: `sk_created`, + }, + { + wantError: false, + name: "list prints the returned keys", + cmd: "list api-keys -s test-sa --output json" + args, + goldenRegex: `id-1`, + }, + { + wantError: false, + name: "rotate of multiple keys prints all rotated keys", + cmd: "rotate api-key k1 k2 -s test-sa --output json" + args, + goldenJson: []jsonCheck{ + {Path: "", Want: "length:2"}, + {Path: "[0].key", Want: "sk_one"}, + }, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestUpdatePartialFailure verifies that when one key in a multi-key rotate +// fails, the keys already rotated are still printed (their values are only +// returned once) before the error is surfaced. +func (suite *ApiKeyCommandTestSuite) TestUpdatePartialFailure() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1/rotate"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json")) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate"). + Reply(404). + BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "rotate prints already-rotated keys then surfaces the error", + cmd: "rotate api-key k1 k2 -s test-sa --output json" + args, + goldenRegex: `(?s)sk_one.*Error: API key not found`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestDeletePartialFailure verifies that when one key in a multi-key delete +// fails, the keys already deleted are reported (deletion is destructive and +// one-way) before the error is surfaced. +func (suite *ApiKeyCommandTestSuite) TestDeletePartialFailure() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1"). + Reply(200). + BodyString(apiKeyFixture(suite.T(), "revoke_success.json")) + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2"). + Reply(404). + BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "delete reports deleted keys before a later key fails", + cmd: "delete api-key k1 k2 -s test-sa --assume-yes" + args, + goldenRegex: `(?s)API key k1 for service account test-sa was deleted.*already deleted before this failure: k1.*failed to delete API key k2.*API key not found`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestDeleteApiKeyNotFound stubs the API with a 404 to verify that deleting a +// non-existing key surfaces the server's "API key not found" error instead of +// reporting success. +func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyNotFound() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key"). + Reply(404). + BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "delete surfaces a 404 from the API as an error", + cmd: "delete api-key missing-key --service-account test-sa --assume-yes" + args, + goldenRegex: `(?s)failed to delete API key missing-key.*API key not found`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestApiErrorsAreSurfaced stubs the API with 4xx responses to verify that +// create/rotate/list surface the server's error message instead of succeeding. +func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys"). + Reply(404). + BodyString(apiKeyFixture(suite.T(), "error_service_account_not_found.json")) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key/rotate"). + Reply(404). + BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json")) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys"). + Reply(403). + BodyString(apiKeyFixture(suite.T(), "error_forbidden.json")) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "create surfaces a 404 from the API as an error", + cmd: "create api-key --service-account missing-sa --description x" + args, + goldenRegex: `Error: Service account not found`, + }, + { + wantError: true, + name: "rotate surfaces a 404 from the API as an error", + cmd: "rotate api-key missing-key --service-account test-sa" + args, + goldenRegex: `Error: API key not found`, + }, + { + wantError: true, + name: "list surfaces a 403 from the API as an error", + cmd: "list api-keys --service-account missing-sa" + args, + goldenRegex: `Error: You don't have permission to access this resource`, + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ApiKeyCommandTestSuite) TestListApiKeysCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "list fails when --service-account is missing", + cmd: "list api-keys" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"service-account\" not set\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestApiKeyCommandTestSuite(t *testing.T) { + suite.Run(t, new(ApiKeyCommandTestSuite)) +} diff --git a/cmd/kosli/cli_utils.go b/cmd/kosli/cli_utils.go index 506958699..921021113 100644 --- a/cmd/kosli/cli_utils.go +++ b/cmd/kosli/cli_utils.go @@ -22,8 +22,55 @@ import ( cp "github.com/otiai10/copy" "github.com/spf13/cobra" "github.com/xeonx/timeago" + "golang.org/x/term" ) +// ANSI style codes for terminal output. Pass one or more of these to style(). +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + + // standard foreground colors + ansiBlack = "\033[30m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiBlue = "\033[34m" + ansiMagenta = "\033[35m" + ansiCyan = "\033[36m" + ansiWhite = "\033[37m" + + // bright foreground colors + ansiBrightBlack = "\033[90m" + ansiBrightRed = "\033[91m" + ansiBrightGreen = "\033[92m" + ansiBrightYellow = "\033[93m" + ansiBrightBlue = "\033[94m" + ansiBrightMagenta = "\033[95m" + ansiBrightCyan = "\033[96m" + ansiBrightWhite = "\033[97m" +) + +// styleEnabled reports whether ANSI styling should be applied to out: +// only when out is an interactive terminal and NO_COLOR is not set. +func styleEnabled(out io.Writer) bool { + if _, noColor := os.LookupEnv("NO_COLOR"); noColor { + return false + } + f, ok := out.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} + +// style wraps s in the given ANSI codes (bold, colors, ...) when styling is +// enabled for out, otherwise returns s unchanged. Multiple codes can be +// combined, e.g. style(out, s, ansiBold, ansiRed); a single reset closes them. +func style(out io.Writer, s string, codes ...string) string { + if len(codes) == 0 || !styleEnabled(out) { + return s + } + return strings.Join(codes, "") + s + ansiReset +} + const ( bitbucket = "Bitbucket" github = "Github" diff --git a/cmd/kosli/cli_utils_test.go b/cmd/kosli/cli_utils_test.go index edb7ff145..5b392595d 100644 --- a/cmd/kosli/cli_utils_test.go +++ b/cmd/kosli/cli_utils_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "os" "path/filepath" @@ -22,6 +23,28 @@ type CliUtilsTestSuite struct { // All methods that begin with "Test" are run as tests within a // suite. +func (suite *CliUtilsTestSuite) TestStyle() { + // every defined ANSI code + allCodes := []string{ + ansiReset, ansiBold, + ansiBlack, ansiRed, ansiGreen, ansiYellow, ansiBlue, ansiMagenta, ansiCyan, ansiWhite, + ansiBrightBlack, ansiBrightRed, ansiBrightGreen, ansiBrightYellow, ansiBrightBlue, + ansiBrightMagenta, ansiBrightCyan, ansiBrightWhite, + } + + // A bytes.Buffer is not a terminal, so styling must never be applied, + // regardless of which/how many codes are passed. + buf := new(bytes.Buffer) + require.False(suite.T(), styleEnabled(buf), "a non-*os.File writer is never a terminal") + for _, code := range allCodes { + require.Equal(suite.T(), "text", style(buf, "text", code), + "non-terminal output should never be styled") + } + require.Equal(suite.T(), "text", style(buf, "text"), "no codes returns the input unchanged") + require.Equal(suite.T(), "text", style(buf, "text", ansiBold, ansiYellow), + "combined codes are still plain on a non-terminal") +} + func (suite *CliUtilsTestSuite) TestWhichCI() { for _, t := range []struct { name string diff --git a/cmd/kosli/create.go b/cmd/kosli/create.go index da9ffe9b2..6ba942615 100644 --- a/cmd/kosli/create.go +++ b/cmd/kosli/create.go @@ -10,9 +10,10 @@ const createDesc = `All Kosli create commands.` func newCreateCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "create", - Short: createDesc, - Long: createDesc, + Use: "create", + Aliases: []string{"c", "cr"}, + Short: createDesc, + Long: createDesc, } // Add subcommands @@ -21,6 +22,7 @@ func newCreateCmd(out io.Writer) *cobra.Command { newCreateFlowCmd(out), newCreatePolicyCmd(out), newCreateAttestationTypeCmd(out), + newCreateApiKeyCmd(out), ) return cmd } diff --git a/cmd/kosli/createApiKey.go b/cmd/kosli/createApiKey.go new file mode 100644 index 000000000..04c3f032b --- /dev/null +++ b/cmd/kosli/createApiKey.go @@ -0,0 +1,113 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const createApiKeyShortDesc = `Create an API key for a service account.` + +const createApiKeyLongDesc = createApiKeyShortDesc + ` + +The key value is only returned once, at creation time, so make sure to store it securely.` + +const createApiKeyExample = ` +# create an API key for a service account: +kosli create api-key \ + --service-account yourServiceAccountName \ + --description "key for CI" \ + --api-token yourAPIToken \ + --org yourOrgName + +# create an API key that expires on a given date: +kosli create api-key \ + --service-account yourServiceAccountName \ + --description "key for CI" \ + --expires-at 2026-12-31 \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type createApiKeyOptions struct { + serviceAccount string + expiresAt string + output string + payload createApiKeyPayload +} + +type createApiKeyPayload struct { + Description string `json:"description"` + ExpiresAt *int64 `json:"expires_at,omitempty"` +} + +func newCreateApiKeyCmd(out io.Writer) *cobra.Command { + o := new(createApiKeyOptions) + cmd := &cobra.Command{ + Use: "api-key", + Aliases: []string{"ak"}, + Short: createApiKeyShortDesc, + Long: createApiKeyLongDesc, + Example: createApiKeyExample, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", apiKeyDescriptionFlag) + cmd.Flags().StringVarP(&o.expiresAt, "expires-at", "e", "", apiKeyExpiresAtFlag) + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"service-account", "description"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *createApiKeyOptions) run(out io.Writer, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys") + if err != nil { + return err + } + + if o.expiresAt != "" { + expiresAt, err := parseExpiresAt(o.expiresAt) + if err != nil { + return err + } + o.payload.ExpiresAt = &expiresAt + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Payload: o.payload, + DryRun: global.DryRun, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil || global.DryRun { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printApiKeyAsTable, + "json": output.PrintJson, + }) +} diff --git a/cmd/kosli/delete.go b/cmd/kosli/delete.go new file mode 100644 index 000000000..d66cd23d4 --- /dev/null +++ b/cmd/kosli/delete.go @@ -0,0 +1,25 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const deleteDesc = `All Kosli delete commands.` + +func newDeleteCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"d", "de", "rm", "remove"}, + Short: deleteDesc, + Long: deleteDesc, + } + + // Add subcommands + cmd.AddCommand( + newDeleteApiKeyCmd(out), + ) + + return cmd +} diff --git a/cmd/kosli/deleteApiKey.go b/cmd/kosli/deleteApiKey.go new file mode 100644 index 000000000..990ac723b --- /dev/null +++ b/cmd/kosli/deleteApiKey.go @@ -0,0 +1,153 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const deleteApiKeyShortDesc = `Delete one or more API keys for a service account.` + +const deleteApiKeyLongDesc = deleteApiKeyShortDesc + ` + +This permanently deletes the API key(s) identified by KEY-ID. Deletion is immediate and +cannot be undone. You are asked to confirm before the key is deleted; use +^--assume-yes^/^--yes^ to skip the confirmation prompt.` + +const deleteApiKeyExample = ` +# delete an API key for a service account (asks for confirmation): +kosli delete api-key yourApiKeyID \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# delete multiple API keys at once: +kosli delete api-key keyID1 keyID2 \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# delete an API key without confirmation: +kosli delete api-key yourApiKeyID \ + --service-account yourServiceAccountName \ + --assume-yes \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type deleteApiKeyOptions struct { + serviceAccount string + assumeYes bool +} + +func newDeleteApiKeyCmd(out io.Writer) *cobra.Command { + o := new(deleteApiKeyOptions) + cmd := &cobra.Command{ + Use: "api-key KEY-ID [KEY-ID...]", + Aliases: []string{"ak"}, + Short: deleteApiKeyShortDesc, + Long: deleteApiKeyLongDesc, + Example: deleteApiKeyExample, + Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, cmd.InOrStdin(), args) + }, + } + + cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + cmd.Flags().BoolVarP(&o.assumeYes, "assume-yes", "y", false, apiKeyAssumeYesFlag) + // keep --yes as a hidden alias for --assume-yes (bound to the same option) + cmd.Flags().BoolVar(&o.assumeYes, "yes", false, apiKeyAssumeYesFlag) + if f := cmd.Flags().Lookup("yes"); f != nil { + f.Hidden = true + } + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"service-account"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *deleteApiKeyOptions) run(out io.Writer, in io.Reader, args []string) error { + if !o.assumeYes && !global.DryRun { + confirmed, err := confirmApiKeyDeletion(args, o.serviceAccount, out, in) + if err != nil { + return err + } + if !confirmed { + logger.Info("deletion of API key(s) %s was cancelled", strings.Join(args, ", ")) + return nil + } + } + + // deletion is destructive and one-way: on any failure mid-batch, make clear + // which keys were already deleted before it (user-facing, styled green), while + // keeping the returned error plain (no ANSI). + reportAlreadyDeleted := func(i int) { + if i > 0 { + deleted := make([]string, i) + for j, k := range args[:i] { + deleted[j] = style(out, k, ansiBold, ansiGreen) + } + logger.Info("keys already deleted before this failure: %s", strings.Join(deleted, ", ")) + } + } + + for i, keyID := range args { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys", keyID) + if err != nil { + reportAlreadyDeleted(i) + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodDelete, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + if _, err := kosliClient.Do(reqParams); err != nil { + reportAlreadyDeleted(i) + return fmt.Errorf("failed to delete API key %s: %w", keyID, err) + } + if !global.DryRun { + logger.Info("API key %s for service account %s was deleted", style(out, keyID, ansiBold, ansiGreen), o.serviceAccount) + } + } + return nil +} + +// confirmApiKeyDeletion prompts the user to confirm deletion and returns true +// only when the answer is an affirmative "y"/"yes" (case-insensitive). +func confirmApiKeyDeletion(keyIDs []string, serviceAccount string, out io.Writer, in io.Reader) (bool, error) { + styledKeys := make([]string, len(keyIDs)) + for i, keyID := range keyIDs { + styledKeys[i] = style(out, keyID, ansiBold, ansiMagenta) + } + + logger.Info("Are you sure you want to delete API key(s) %s for service account %s? [y/N]", + strings.Join(styledKeys, ", "), style(out, serviceAccount, ansiBold, ansiGreen)) + + answer, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return false, err + } + + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index a47e69064..577f47d7c 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -27,7 +27,7 @@ func (o *listOptions) validate(cmd *cobra.Command) error { func newListCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Aliases: []string{"ls"}, + Aliases: []string{"l", "ls"}, Short: listDesc, Long: listDesc, } @@ -43,6 +43,7 @@ func newListCmd(out io.Writer) *cobra.Command { newListPoliciesCmd(out), newListAttestationTypesCmd(out), newListReposCmd(out), + newListApiKeysCmd(out), ) return cmd diff --git a/cmd/kosli/listApiKeys.go b/cmd/kosli/listApiKeys.go new file mode 100644 index 000000000..861b28bcb --- /dev/null +++ b/cmd/kosli/listApiKeys.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const listApiKeysShortDesc = `List API keys for a service account.` + +const listApiKeysLongDesc = listApiKeysShortDesc + ` + +Only the metadata of each active API key is returned; the key values themselves are never +listed (they are only shown once, at creation or rotation time).` + +const listApiKeysExample = ` +# list the API keys for a service account: +kosli list api-keys \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type listApiKeysOptions struct { + serviceAccount string + output string +} + +func newListApiKeysCmd(out io.Writer) *cobra.Command { + o := new(listApiKeysOptions) + cmd := &cobra.Command{ + Use: "api-keys", + Aliases: []string{"ak", "aks", "api-key"}, + Short: listApiKeysShortDesc, + Long: listApiKeysLongDesc, + Example: listApiKeysExample, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + + err := RequireFlags(cmd, []string{"service-account"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *listApiKeysOptions) run(out io.Writer, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys") + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: url, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printApiKeysListAsTable, + "json": output.PrintJson, + }) +} + +func printApiKeysListAsTable(raw string, out io.Writer, page int) error { + var keys []apiKeyMetadata + if err := json.Unmarshal([]byte(raw), &keys); err != nil { + return err + } + + if len(keys) == 0 { + logger.Info("No API keys were found.") + return nil + } + + header := []string{"ID", "DESCRIPTION", "CREATED", "EXPIRES", "LAST USED"} + rows := []string{} + for _, key := range keys { + createdAt, err := formattedTimestamp(key.CreatedAt, false) + if err != nil { + return err + } + expiresAt, err := optionalTimestamp(key.ExpiresAt) + if err != nil { + return err + } + lastUsedAt, err := optionalTimestamp(key.LastUsedAt) + if err != nil { + return err + } + + row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s", key.Id, key.Description, createdAt, expiresAt, lastUsedAt) + rows = append(rows, row) + } + tabFormattedPrint(out, header, rows) + return nil +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 7c3dd2602..c39d5d0c9 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -107,6 +107,11 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, templateArtifactName = "The name of the artifact in the yml template file." flowNamesFlag = "[defaulted] The comma separated list of Kosli flows. Defaults to all flows of the org." outputFlag = "[defaulted] The format of the output. Valid formats are: [table, json]." + serviceAccountNameFlag = "The name of the service account whose API keys are managed." + apiKeyDescriptionFlag = "A description for the API key." + apiKeyExpiresAtFlag = "[optional] When the API key expires. Accepts an epoch timestamp or a date like '2026-06-04', '2026-06-04 15:04:05', or an RFC3339 timestamp. Defaults to no expiry." + apiKeyGracePeriodHoursFlag = "[optional] How many hours the old API key remains valid after rotation, to allow time to update dependent systems. Defaults to the server-side value when not set." + apiKeyAssumeYesFlag = "[optional] Skip the confirmation prompt and delete the API key without asking. (alias: --yes)" environmentNameFlag = "The environment name." approvalEnvironmentNameFlag = "[defaulted] The environment the artifact is approved for. (defaults to all environments)" pageNumberFlag = "[defaulted] The page number of a response." @@ -414,6 +419,8 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newAttachPolicyCmd(out), newDetachPolicyCmd(out), newEvaluateCmd(out), + newDeleteCmd(out), + newRotateCmd(out), ) cobra.AddTemplateFunc("isBeta", isBeta) diff --git a/cmd/kosli/rotate.go b/cmd/kosli/rotate.go new file mode 100644 index 000000000..25038855f --- /dev/null +++ b/cmd/kosli/rotate.go @@ -0,0 +1,25 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const rotateDesc = `All Kosli rotate commands.` + +func newRotateCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "rotate", + Aliases: []string{"ro"}, + Short: rotateDesc, + Long: rotateDesc, + } + + // Add subcommands + cmd.AddCommand( + newRotateApiKeyCmd(out), + ) + + return cmd +} diff --git a/cmd/kosli/rotateApiKey.go b/cmd/kosli/rotateApiKey.go new file mode 100644 index 000000000..f82fcfac0 --- /dev/null +++ b/cmd/kosli/rotateApiKey.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const rotateApiKeyShortDesc = `Rotate one or more API keys for a service account.` + +const rotateApiKeyLongDesc = rotateApiKeyShortDesc + ` + +A new API key is generated immediately. The old key remains valid for a grace period to +allow time to update dependent systems; the length of that grace period is server-managed +unless overridden with ^--grace-period-hours^. The new key value is only returned once, so +make sure to store it securely.` + +const rotateApiKeyExample = ` +# rotate an API key for a service account: +kosli rotate api-key yourApiKeyID \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# rotate multiple API keys at once: +kosli rotate api-key keyID1 keyID2 \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# rotate an API key, keeping the old key valid for 48 hours: +kosli rotate api-key yourApiKeyID \ + --grace-period-hours 48 \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type rotateApiKeyOptions struct { + serviceAccount string + expiresAt string + gracePeriodHours int + gracePeriodHoursSet bool + output string + payload rotateApiKeyPayload +} + +type rotateApiKeyPayload struct { + GracePeriodHours *int `json:"grace_period_hours,omitempty"` + ExpiresAt *int64 `json:"expires_at,omitempty"` +} + +func newRotateApiKeyCmd(out io.Writer) *cobra.Command { + o := new(rotateApiKeyOptions) + cmd := &cobra.Command{ + Use: "api-key KEY-ID [KEY-ID...]", + Aliases: []string{"ak"}, + Short: rotateApiKeyShortDesc, + Long: rotateApiKeyLongDesc, + Example: rotateApiKeyExample, + Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.gracePeriodHoursSet = cmd.Flags().Changed("grace-period-hours") + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + cmd.Flags().StringVarP(&o.expiresAt, "expires-at", "e", "", apiKeyExpiresAtFlag) + cmd.Flags().IntVarP(&o.gracePeriodHours, "grace-period-hours", "g", 0, apiKeyGracePeriodHoursFlag) + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"service-account"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *rotateApiKeyOptions) run(out io.Writer, args []string) error { + // Only send grace_period_hours when the user explicitly set it; otherwise + // let the server apply its own default. + if o.gracePeriodHoursSet { + o.payload.GracePeriodHours = &o.gracePeriodHours + } + if o.expiresAt != "" { + expiresAt, err := parseExpiresAt(o.expiresAt) + if err != nil { + return err + } + o.payload.ExpiresAt = &expiresAt + } + + // Rotated key values are only returned once, so collect each successful + // response and print what we have even if a later key fails (rather than + // losing the new keys that were already rotated). + keys := make([]json.RawMessage, 0, len(args)) + var runErr error + for _, keyID := range args { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys", keyID, "rotate") + if err != nil { + runErr = err + break + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Payload: o.payload, + DryRun: global.DryRun, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + runErr = err + break + } + if !global.DryRun { + keys = append(keys, json.RawMessage(response.Body)) + } + } + + if !global.DryRun && len(keys) > 0 { + raw, err := json.Marshal(keys) + if err != nil { + return err + } + if err := output.FormattedPrint(string(raw), o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printApiKeysAsTable, + "json": output.PrintJson, + }); err != nil { + return err + } + } + + return runErr +} diff --git a/cmd/kosli/testdata/service-account/README.md b/cmd/kosli/testdata/service-account/README.md new file mode 100644 index 000000000..b1d13b514 --- /dev/null +++ b/cmd/kosli/testdata/service-account/README.md @@ -0,0 +1,21 @@ +# Service Account API-key response fixtures + +These JSON files are the canonical example response bodies returned by the +Service Account `api-keys` endpoints. The command tests in +`cmd/kosli/apiKey_test.go` stub the API (`httpfake`) with these fixtures +instead of inline strings, so the response contract lives in one place. + +| Fixture | Endpoint / response | +|---------|---------------------| +| `created_api_key.json` | `POST .../api-keys` → `201` (create) | +| `rotated_api_key.json` | `POST .../api-keys/{key_id}/rotate` → `201` (rotate; includes `grace_period_expires_at`) | +| `listed_api_keys.json` | `GET .../api-keys` → `200` (list) | +| `revoke_success.json` | `DELETE .../api-keys/{key_id}` → `200` (bare string) | +| `error_*.json` | error envelope `{ "message": string }` (`403`/`404`) | + +> **Note:** these fixtures only exercise CLI logic (flag parsing, output +> formatting, error handling). They do **not** verify the live API contract — +> if the API changes field names/types, the stubbed tests still pass. Keeping +> the bodies here makes it possible to validate them against the published +> OpenAPI schema in a separate step: +> https://app.kosli.com/api/v2/doc/ diff --git a/cmd/kosli/testdata/service-account/created_api_key.json b/cmd/kosli/testdata/service-account/created_api_key.json new file mode 100644 index 000000000..72a7a4194 --- /dev/null +++ b/cmd/kosli/testdata/service-account/created_api_key.json @@ -0,0 +1 @@ +{"id":"id-1","key":"sk_created","description":"ci","created_at":1780584129.5,"expires_at":0} diff --git a/cmd/kosli/testdata/service-account/error_api_key_not_found.json b/cmd/kosli/testdata/service-account/error_api_key_not_found.json new file mode 100644 index 000000000..4a5acd2d1 --- /dev/null +++ b/cmd/kosli/testdata/service-account/error_api_key_not_found.json @@ -0,0 +1 @@ +{"message":"API key not found"} diff --git a/cmd/kosli/testdata/service-account/error_forbidden.json b/cmd/kosli/testdata/service-account/error_forbidden.json new file mode 100644 index 000000000..bfe4115c4 --- /dev/null +++ b/cmd/kosli/testdata/service-account/error_forbidden.json @@ -0,0 +1 @@ +{"message":"You don't have permission to access this resource"} diff --git a/cmd/kosli/testdata/service-account/error_service_account_not_found.json b/cmd/kosli/testdata/service-account/error_service_account_not_found.json new file mode 100644 index 000000000..be7ac6c55 --- /dev/null +++ b/cmd/kosli/testdata/service-account/error_service_account_not_found.json @@ -0,0 +1 @@ +{"message":"Service account not found"} diff --git a/cmd/kosli/testdata/service-account/listed_api_keys.json b/cmd/kosli/testdata/service-account/listed_api_keys.json new file mode 100644 index 000000000..4b24137ac --- /dev/null +++ b/cmd/kosli/testdata/service-account/listed_api_keys.json @@ -0,0 +1 @@ +[{"id":"id-1","description":"ci","created_at":1780584129.5,"expires_at":0,"last_used_at":0}] diff --git a/cmd/kosli/testdata/service-account/revoke_success.json b/cmd/kosli/testdata/service-account/revoke_success.json new file mode 100644 index 000000000..902198e30 --- /dev/null +++ b/cmd/kosli/testdata/service-account/revoke_success.json @@ -0,0 +1 @@ +"revoked" diff --git a/cmd/kosli/testdata/service-account/rotated_api_key.json b/cmd/kosli/testdata/service-account/rotated_api_key.json new file mode 100644 index 000000000..eafe68de1 --- /dev/null +++ b/cmd/kosli/testdata/service-account/rotated_api_key.json @@ -0,0 +1 @@ +{"id":"k1","key":"sk_one","description":"one","created_at":1780584129.5,"expires_at":0,"grace_period_expires_at":1780584130.5} diff --git a/go.mod b/go.mod index c0eb1b77d..0578c8774 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,20 @@ go 1.26.4 require ( cloud.google.com/go/run v1.21.0 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/andygrunwald/go-jira v1.17.0 - github.com/aws/aws-sdk-go-v2 v1.41.11 - github.com/aws/aws-sdk-go-v2/config v1.32.22 - github.com/aws/aws-sdk-go-v2/credentials v1.19.21 - github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.5 - github.com/aws/aws-sdk-go-v2/service/ecs v1.82.2 - github.com/aws/aws-sdk-go-v2/service/lambda v1.92.1 - github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1 - github.com/aws/smithy-go v1.27.0 + github.com/aws/aws-sdk-go-v2 v1.41.12 + github.com/aws/aws-sdk-go-v2/config v1.32.23 + github.com/aws/aws-sdk-go-v2/credentials v1.19.22 + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.8 + github.com/aws/aws-sdk-go-v2/service/ecs v1.82.3 + github.com/aws/aws-sdk-go-v2/service/lambda v1.92.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2 + github.com/aws/smithy-go v1.27.2 github.com/containerd/errdefs v1.0.0 github.com/containers/image/v5 v5.36.2 github.com/go-git/go-billy/v5 v5.9.0 @@ -32,7 +32,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 - github.com/open-policy-agent/opa v1.17.0 + github.com/open-policy-agent/opa v1.17.1 github.com/otiai10/copy v1.14.1 github.com/owenrumney/go-sarif/v2 v2.3.3 github.com/pkg/errors v0.9.1 @@ -76,19 +76,19 @@ require ( github.com/ProtonMail/go-crypto v1.2.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.1.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.43.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.28 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index 1585e40fe..b80aaaebc 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/run v1.21.0 h1:gQJUy0//XNXXpiZs42KlbLPhbycxbpS2QymGRFlPXv4= cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= @@ -58,48 +58,48 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw= -github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU= -github.com/aws/aws-sdk-go-v2/config v1.32.22 h1:Vfvp7+fYKsVCADcWOEllqEV47aIBXhNchvyDFu1B5fY= -github.com/aws/aws-sdk-go-v2/config v1.32.22/go.mod h1:0+H+0nPKbvWltf5vSIGkApv+hGbaQ4FfwTjGIYQREcw= -github.com/aws/aws-sdk-go-v2/credentials v1.19.21 h1:0+HscFXtNa4+3buV4IlG6v5lnOdzi5TrpicFGjKHgh4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.21/go.mod h1:UE8+9t5zudFwu5k5ShC1PKArVEdOkQQdCXIHQAVNUcU= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27 h1:BEfN1sjtiKEdikRDxYkjZNE4tyvw/YbGWCbl3xDZgRw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27/go.mod h1:ISGSFNbOHRS+JV/17yStzRTPBUHHqF92kCpRLLyH3Nk= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.5 h1:act7IL6depeMPlVfOR1l7ZsBYr/H35Wj//ZyOxm8XHg= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.5/go.mod h1:BBuPFKqCoiWtIVfFgRUeTj3AJzh924pHW0J6WrWuCG8= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 h1:eaS9vwQ5ym4Y9S6+G/K3d3lgZhxs9Sldcn/YS7cmdKY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28/go.mod h1:oTdbDr+BMs7gAYrNpD0LDTyqQfv6yOYgTDv46+xbwFY= -github.com/aws/aws-sdk-go-v2/service/ecs v1.82.2 h1:5+R0WdILivVBjS2XWGolf2xNLrAobmRJapbuY80rM0M= -github.com/aws/aws-sdk-go-v2/service/ecs v1.82.2/go.mod h1:NALx5A0EmoS9guqXCX15K74AIKaVV4UK0c1Kp8WAizA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 h1:rFSsqDfCMPAmG70JOsYqFZCHXkyatoGa1K4YEt/BggQ= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11/go.mod h1:XG68qW+YLLFH0vnSDCou43Cgj5TeAG83O5NRSJgt04Y= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20 h1:yt2fjgev3Hqm33zPw0ZWtki3sZ0SLcr+PkuvXDAAf/8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20/go.mod h1:wnPjCjPJ6x5GBhrER8f0QakaQ2LokfhCVYxmAZBpPjY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 h1:2/pUo42hhVmQcM21ttZoBOLHQymyUH8qEnZGTIuGBT8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27/go.mod h1:p7hwgbwompjCRNTdB3ytlldddNt1rDBgVVMqWEVG1II= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27 h1:JEXSW4wztrl1MoL5EMvJMO7lc/TRZloztrJKNl96SW8= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27/go.mod h1:8eL+YgEqy6IYqjwW6PG0Ubn59a2xtCzbz7Pi18JBu04= -github.com/aws/aws-sdk-go-v2/service/lambda v1.92.1 h1:WXCHC8eGCieCTxSJ0CsZc4yRK8PiUKPZm3un8L4Eu6I= -github.com/aws/aws-sdk-go-v2/service/lambda v1.92.1/go.mod h1:RQmeW7HZrBCTGhwnmRTEw29G+Fhao0pkhrlwakALzbs= -github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1 h1:WkX5IXwcxgO/WPTvhEqoSW2L1GB1OyIxk0vuzzdTftc= -github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1/go.mod h1:9Q9ZHyiTItraw8BXpO48pk398Mou0YCSI+xvFcaGgxU= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.3 h1:t6U7sowMfOjTeZXtDOtgEJXsoJyX4MDag+sfWGwUM9M= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.3/go.mod h1:WhO1EH3phjFWValQDsExaxncgEWJsHeoTvuyQAj3jwU= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.1 h1:TUV8oytPCX1PfVgZn0N8/sPZx7T0YasaMCBHox1erlw= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.1/go.mod h1:tEL1hqCrkgwrDVL04HuLxz1SLUXdh+4kKhWv1pXKeiY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4 h1:p9+Fizo2sUB6wI5Yb3K5lmykQAGs5JrKLBV/me6613Y= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4/go.mod h1:0x10Wy0dVS4Gn552xhHY5th2QdYpfJf44EsfyYGV194= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.1 h1:r/vUkpLilfCA3sxbRnkHbJejaoVHEdj4FEhv+Zva4DU= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.1/go.mod h1:t01JURC8Fe5M+7R1K0vzIZ2NT04HqvZR+FjlHrHDT2A= -github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus= -github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.12 h1:DIKX2c31ekm9RA2D9FBj1EWXx++9AdAqRw+e78Tq2Ck= +github.com/aws/aws-sdk-go-v2 v1.41.12/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.23 h1:PYDobtcsJXK6bQe9I8RQk6s19Bz3xa3xRU08Hy1Em3Y= +github.com/aws/aws-sdk-go-v2/config v1.32.23/go.mod h1:QID4dqUQVgEOYPKsPWd1sNWCCR2c5g7o3jeEtIXPOZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.22 h1:SHfH6wyPsEgG7fVsi5rQxWEt7tuIcN2PGhb1mTFv6tE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.22/go.mod h1:54nO8lKD4aQPOntM/VTWjnR+DYzTwx0YkSMZMhAgewQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 h1:b+kcDejJrXc30zU/w8Tc9klISwaO5wh+6T0sMBdDoHM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28/go.mod h1:LnI62O9GnSv6GcuLXxOYqlq0C8EmxMcgnF6m7LdYuOY= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.8 h1:vooR0jc+VLHDkM97Q82ml82WAOl1aA3jX/Dn6Yb19bc= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.8/go.mod h1:9A4usyBencYSi5/18mRjSDe0LHFarrOmyWifz4Om4bY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 h1:Xf2j7NdVcUKomlZ4iihOP4AZ3Fzlr8h4yKpXeP+OFPg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28/go.mod h1:O8cDo1dW63jU7ki//kRe1z+tLGcpnD1jrouitsQddDw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 h1:KqIfN9kpkKkcBqBbNpNGTIrXO6ExTUvFKvXkC+YAzVo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28/go.mod h1:uxtQiKvLtNS4iXVsH2McVD/ls8FKN/uUhe1hGxPjrw0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 h1:VkE9FuzTQVjBBrnj4+oCdxCLFIz7aqLYKUCjtvxVcOs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29/go.mod h1:H32Z2Qth9b+9LqjyBsCnozMQ8H2N7YBUDVXwbs0iggg= +github.com/aws/aws-sdk-go-v2/service/ecs v1.82.3 h1:0h+khLoWaCU9JE6ZeQrQ9xZS+EnrjISQlnt/xtxEL/I= +github.com/aws/aws-sdk-go-v2/service/ecs v1.82.3/go.mod h1:st/PIQyL6fJSoYTxijbYIKZjPpPP2JiUdcUf++xX0Kk= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.21 h1:FsZxbPiVgEHYofziwfylouMki8b1Z7mI4CMU/7bhwBA= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.21/go.mod h1:Mmm30OV+JLXYQUcbSd84THnv3P5JtjhVDujLwMqRG0U= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 h1:axj4mEDletwKmTm/9jR+DkIMmCfcn5vE4jBMAAN+3Vg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28/go.mod h1:3Aaz69M0jqfSHLKqxgolgUBFT4hpwSNc7DzC95orEi8= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.28 h1:li8rTZAAb22g4UsxbjwMdaNVWbgVcDzPqI7nDTI+mF4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.28/go.mod h1:/brXioSGIMEdcBFoubpSdmighSVp6poP+mma/wB7iHA= +github.com/aws/aws-sdk-go-v2/service/lambda v1.92.2 h1:MeFQKmFAYIoXH9+oXgaOPeM7pQ5LATWJyJykj9lsHEo= +github.com/aws/aws-sdk-go-v2/service/lambda v1.92.2/go.mod h1:Clrn/3iAEhtDG9HJn1HYp+arjui7NU6jrcx0BEd+bk0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2 h1:b4ikkRk22T4xYkEgaWc3Voe+3xbt5YbbFhNehOWyUiY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2/go.mod h1:Gp7eHZ0NZ8ZK5RXpoIUp/C8OeAmJqpCgdwEK1D/QOek= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 h1:YcpVyIPLCbiypN6KSphijN5fC7DDjX114SqA7prnnxg= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.4/go.mod h1:5ZICS++oFTRPfa1GsBqFDWX/8WamZ/QQOcCzIuU/zLw= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 h1:ySNWu7TPmj5fKFIa1GYvX+Ddxd5ccruqC20aMNuyWDM= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.2/go.mod h1:A+U9luAOwFeB1kseyWCITVg7/NntoPebCFR9pQ4ch9A= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 h1:KSzGGqfk39O+WU3OEyYbx6F7sLDQCqxlOJ+2IksfK6U= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5/go.mod h1:ATs88lXDeQB6CZOgQ5BIl9JbYS+EsCWUSDyff6L/oVo= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 h1:RTO7mmGyedgnNmcPh3yQizNfc6GKoV5iqfdJavuf9vw= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.2/go.mod h1:fBhUZXDin9YYqhcpOMjIcpdik25rVwWyxLdPH1RZd9s= +github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= +github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -389,8 +389,8 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/open-policy-agent/opa v1.17.0 h1:TMm6bCyb3CEL4wjXsXn1d/kBSBbjF+5sEIyzQvbJiEw= -github.com/open-policy-agent/opa v1.17.0/go.mod h1:lcuZYSlqQpXFzsA6EJCELmfR5+nNOpZYX+eo7xaIIlk= +github.com/open-policy-agent/opa v1.17.1 h1:wO0MOux/VCqY41aVAD6Toe1p3A7O7DlRZ1RHmYSpoS8= +github.com/open-policy-agent/opa v1.17.1/go.mod h1:lcuZYSlqQpXFzsA6EJCELmfR5+nNOpZYX+eo7xaIIlk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=