From 4aa6ce0aeb879c68ea6a7caa0a0776b15d837f75 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 12:49:42 +0100 Subject: [PATCH 01/10] feat: add service-account api-keys commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `service-account` (alias `sa`) command group with an `api-keys` (alias `ak`) subgroup to manage Service Account API keys via the CLI: - `create` — create an API key (requires --service-account and --description; optional --expires-at) - `revoke` — revoke an API key by KEY-ID, with a confirmation prompt (--yes to skip) - `rotate` — rotate an API key by KEY-ID, with a configurable grace period - `list` — list a service account's API keys Closes #937 --- cmd/kosli/cli_utils.go | 47 ++++ cmd/kosli/cli_utils_test.go | 23 ++ cmd/kosli/createApiKey.go | 113 +++++++++ cmd/kosli/listApiKeys.go | 121 +++++++++ cmd/kosli/revokeApiKey.go | 142 +++++++++++ cmd/kosli/root.go | 6 + cmd/kosli/rotateApiKey.go | 133 ++++++++++ cmd/kosli/serviceAccount.go | 25 ++ cmd/kosli/serviceAccountApiKeys.go | 134 ++++++++++ cmd/kosli/serviceAccount_test.go | 381 +++++++++++++++++++++++++++++ 10 files changed, 1125 insertions(+) create mode 100644 cmd/kosli/createApiKey.go create mode 100644 cmd/kosli/listApiKeys.go create mode 100644 cmd/kosli/revokeApiKey.go create mode 100644 cmd/kosli/rotateApiKey.go create mode 100644 cmd/kosli/serviceAccount.go create mode 100644 cmd/kosli/serviceAccountApiKeys.go create mode 100644 cmd/kosli/serviceAccount_test.go 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/createApiKey.go b/cmd/kosli/createApiKey.go new file mode 100644 index 000000000..57e4f2e69 --- /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 service-account api-keys create \ + --service-account yourServiceAccountName \ + --description "key for CI" \ + --api-token yourAPIToken \ + --org yourOrgName + +# create an API key that expires on a given date: +kosli service-account api-keys create \ + --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: "create", + Aliases: []string{"c", "cr"}, + 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/listApiKeys.go b/cmd/kosli/listApiKeys.go new file mode 100644 index 000000000..729e1f64b --- /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 service-account api-keys list \ + --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: "list", + Aliases: []string{"l", "ls"}, + 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 []map[string]interface{} + 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["created_at"], false) + if err != nil { + return err + } + expiresAt, err := formattedTimestamp(key["expires_at"], false) + if err != nil { + return err + } + lastUsedAt, err := formattedTimestamp(key["last_used_at"], false) + 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/revokeApiKey.go b/cmd/kosli/revokeApiKey.go new file mode 100644 index 000000000..e8e30157f --- /dev/null +++ b/cmd/kosli/revokeApiKey.go @@ -0,0 +1,142 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const revokeApiKeyShortDesc = `Revoke an API key for a service account.` + +const revokeApiKeyLongDesc = revokeApiKeyShortDesc + ` + +This permanently revokes the API key identified by KEY-ID. You are asked to confirm +before the key is revoked; use ^--yes^ or ^--assume-yes^ to skip the confirmation prompt.` + +const revokeApiKeyExample = ` +# revoke an API key for a service account (asks for confirmation): +kosli service-account api-keys revoke yourApiKeyID \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# revoke multiple API keys at once: +kosli service-account api-keys revoke keyID1 keyID2 \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# revoke an API key without confirmation: +kosli service-account api-keys revoke yourApiKeyID \ + --service-account yourServiceAccountName \ + --assume-yes \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type revokeApiKeyOptions struct { + serviceAccount string + assumeYes bool +} + +func newRevokeApiKeyCmd(out io.Writer) *cobra.Command { + o := new(revokeApiKeyOptions) + cmd := &cobra.Command{ + Use: "revoke KEY-ID [KEY-ID...]", + Aliases: []string{"re", "del"}, + Short: revokeApiKeyShortDesc, + Long: revokeApiKeyLongDesc, + Example: revokeApiKeyExample, + 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, revokeApiKeyYesFlag) + // allow --yes as an alias for --assume-yes + cmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "yes" { + name = "assume-yes" + } + return pflag.NormalizedName(name) + }) + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"service-account"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) error { + if !o.assumeYes && !global.DryRun { + confirmed, err := confirmApiKeyRevoke(args, o.serviceAccount, out, in) + if err != nil { + return err + } + if !confirmed { + logger.Info("revocation of API key(s) %s was cancelled", strings.Join(args, ", ")) + return nil + } + } + + for _, keyID := range args { + url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys", keyID) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodDelete, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + if _, err := kosliClient.Do(reqParams); err != nil { + return err + } + if !global.DryRun { + logger.Info("API key %s for service account %s was revoked", keyID, o.serviceAccount) + } + } + return nil +} + +// confirmApiKeyRevoke prompts the user to confirm revocation and returns true +// only when the answer is an affirmative "y"/"yes" (case-insensitive). +func confirmApiKeyRevoke(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) + } + + if _, err := fmt.Fprintf(out, "Are you sure you want to revoke API key(s) %s for service account %s? [y/N] ", + strings.Join(styledKeys, ", "), style(out, serviceAccount, ansiBold, ansiGreen)); err != nil { + return false, err + } + + 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/root.go b/cmd/kosli/root.go index 7c3dd2602..67703ed3e 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 = "[defaulted] How many hours the old API key remains valid after rotation, to allow time to update dependent systems." + revokeApiKeyYesFlag = "[optional] Skip the confirmation prompt and revoke 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,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newAttachPolicyCmd(out), newDetachPolicyCmd(out), newEvaluateCmd(out), + newServiceAccountCmd(out), ) cobra.AddTemplateFunc("isBeta", isBeta) diff --git a/cmd/kosli/rotateApiKey.go b/cmd/kosli/rotateApiKey.go new file mode 100644 index 000000000..958d61429 --- /dev/null +++ b/cmd/kosli/rotateApiKey.go @@ -0,0 +1,133 @@ +package main + +import ( + "io" + "net/http" + "net/url" + "strings" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const rotateApiKeyShortDesc = `Rotate an API key for a service account.` + +const rotateApiKeyLongDesc = rotateApiKeyShortDesc + ` + +A new API key is generated immediately. The old key remains valid for a grace period +(default 24 hours) to allow time to update dependent systems. 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 service-account api-keys rotate yourApiKeyID \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# rotate multiple API keys at once: +kosli service-account api-keys rotate keyID1 keyID2 \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# rotate an API key, keeping the old key valid for 48 hours: +kosli service-account api-keys rotate yourApiKeyID \ + --service-account yourServiceAccountName \ + --grace-period-hours 48 \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type rotateApiKeyOptions struct { + serviceAccount string + expiresAt string + gracePeriodHours int + 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: "rotate KEY-ID [KEY-ID...]", + Aliases: []string{"ro"}, + 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 { + 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", 24, 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 { + o.payload.GracePeriodHours = &o.gracePeriodHours + if o.expiresAt != "" { + expiresAt, err := parseExpiresAt(o.expiresAt) + if err != nil { + return err + } + o.payload.ExpiresAt = &expiresAt + } + + bodies := []string{} + 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 { + return err + } + + 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 { + return err + } + if !global.DryRun { + bodies = append(bodies, response.Body) + } + } + + if global.DryRun { + return nil + } + + raw := "[" + strings.Join(bodies, ",") + "]" + return output.FormattedPrint(raw, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printApiKeysAsTable, + "json": output.PrintJson, + }) +} diff --git a/cmd/kosli/serviceAccount.go b/cmd/kosli/serviceAccount.go new file mode 100644 index 000000000..59da6e4c1 --- /dev/null +++ b/cmd/kosli/serviceAccount.go @@ -0,0 +1,25 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const serviceAccountDesc = `All Kosli service account operations.` + +func newServiceAccountCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "service-account", + Aliases: []string{"sa"}, + Short: serviceAccountDesc, + Long: serviceAccountDesc, + } + + // Add subcommands + cmd.AddCommand( + newServiceAccountApiKeysCmd(out), + ) + + return cmd +} diff --git a/cmd/kosli/serviceAccountApiKeys.go b/cmd/kosli/serviceAccountApiKeys.go new file mode 100644 index 000000000..db5970c1b --- /dev/null +++ b/cmd/kosli/serviceAccountApiKeys.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/spf13/cobra" +) + +const serviceAccountApiKeysDesc = `Manage API keys for a Kosli service account.` + +func newServiceAccountApiKeysCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "api-keys", + Aliases: []string{"ak"}, + Short: serviceAccountApiKeysDesc, + Long: serviceAccountApiKeysDesc, + } + + // Add subcommands + cmd.AddCommand( + newCreateApiKeyCmd(out), + newRevokeApiKeyCmd(out), + newRotateApiKeyCmd(out), + newListApiKeysCmd(out), + ) + + return cmd +} + +// 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"` +} + +// 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 +} + +// 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 := formattedTimestamp(key.ExpiresAt, false) + 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/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go new file mode 100644 index 000000000..861dcb191 --- /dev/null +++ b/cmd/kosli/serviceAccount_test.go @@ -0,0 +1,381 @@ +package main + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/maxcnunes/httpfake" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +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") +} + +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) + } +} + +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 ServiceAccountApiKeysCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *ServiceAccountApiKeysCommandTestSuite) 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 *ServiceAccountApiKeysCommandTestSuite) TestCreateApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "create builds the right url and payload (dry-run)", + cmd: "service-account api-keys create --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: "service-account api-keys create --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 sa/ak aliases work", + cmd: "sa ak create --service-account test-sa --description 'ci key' --dry-run" + suite.defaultKosliArguments, + goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys`, + }, + { + wantError: false, + name: "the create aliases and -s shorthand work", + cmd: "sa ak c -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: "service-account api-keys create --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: "service-account api-keys create --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: "service-account api-keys create --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 *ServiceAccountApiKeysCommandTestSuite) TestRotateApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "rotate builds the right url and default grace period (dry-run)", + cmd: "service-account api-keys rotate 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.*"grace_period_hours": 24`, + }, + { + wantError: false, + name: "rotate honours a custom --grace-period-hours (dry-run)", + cmd: "service-account api-keys rotate key-123 --service-account test-sa --grace-period-hours 48 --dry-run" + suite.defaultKosliArguments, + goldenRegex: `"grace_period_hours": 48`, + }, + { + wantError: false, + name: "the rotate alias and -s shorthand work", + cmd: "sa ak ro 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: "sa ak ro 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: "service-account api-keys rotate 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: "service-account api-keys rotate --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: "service-account api-keys rotate key-123" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"service-account\" not set\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokeApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "revoke without confirmation (empty stdin) is cancelled and makes no call", + cmd: "service-account api-keys revoke key-123 --service-account test-sa" + suite.defaultKosliArguments, + golden: "Are you sure you want to revoke API key(s) key-123 for service account test-sa? [y/N] revocation of API key(s) key-123 was cancelled\n", + }, + { + wantError: false, + name: "revoke accepts multiple KEY-IDs (dry-run)", + cmd: "service-account api-keys revoke 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: "revoke with --assume-yes and --dry-run builds the right url", + cmd: "service-account api-keys revoke 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: "revoke with --yes bypasses confirmation too", + cmd: "service-account api-keys revoke 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 del alias and -s shorthand work", + cmd: "sa ak del 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: "revoke fails when KEY-ID argument is missing", + cmd: "service-account api-keys revoke --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: requires at least 1 arg(s), only received 0\n", + }, + { + wantError: true, + name: "revoke fails when --service-account is missing", + cmd: "service-account api-keys revoke 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 *ServiceAccountApiKeysCommandTestSuite) 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(`{"id":"id-1","key":"sk_created","description":"ci","created_at":1780584129.5,"expires_at":0}`) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys"). + Reply(200). + BodyString(`[{"id":"id-1","description":"ci","created_at":1780584129.5,"expires_at":0,"last_used_at":0}]`) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1/rotate"). + Reply(201). + BodyString(`{"id":"k1","key":"sk_one","description":"one","created_at":1,"expires_at":0}`) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate"). + Reply(201). + BodyString(`{"id":"k2","key":"sk_two","description":"two","created_at":1,"expires_at":0}`) + + 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: "service-account api-keys create -s test-sa -d ci --output json" + args, + goldenRegex: `sk_created`, + }, + { + wantError: false, + name: "list prints the returned keys", + cmd: "service-account api-keys list -s test-sa --output json" + args, + goldenRegex: `id-1`, + }, + { + wantError: false, + name: "rotate of multiple keys prints all new key values", + cmd: "service-account api-keys rotate k1 k2 -s test-sa --output json" + args, + goldenRegex: `(?s)sk_one.*sk_two`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestRevokeApiKeyNotFound stubs the API with a 404 to verify that revoking a +// non-existing key surfaces the server's "API key not found" error instead of +// reporting success. +func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokeApiKeyNotFound() { + 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(`{"message": "API key not found"}`) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "revoke surfaces a 404 from the API as an error", + cmd: "service-account api-keys revoke missing-key --service-account test-sa --assume-yes" + args, + goldenRegex: `Error: 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 *ServiceAccountApiKeysCommandTestSuite) 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(`{"message": "Service account not found"}`) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key/rotate"). + Reply(404). + BodyString(`{"message": "API key not found"}`) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys"). + Reply(403). + BodyString(`{"message": "You don't have permission to access this resource"}`) + + 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: "service-account api-keys create --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: "service-account api-keys rotate 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: "service-account api-keys list --service-account missing-sa" + args, + goldenRegex: `Error: You don't have permission to access this resource`, + }, + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ServiceAccountApiKeysCommandTestSuite) TestListApiKeysCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "list fails when --service-account is missing", + cmd: "service-account api-keys list" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"service-account\" not set\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestServiceAccountApiKeysCommandTestSuite(t *testing.T) { + suite.Run(t, new(ServiceAccountApiKeysCommandTestSuite)) +} From 90c8f8e8a310e8027b4acabc0ad215400b8618f3 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 13:23:48 +0100 Subject: [PATCH 02/10] fix: enforce new keys feature and address feedback --- cmd/kosli/listApiKeys.go | 4 ++-- cmd/kosli/revokeApiKey.go | 13 +++++------- cmd/kosli/rotateApiKey.go | 34 +++++++++++++++++++----------- cmd/kosli/serviceAccountApiKeys.go | 20 +++++++++++++++++- cmd/kosli/serviceAccount_test.go | 34 ++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 23 deletions(-) diff --git a/cmd/kosli/listApiKeys.go b/cmd/kosli/listApiKeys.go index 729e1f64b..4d87334ab 100644 --- a/cmd/kosli/listApiKeys.go +++ b/cmd/kosli/listApiKeys.go @@ -104,11 +104,11 @@ func printApiKeysListAsTable(raw string, out io.Writer, page int) error { if err != nil { return err } - expiresAt, err := formattedTimestamp(key["expires_at"], false) + expiresAt, err := optionalTimestamp(key["expires_at"]) if err != nil { return err } - lastUsedAt, err := formattedTimestamp(key["last_used_at"], false) + lastUsedAt, err := optionalTimestamp(key["last_used_at"]) if err != nil { return err } diff --git a/cmd/kosli/revokeApiKey.go b/cmd/kosli/revokeApiKey.go index e8e30157f..52fb8aa8e 100644 --- a/cmd/kosli/revokeApiKey.go +++ b/cmd/kosli/revokeApiKey.go @@ -10,7 +10,6 @@ import ( "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) const revokeApiKeyShortDesc = `Revoke an API key for a service account.` @@ -68,13 +67,11 @@ func newRevokeApiKeyCmd(out io.Writer) *cobra.Command { cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) cmd.Flags().BoolVarP(&o.assumeYes, "assume-yes", "y", false, revokeApiKeyYesFlag) - // allow --yes as an alias for --assume-yes - cmd.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { - if name == "yes" { - name = "assume-yes" - } - return pflag.NormalizedName(name) - }) + // keep --yes as a hidden alias for --assume-yes (bound to the same option) + cmd.Flags().BoolVar(&o.assumeYes, "yes", false, revokeApiKeyYesFlag) + if f := cmd.Flags().Lookup("yes"); f != nil { + f.Hidden = true + } addDryRunFlag(cmd) err := RequireFlags(cmd, []string{"service-account"}) diff --git a/cmd/kosli/rotateApiKey.go b/cmd/kosli/rotateApiKey.go index 958d61429..a01c2a91a 100644 --- a/cmd/kosli/rotateApiKey.go +++ b/cmd/kosli/rotateApiKey.go @@ -1,10 +1,10 @@ package main import ( + "encoding/json" "io" "net/http" "net/url" - "strings" "github.com/kosli-dev/cli/internal/output" "github.com/kosli-dev/cli/internal/requests" @@ -97,7 +97,11 @@ func (o *rotateApiKeyOptions) run(out io.Writer, args []string) error { o.payload.ExpiresAt = &expiresAt } - bodies := []string{} + // 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 { @@ -113,21 +117,27 @@ func (o *rotateApiKeyOptions) run(out io.Writer, args []string) error { } response, err := kosliClient.Do(reqParams) if err != nil { - return err + runErr = err + break } if !global.DryRun { - bodies = append(bodies, response.Body) + keys = append(keys, json.RawMessage(response.Body)) } } - if global.DryRun { - return nil + 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 + } } - raw := "[" + strings.Join(bodies, ",") + "]" - return output.FormattedPrint(raw, o.output, out, 0, - map[string]output.FormatOutputFunc{ - "table": printApiKeysAsTable, - "json": output.PrintJson, - }) + return runErr } diff --git a/cmd/kosli/serviceAccountApiKeys.go b/cmd/kosli/serviceAccountApiKeys.go index db5970c1b..9be364679 100644 --- a/cmd/kosli/serviceAccountApiKeys.go +++ b/cmd/kosli/serviceAccountApiKeys.go @@ -106,13 +106,31 @@ func printApiKeysAsTable(raw string, out io.Writer, page int) error { 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 := formattedTimestamp(key.ExpiresAt, false) + expiresAt, err := optionalTimestamp(key.ExpiresAt) if err != nil { return nil, err } diff --git a/cmd/kosli/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go index 861dcb191..7576221e7 100644 --- a/cmd/kosli/serviceAccount_test.go +++ b/cmd/kosli/serviceAccount_test.go @@ -25,6 +25,9 @@ func TestPrintApiKeyAsTable(t *testing.T) { 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) { @@ -56,6 +59,9 @@ func TestPrintApiKeysListAsTable(t *testing.T) { 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) { @@ -296,6 +302,34 @@ func (suite *ServiceAccountApiKeysCommandTestSuite) TestApiKeysSuccessOutput() { runTestCmd(suite.T(), tests) } +// TestRotatePartialFailure 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 *ServiceAccountApiKeysCommandTestSuite) TestRotatePartialFailure() { + 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(`{"id":"k1","key":"sk_one","description":"one","created_at":1,"expires_at":0}`) + fake.NewHandler(). + Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate"). + Reply(404). + BodyString(`{"message": "API key not found"}`) + + 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: "service-account api-keys rotate k1 k2 -s test-sa --output json" + args, + goldenRegex: `(?s)sk_one.*Error: API key not found`, + }, + } + + runTestCmd(suite.T(), tests) +} + // TestRevokeApiKeyNotFound stubs the API with a 404 to verify that revoking a // non-existing key surfaces the server's "API key not found" error instead of // reporting success. From bf048c53a019ab72f70926c0931258a2ae06940b Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 13:48:34 +0100 Subject: [PATCH 03/10] fix: be moreo verbose about partial revocation failures --- cmd/kosli/revokeApiKey.go | 6 ++++-- cmd/kosli/serviceAccount_test.go | 30 +++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/cmd/kosli/revokeApiKey.go b/cmd/kosli/revokeApiKey.go index 52fb8aa8e..84910bd25 100644 --- a/cmd/kosli/revokeApiKey.go +++ b/cmd/kosli/revokeApiKey.go @@ -107,10 +107,12 @@ func (o *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er Token: global.ApiToken, } if _, err := kosliClient.Do(reqParams); err != nil { - return err + // revocation is destructive: the failed key is shown in bold red, + // while the keys already revoked were each logged in bold green above. + return fmt.Errorf("failed to revoke API key %s: %w", style(out, keyID, ansiBold, ansiRed), err) } if !global.DryRun { - logger.Info("API key %s for service account %s was revoked", keyID, o.serviceAccount) + logger.Info("API key %s for service account %s was revoked", style(out, keyID, ansiBold, ansiGreen), o.serviceAccount) } } return nil diff --git a/cmd/kosli/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go index 7576221e7..fa6542248 100644 --- a/cmd/kosli/serviceAccount_test.go +++ b/cmd/kosli/serviceAccount_test.go @@ -330,6 +330,34 @@ func (suite *ServiceAccountApiKeysCommandTestSuite) TestRotatePartialFailure() { runTestCmd(suite.T(), tests) } +// TestRevokePartialFailure verifies that when one key in a multi-key revoke +// fails, the keys already revoked are reported (revocation is destructive and +// one-way) before the error is surfaced. +func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokePartialFailure() { + 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(`"revoked"`) + fake.NewHandler(). + Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2"). + Reply(404). + BodyString(`{"message": "API key not found"}`) + + args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: true, + name: "revoke reports revoked keys before a later key fails", + cmd: "service-account api-keys revoke k1 k2 -s test-sa --assume-yes" + args, + goldenRegex: `(?s)API key k1 for service account test-sa was revoked.*failed to revoke API key k2.*API key not found`, + }, + } + + runTestCmd(suite.T(), tests) +} + // TestRevokeApiKeyNotFound stubs the API with a 404 to verify that revoking a // non-existing key surfaces the server's "API key not found" error instead of // reporting success. @@ -347,7 +375,7 @@ func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokeApiKeyNotFound() { wantError: true, name: "revoke surfaces a 404 from the API as an error", cmd: "service-account api-keys revoke missing-key --service-account test-sa --assume-yes" + args, - goldenRegex: `Error: API key not found`, + goldenRegex: `(?s)failed to revoke API key missing-key.*API key not found`, }, } From e83ac792cd24b07cfcdc511422cd1ec23b16fba2 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 14:59:33 +0100 Subject: [PATCH 04/10] fix: remove erorr colouring, use logger consistently and remove hard-coded grace period default --- cmd/kosli/revokeApiKey.go | 13 ++++++------- cmd/kosli/root.go | 2 +- cmd/kosli/rotateApiKey.go | 23 +++++++++++++++-------- cmd/kosli/serviceAccount_test.go | 6 +++--- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/cmd/kosli/revokeApiKey.go b/cmd/kosli/revokeApiKey.go index 84910bd25..6ab9cedce 100644 --- a/cmd/kosli/revokeApiKey.go +++ b/cmd/kosli/revokeApiKey.go @@ -107,9 +107,10 @@ func (o *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er Token: global.ApiToken, } if _, err := kosliClient.Do(reqParams); err != nil { - // revocation is destructive: the failed key is shown in bold red, - // while the keys already revoked were each logged in bold green above. - return fmt.Errorf("failed to revoke API key %s: %w", style(out, keyID, ansiBold, ansiRed), err) + // Keep the returned error plain (no ANSI): it may be logged or + // wrapped by callers that don't expect escape codes. Keys already + // revoked were each logged in bold green above (user-facing output). + return fmt.Errorf("failed to revoke API key %s: %w", keyID, err) } if !global.DryRun { logger.Info("API key %s for service account %s was revoked", style(out, keyID, ansiBold, ansiGreen), o.serviceAccount) @@ -126,10 +127,8 @@ func confirmApiKeyRevoke(keyIDs []string, serviceAccount string, out io.Writer, styledKeys[i] = style(out, keyID, ansiBold, ansiMagenta) } - if _, err := fmt.Fprintf(out, "Are you sure you want to revoke API key(s) %s for service account %s? [y/N] ", - strings.Join(styledKeys, ", "), style(out, serviceAccount, ansiBold, ansiGreen)); err != nil { - return false, err - } + logger.Info("Are you sure you want to revoke 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 { diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 67703ed3e..d5e682b45 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -110,7 +110,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, 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 = "[defaulted] How many hours the old API key remains valid after rotation, to allow time to update dependent systems." + 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." revokeApiKeyYesFlag = "[optional] Skip the confirmation prompt and revoke the API key without asking. (alias: --yes)" environmentNameFlag = "The environment name." approvalEnvironmentNameFlag = "[defaulted] The environment the artifact is approved for. (defaults to all environments)" diff --git a/cmd/kosli/rotateApiKey.go b/cmd/kosli/rotateApiKey.go index a01c2a91a..c62803fdd 100644 --- a/cmd/kosli/rotateApiKey.go +++ b/cmd/kosli/rotateApiKey.go @@ -16,7 +16,8 @@ const rotateApiKeyShortDesc = `Rotate an API key for a service account.` const rotateApiKeyLongDesc = rotateApiKeyShortDesc + ` A new API key is generated immediately. The old key remains valid for a grace period -(default 24 hours) to allow time to update dependent systems. The new key value is only +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 = ` @@ -41,11 +42,12 @@ kosli service-account api-keys rotate yourApiKeyID \ ` type rotateApiKeyOptions struct { - serviceAccount string - expiresAt string - gracePeriodHours int - output string - payload rotateApiKeyPayload + serviceAccount string + expiresAt string + gracePeriodHours int + gracePeriodHoursSet bool + output string + payload rotateApiKeyPayload } type rotateApiKeyPayload struct { @@ -69,13 +71,14 @@ func newRotateApiKeyCmd(out io.Writer) *cobra.Command { 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", 24, apiKeyGracePeriodHoursFlag) + cmd.Flags().IntVarP(&o.gracePeriodHours, "grace-period-hours", "g", 0, apiKeyGracePeriodHoursFlag) cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) addDryRunFlag(cmd) @@ -88,7 +91,11 @@ func newRotateApiKeyCmd(out io.Writer) *cobra.Command { } func (o *rotateApiKeyOptions) run(out io.Writer, args []string) error { - o.payload.GracePeriodHours = &o.gracePeriodHours + // 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 { diff --git a/cmd/kosli/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go index fa6542248..872e75b62 100644 --- a/cmd/kosli/serviceAccount_test.go +++ b/cmd/kosli/serviceAccount_test.go @@ -161,9 +161,9 @@ func (suite *ServiceAccountApiKeysCommandTestSuite) TestRotateApiKeyCmd() { tests := []cmdTestCase{ { wantError: false, - name: "rotate builds the right url and default grace period (dry-run)", + name: "rotate without --grace-period-hours sends an empty payload (server owns the default)", cmd: "service-account api-keys rotate 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.*"grace_period_hours": 24`, + goldenRegex: `(?s)service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123/rotate.*real run:\s*\{\}`, }, { wantError: false, @@ -212,7 +212,7 @@ func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokeApiKeyCmd() { wantError: false, name: "revoke without confirmation (empty stdin) is cancelled and makes no call", cmd: "service-account api-keys revoke key-123 --service-account test-sa" + suite.defaultKosliArguments, - golden: "Are you sure you want to revoke API key(s) key-123 for service account test-sa? [y/N] revocation of API key(s) key-123 was cancelled\n", + golden: "Are you sure you want to revoke API key(s) key-123 for service account test-sa? [y/N]\nrevocation of API key(s) key-123 was cancelled\n", }, { wantError: false, From 151b4702775258396c4033eeb9a6293334d2e5eb Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 15:03:49 +0100 Subject: [PATCH 05/10] chore: remove unnecessary comments --- cmd/kosli/revokeApiKey.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/kosli/revokeApiKey.go b/cmd/kosli/revokeApiKey.go index 6ab9cedce..ee2a8262c 100644 --- a/cmd/kosli/revokeApiKey.go +++ b/cmd/kosli/revokeApiKey.go @@ -107,9 +107,6 @@ func (o *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er Token: global.ApiToken, } if _, err := kosliClient.Do(reqParams); err != nil { - // Keep the returned error plain (no ANSI): it may be logged or - // wrapped by callers that don't expect escape codes. Keys already - // revoked were each logged in bold green above (user-facing output). return fmt.Errorf("failed to revoke API key %s: %w", keyID, err) } if !global.DryRun { From 98ee4a1d755b69fda7f5196e23ce8116433bc49c Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 20:10:52 +0100 Subject: [PATCH 06/10] chore: print which keys were revoked --- cmd/kosli/revokeApiKey.go | 12 +++++++++++- cmd/kosli/serviceAccount_test.go | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/revokeApiKey.go b/cmd/kosli/revokeApiKey.go index ee2a8262c..db50bf48d 100644 --- a/cmd/kosli/revokeApiKey.go +++ b/cmd/kosli/revokeApiKey.go @@ -94,7 +94,7 @@ func (o *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er } } - for _, keyID := range args { + 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 { return err @@ -107,6 +107,16 @@ func (o *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er Token: global.ApiToken, } if _, err := kosliClient.Do(reqParams); err != nil { + // revocation is destructive and one-way: make clear which keys were + // already revoked before this one failed (user-facing, styled green), + // while keeping the returned error plain (no ANSI). + if i > 0 { + revoked := make([]string, i) + for j, k := range args[:i] { + revoked[j] = style(out, k, ansiBold, ansiGreen) + } + logger.Info("keys already revoked before this failure: %s", strings.Join(revoked, ", ")) + } return fmt.Errorf("failed to revoke API key %s: %w", keyID, err) } if !global.DryRun { diff --git a/cmd/kosli/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go index 872e75b62..f32ab0a95 100644 --- a/cmd/kosli/serviceAccount_test.go +++ b/cmd/kosli/serviceAccount_test.go @@ -351,7 +351,7 @@ func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokePartialFailure() { wantError: true, name: "revoke reports revoked keys before a later key fails", cmd: "service-account api-keys revoke k1 k2 -s test-sa --assume-yes" + args, - goldenRegex: `(?s)API key k1 for service account test-sa was revoked.*failed to revoke API key k2.*API key not found`, + goldenRegex: `(?s)API key k1 for service account test-sa was revoked.*already revoked before this failure: k1.*failed to revoke API key k2.*API key not found`, }, } From 19b4e7904b506844f803beef1067b7b3acee2d6c Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 6 Jun 2026 20:20:25 +0100 Subject: [PATCH 07/10] chore: consistent erorr handling when rotating keys --- cmd/kosli/rotateApiKey.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/rotateApiKey.go b/cmd/kosli/rotateApiKey.go index c62803fdd..e01a2631c 100644 --- a/cmd/kosli/rotateApiKey.go +++ b/cmd/kosli/rotateApiKey.go @@ -112,7 +112,8 @@ func (o *rotateApiKeyOptions) run(out io.Writer, args []string) 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 { - return err + runErr = err + break } reqParams := &requests.RequestParams{ From a919e0c7f1b016206e10ac5b1219985ca9eba8a7 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 8 Jun 2026 18:34:05 +0100 Subject: [PATCH 08/10] feat: refactor to verb-first structure --- cmd/kosli/apiKey.go | 139 ++++++ cmd/kosli/apiKey_test.go | 464 ++++++++++++++++++ cmd/kosli/create.go | 8 +- cmd/kosli/createApiKey.go | 8 +- cmd/kosli/delete.go | 25 + cmd/kosli/deleteApiKey.go | 147 ++++++ cmd/kosli/disable.go | 7 +- cmd/kosli/enable.go | 7 +- cmd/kosli/get.go | 7 +- cmd/kosli/list.go | 3 +- cmd/kosli/listApiKeys.go | 16 +- cmd/kosli/log.go | 7 +- cmd/kosli/rename.go | 7 +- cmd/kosli/root.go | 6 +- cmd/kosli/status.go | 9 +- cmd/kosli/testdata/service-account/README.md | 21 + .../service-account/created_api_key.json | 1 + .../error_api_key_not_found.json | 1 + .../service-account/error_forbidden.json | 1 + .../error_service_account_not_found.json | 1 + .../service-account/listed_api_keys.json | 1 + .../service-account/revoke_success.json | 1 + .../service-account/rotated_api_key.json | 1 + cmd/kosli/update.go | 25 + cmd/kosli/updateApiKey.go | 163 ++++++ 25 files changed, 1039 insertions(+), 37 deletions(-) create mode 100644 cmd/kosli/apiKey.go create mode 100644 cmd/kosli/apiKey_test.go create mode 100644 cmd/kosli/delete.go create mode 100644 cmd/kosli/deleteApiKey.go create mode 100644 cmd/kosli/testdata/service-account/README.md create mode 100644 cmd/kosli/testdata/service-account/created_api_key.json create mode 100644 cmd/kosli/testdata/service-account/error_api_key_not_found.json create mode 100644 cmd/kosli/testdata/service-account/error_forbidden.json create mode 100644 cmd/kosli/testdata/service-account/error_service_account_not_found.json create mode 100644 cmd/kosli/testdata/service-account/listed_api_keys.json create mode 100644 cmd/kosli/testdata/service-account/revoke_success.json create mode 100644 cmd/kosli/testdata/service-account/rotated_api_key.json create mode 100644 cmd/kosli/update.go create mode 100644 cmd/kosli/updateApiKey.go 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..2e93f80ff --- /dev/null +++ b/cmd/kosli/apiKey_test.go @@ -0,0 +1,464 @@ +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 update --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) TestUpdateApiKeyCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "rotate without --grace-period-hours sends an empty payload (server owns the default)", + cmd: "update api-key key-123 --rotate --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: "update api-key key-123 --rotate --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: "update ak key-123 --rotate -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 update verb aliases (up, u) work", + cmd: "u ak key-123 --rotate -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 -R, -g and -e shorthands work (dry-run)", + cmd: "update ak key-123 -R -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: "update --rotate accepts multiple KEY-IDs (dry-run)", + cmd: "update api-key key-1 key-2 --rotate --service-account test-sa --dry-run" + suite.defaultKosliArguments, + goldenRegex: `(?s)api-keys/key-1/rotate.*api-keys/key-2/rotate`, + }, + { + wantError: true, + name: "update without --rotate has nothing to do", + cmd: "update api-key key-123 --service-account test-sa" + suite.defaultKosliArguments, + goldenRegex: `Error: nothing to update`, + }, + { + wantError: true, + name: "update fails when KEY-ID argument is missing", + cmd: "update api-key --rotate --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: requires at least 1 arg(s), only received 0\n", + }, + { + wantError: true, + name: "update fails when --service-account is missing", + cmd: "update api-key key-123 --rotate" + 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/update 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: "update --rotate of multiple keys prints all rotated keys", + cmd: "update api-key k1 k2 --rotate -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: "update api-key k1 k2 --rotate -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/update/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: "update --rotate surfaces a 404 from the API as an error", + cmd: "update api-key missing-key --rotate --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/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 index 57e4f2e69..04c3f032b 100644 --- a/cmd/kosli/createApiKey.go +++ b/cmd/kosli/createApiKey.go @@ -18,14 +18,14 @@ The key value is only returned once, at creation time, so make sure to store it const createApiKeyExample = ` # create an API key for a service account: -kosli service-account api-keys create \ +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 service-account api-keys create \ +kosli create api-key \ --service-account yourServiceAccountName \ --description "key for CI" \ --expires-at 2026-12-31 \ @@ -48,8 +48,8 @@ type createApiKeyPayload struct { func newCreateApiKeyCmd(out io.Writer) *cobra.Command { o := new(createApiKeyOptions) cmd := &cobra.Command{ - Use: "create", - Aliases: []string{"c", "cr"}, + Use: "api-key", + Aliases: []string{"ak"}, Short: createApiKeyShortDesc, Long: createApiKeyLongDesc, Example: createApiKeyExample, 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..e5a1f1b97 --- /dev/null +++ b/cmd/kosli/deleteApiKey.go @@ -0,0 +1,147 @@ +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 (revoke) one or more API keys for a service account.` + +const deleteApiKeyLongDesc = deleteApiKeyShortDesc + ` + +This permanently revokes the API key(s) identified by KEY-ID. 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 + } + } + + 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 { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodDelete, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + if _, err := kosliClient.Do(reqParams); err != nil { + // deletion is destructive and one-way: make clear which keys were + // already deleted before this one failed (user-facing, styled green), + // while keeping the returned error plain (no ANSI). + 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, ", ")) + } + 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/disable.go b/cmd/kosli/disable.go index c3e3db2df..ef39c784f 100644 --- a/cmd/kosli/disable.go +++ b/cmd/kosli/disable.go @@ -10,9 +10,10 @@ const disableDesc = `Kosli disable commands.` func newDisableCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "disable", - Short: disableDesc, - Long: disableDesc, + Use: "disable", + Aliases: []string{"dis"}, + Short: disableDesc, + Long: disableDesc, } // Add subcommands diff --git a/cmd/kosli/enable.go b/cmd/kosli/enable.go index d29f5f7a1..749356656 100644 --- a/cmd/kosli/enable.go +++ b/cmd/kosli/enable.go @@ -10,9 +10,10 @@ const enableDesc = `Kosli enable commands.` func newEnableCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "enable", - Short: enableDesc, - Long: enableDesc, + Use: "enable", + Aliases: []string{"en"}, + Short: enableDesc, + Long: enableDesc, } // Add subcommands diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index 385f64916..c3f4db0e8 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -10,9 +10,10 @@ const getDesc = `All Kosli get commands.` func newGetCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "get", - Short: getDesc, - Long: getDesc, + Use: "get", + Aliases: []string{"g"}, + Short: getDesc, + Long: getDesc, } // Add subcommands 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 index 4d87334ab..b5b34f4f3 100644 --- a/cmd/kosli/listApiKeys.go +++ b/cmd/kosli/listApiKeys.go @@ -21,7 +21,7 @@ listed (they are only shown once, at creation or rotation time).` const listApiKeysExample = ` # list the API keys for a service account: -kosli service-account api-keys list \ +kosli list api-keys \ --service-account yourServiceAccountName \ --api-token yourAPIToken \ --org yourOrgName @@ -35,8 +35,8 @@ type listApiKeysOptions struct { func newListApiKeysCmd(out io.Writer) *cobra.Command { o := new(listApiKeysOptions) cmd := &cobra.Command{ - Use: "list", - Aliases: []string{"l", "ls"}, + Use: "api-keys", + Aliases: []string{"api-key", "ak"}, Short: listApiKeysShortDesc, Long: listApiKeysLongDesc, Example: listApiKeysExample, @@ -87,7 +87,7 @@ func (o *listApiKeysOptions) run(out io.Writer, args []string) error { } func printApiKeysListAsTable(raw string, out io.Writer, page int) error { - var keys []map[string]interface{} + var keys []apiKeyMetadata if err := json.Unmarshal([]byte(raw), &keys); err != nil { return err } @@ -100,20 +100,20 @@ func printApiKeysListAsTable(raw string, out io.Writer, page int) error { header := []string{"ID", "DESCRIPTION", "CREATED", "EXPIRES", "LAST USED"} rows := []string{} for _, key := range keys { - createdAt, err := formattedTimestamp(key["created_at"], false) + createdAt, err := formattedTimestamp(key.CreatedAt, false) if err != nil { return err } - expiresAt, err := optionalTimestamp(key["expires_at"]) + expiresAt, err := optionalTimestamp(key.ExpiresAt) if err != nil { return err } - lastUsedAt, err := optionalTimestamp(key["last_used_at"]) + 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) + 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) diff --git a/cmd/kosli/log.go b/cmd/kosli/log.go index 78f1fc6c9..94df84575 100644 --- a/cmd/kosli/log.go +++ b/cmd/kosli/log.go @@ -10,9 +10,10 @@ const logDesc = `All Kosli log commands.` func newLogCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "log", - Short: logDesc, - Long: logDesc, + Use: "log", + Aliases: []string{"lo"}, + Short: logDesc, + Long: logDesc, } // Add subcommands diff --git a/cmd/kosli/rename.go b/cmd/kosli/rename.go index 0dcc76a6b..a162ecf67 100644 --- a/cmd/kosli/rename.go +++ b/cmd/kosli/rename.go @@ -10,9 +10,10 @@ const renameDesc = `All Kosli rename commands.` func newRenameCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "rename", - Short: renameDesc, - Long: renameDesc, + Use: "rename", + Aliases: []string{"re"}, + Short: renameDesc, + Long: renameDesc, } // Add subcommands diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index d5e682b45..43038e9ec 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -111,7 +111,8 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, 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." - revokeApiKeyYesFlag = "[optional] Skip the confirmation prompt and revoke the API key without asking. (alias: --yes)" + apiKeyRotateFlag = "Rotate the API key(s): generate a new key value and start the grace period on the old one. This is currently the only supported update." + 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." @@ -419,7 +420,8 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newAttachPolicyCmd(out), newDetachPolicyCmd(out), newEvaluateCmd(out), - newServiceAccountCmd(out), + newDeleteCmd(out), + newUpdateCmd(out), ) cobra.AddTemplateFunc("isBeta", isBeta) diff --git a/cmd/kosli/status.go b/cmd/kosli/status.go index 1e50dcf61..6941ee61b 100644 --- a/cmd/kosli/status.go +++ b/cmd/kosli/status.go @@ -23,10 +23,11 @@ type statusOptions struct { func newStatusCmd(out io.Writer) *cobra.Command { o := new(statusOptions) cmd := &cobra.Command{ - Use: "status", - Short: statusShortDesc, - Long: statusLongDesc, - Args: cobra.NoArgs, + Use: "status", + Aliases: []string{"s", "st"}, + Short: statusShortDesc, + Long: statusLongDesc, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return o.run(out) }, 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/cmd/kosli/update.go b/cmd/kosli/update.go new file mode 100644 index 000000000..537d58b87 --- /dev/null +++ b/cmd/kosli/update.go @@ -0,0 +1,25 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const updateDesc = `All Kosli update commands.` + +func newUpdateCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Aliases: []string{"up", "u"}, + Short: updateDesc, + Long: updateDesc, + } + + // Add subcommands + cmd.AddCommand( + newUpdateApiKeyCmd(out), + ) + + return cmd +} diff --git a/cmd/kosli/updateApiKey.go b/cmd/kosli/updateApiKey.go new file mode 100644 index 000000000..5f58f59f1 --- /dev/null +++ b/cmd/kosli/updateApiKey.go @@ -0,0 +1,163 @@ +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 updateApiKeyShortDesc = `Update one or more API keys for a service account.` + +const updateApiKeyLongDesc = updateApiKeyShortDesc + ` + +Currently the only supported update is rotation, requested with ^--rotate^. + +When rotating, 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 updateApiKeyExample = ` +# rotate an API key for a service account: +kosli update api-key yourApiKeyID \ + --rotate \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# rotate multiple API keys at once: +kosli update api-key keyID1 keyID2 \ + --rotate \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName + +# rotate an API key, keeping the old key valid for 48 hours: +kosli update api-key yourApiKeyID \ + --rotate \ + --grace-period-hours 48 \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type updateApiKeyOptions struct { + serviceAccount string + rotate bool + 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 newUpdateApiKeyCmd(out io.Writer) *cobra.Command { + o := new(updateApiKeyOptions) + cmd := &cobra.Command{ + Use: "api-key KEY-ID [KEY-ID...]", + Aliases: []string{"ak"}, + Short: updateApiKeyShortDesc, + Long: updateApiKeyLongDesc, + Example: updateApiKeyExample, + 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().BoolVarP(&o.rotate, "rotate", "R", false, apiKeyRotateFlag) + 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 *updateApiKeyOptions) run(out io.Writer, args []string) error { + if !o.rotate { + return fmt.Errorf("nothing to update: use --rotate to rotate the API key(s)") + } + + // 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 +} From 0b107f2c0de48ce040503059ba8c5b6eae707398 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 8 Jun 2026 18:41:48 +0100 Subject: [PATCH 09/10] refactor: remove pre-restructure api-key files superseded by verb-first commands --- cmd/kosli/revokeApiKey.go | 147 ---------- cmd/kosli/rotateApiKey.go | 151 ---------- cmd/kosli/serviceAccount.go | 25 -- cmd/kosli/serviceAccountApiKeys.go | 152 ---------- cmd/kosli/serviceAccount_test.go | 443 ----------------------------- 5 files changed, 918 deletions(-) delete mode 100644 cmd/kosli/revokeApiKey.go delete mode 100644 cmd/kosli/rotateApiKey.go delete mode 100644 cmd/kosli/serviceAccount.go delete mode 100644 cmd/kosli/serviceAccountApiKeys.go delete mode 100644 cmd/kosli/serviceAccount_test.go diff --git a/cmd/kosli/revokeApiKey.go b/cmd/kosli/revokeApiKey.go deleted file mode 100644 index db50bf48d..000000000 --- a/cmd/kosli/revokeApiKey.go +++ /dev/null @@ -1,147 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/kosli-dev/cli/internal/requests" - "github.com/spf13/cobra" -) - -const revokeApiKeyShortDesc = `Revoke an API key for a service account.` - -const revokeApiKeyLongDesc = revokeApiKeyShortDesc + ` - -This permanently revokes the API key identified by KEY-ID. You are asked to confirm -before the key is revoked; use ^--yes^ or ^--assume-yes^ to skip the confirmation prompt.` - -const revokeApiKeyExample = ` -# revoke an API key for a service account (asks for confirmation): -kosli service-account api-keys revoke yourApiKeyID \ - --service-account yourServiceAccountName \ - --api-token yourAPIToken \ - --org yourOrgName - -# revoke multiple API keys at once: -kosli service-account api-keys revoke keyID1 keyID2 \ - --service-account yourServiceAccountName \ - --api-token yourAPIToken \ - --org yourOrgName - -# revoke an API key without confirmation: -kosli service-account api-keys revoke yourApiKeyID \ - --service-account yourServiceAccountName \ - --assume-yes \ - --api-token yourAPIToken \ - --org yourOrgName -` - -type revokeApiKeyOptions struct { - serviceAccount string - assumeYes bool -} - -func newRevokeApiKeyCmd(out io.Writer) *cobra.Command { - o := new(revokeApiKeyOptions) - cmd := &cobra.Command{ - Use: "revoke KEY-ID [KEY-ID...]", - Aliases: []string{"re", "del"}, - Short: revokeApiKeyShortDesc, - Long: revokeApiKeyLongDesc, - Example: revokeApiKeyExample, - 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, revokeApiKeyYesFlag) - // keep --yes as a hidden alias for --assume-yes (bound to the same option) - cmd.Flags().BoolVar(&o.assumeYes, "yes", false, revokeApiKeyYesFlag) - 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 *revokeApiKeyOptions) run(out io.Writer, in io.Reader, args []string) error { - if !o.assumeYes && !global.DryRun { - confirmed, err := confirmApiKeyRevoke(args, o.serviceAccount, out, in) - if err != nil { - return err - } - if !confirmed { - logger.Info("revocation of API key(s) %s was cancelled", strings.Join(args, ", ")) - return nil - } - } - - 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 { - return err - } - - reqParams := &requests.RequestParams{ - Method: http.MethodDelete, - URL: url, - DryRun: global.DryRun, - Token: global.ApiToken, - } - if _, err := kosliClient.Do(reqParams); err != nil { - // revocation is destructive and one-way: make clear which keys were - // already revoked before this one failed (user-facing, styled green), - // while keeping the returned error plain (no ANSI). - if i > 0 { - revoked := make([]string, i) - for j, k := range args[:i] { - revoked[j] = style(out, k, ansiBold, ansiGreen) - } - logger.Info("keys already revoked before this failure: %s", strings.Join(revoked, ", ")) - } - return fmt.Errorf("failed to revoke API key %s: %w", keyID, err) - } - if !global.DryRun { - logger.Info("API key %s for service account %s was revoked", style(out, keyID, ansiBold, ansiGreen), o.serviceAccount) - } - } - return nil -} - -// confirmApiKeyRevoke prompts the user to confirm revocation and returns true -// only when the answer is an affirmative "y"/"yes" (case-insensitive). -func confirmApiKeyRevoke(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 revoke 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/rotateApiKey.go b/cmd/kosli/rotateApiKey.go deleted file mode 100644 index e01a2631c..000000000 --- a/cmd/kosli/rotateApiKey.go +++ /dev/null @@ -1,151 +0,0 @@ -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 an API key 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 service-account api-keys rotate yourApiKeyID \ - --service-account yourServiceAccountName \ - --api-token yourAPIToken \ - --org yourOrgName - -# rotate multiple API keys at once: -kosli service-account api-keys rotate keyID1 keyID2 \ - --service-account yourServiceAccountName \ - --api-token yourAPIToken \ - --org yourOrgName - -# rotate an API key, keeping the old key valid for 48 hours: -kosli service-account api-keys rotate yourApiKeyID \ - --service-account yourServiceAccountName \ - --grace-period-hours 48 \ - --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: "rotate KEY-ID [KEY-ID...]", - Aliases: []string{"ro"}, - 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/serviceAccount.go b/cmd/kosli/serviceAccount.go deleted file mode 100644 index 59da6e4c1..000000000 --- a/cmd/kosli/serviceAccount.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "io" - - "github.com/spf13/cobra" -) - -const serviceAccountDesc = `All Kosli service account operations.` - -func newServiceAccountCmd(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ - Use: "service-account", - Aliases: []string{"sa"}, - Short: serviceAccountDesc, - Long: serviceAccountDesc, - } - - // Add subcommands - cmd.AddCommand( - newServiceAccountApiKeysCmd(out), - ) - - return cmd -} diff --git a/cmd/kosli/serviceAccountApiKeys.go b/cmd/kosli/serviceAccountApiKeys.go deleted file mode 100644 index 9be364679..000000000 --- a/cmd/kosli/serviceAccountApiKeys.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "strconv" - "time" - - "github.com/spf13/cobra" -) - -const serviceAccountApiKeysDesc = `Manage API keys for a Kosli service account.` - -func newServiceAccountApiKeysCmd(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ - Use: "api-keys", - Aliases: []string{"ak"}, - Short: serviceAccountApiKeysDesc, - Long: serviceAccountApiKeysDesc, - } - - // Add subcommands - cmd.AddCommand( - newCreateApiKeyCmd(out), - newRevokeApiKeyCmd(out), - newRotateApiKeyCmd(out), - newListApiKeysCmd(out), - ) - - return cmd -} - -// 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"` -} - -// 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/serviceAccount_test.go b/cmd/kosli/serviceAccount_test.go deleted file mode 100644 index f32ab0a95..000000000 --- a/cmd/kosli/serviceAccount_test.go +++ /dev/null @@ -1,443 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "testing" - "time" - - "github.com/maxcnunes/httpfake" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -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 ServiceAccountApiKeysCommandTestSuite struct { - suite.Suite - defaultKosliArguments string -} - -func (suite *ServiceAccountApiKeysCommandTestSuite) 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 *ServiceAccountApiKeysCommandTestSuite) TestCreateApiKeyCmd() { - tests := []cmdTestCase{ - { - wantError: false, - name: "create builds the right url and payload (dry-run)", - cmd: "service-account api-keys create --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: "service-account api-keys create --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 sa/ak aliases work", - cmd: "sa ak create --service-account test-sa --description 'ci key' --dry-run" + suite.defaultKosliArguments, - goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys`, - }, - { - wantError: false, - name: "the create aliases and -s shorthand work", - cmd: "sa ak c -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: "service-account api-keys create --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: "service-account api-keys create --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: "service-account api-keys create --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 *ServiceAccountApiKeysCommandTestSuite) TestRotateApiKeyCmd() { - tests := []cmdTestCase{ - { - wantError: false, - name: "rotate without --grace-period-hours sends an empty payload (server owns the default)", - cmd: "service-account api-keys rotate 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: "service-account api-keys rotate key-123 --service-account test-sa --grace-period-hours 48 --dry-run" + suite.defaultKosliArguments, - goldenRegex: `"grace_period_hours": 48`, - }, - { - wantError: false, - name: "the rotate alias and -s shorthand work", - cmd: "sa ak ro 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: "sa ak ro 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: "service-account api-keys rotate 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: "service-account api-keys rotate --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: "service-account api-keys rotate key-123" + suite.defaultKosliArguments, - golden: "Error: required flag(s) \"service-account\" not set\n", - }, - } - - runTestCmd(suite.T(), tests) -} - -func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokeApiKeyCmd() { - tests := []cmdTestCase{ - { - wantError: false, - name: "revoke without confirmation (empty stdin) is cancelled and makes no call", - cmd: "service-account api-keys revoke key-123 --service-account test-sa" + suite.defaultKosliArguments, - golden: "Are you sure you want to revoke API key(s) key-123 for service account test-sa? [y/N]\nrevocation of API key(s) key-123 was cancelled\n", - }, - { - wantError: false, - name: "revoke accepts multiple KEY-IDs (dry-run)", - cmd: "service-account api-keys revoke 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: "revoke with --assume-yes and --dry-run builds the right url", - cmd: "service-account api-keys revoke 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: "revoke with --yes bypasses confirmation too", - cmd: "service-account api-keys revoke 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 del alias and -s shorthand work", - cmd: "sa ak del 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: "revoke fails when KEY-ID argument is missing", - cmd: "service-account api-keys revoke --service-account test-sa" + suite.defaultKosliArguments, - golden: "Error: requires at least 1 arg(s), only received 0\n", - }, - { - wantError: true, - name: "revoke fails when --service-account is missing", - cmd: "service-account api-keys revoke 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 *ServiceAccountApiKeysCommandTestSuite) 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(`{"id":"id-1","key":"sk_created","description":"ci","created_at":1780584129.5,"expires_at":0}`) - fake.NewHandler(). - Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys"). - Reply(200). - BodyString(`[{"id":"id-1","description":"ci","created_at":1780584129.5,"expires_at":0,"last_used_at":0}]`) - fake.NewHandler(). - Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1/rotate"). - Reply(201). - BodyString(`{"id":"k1","key":"sk_one","description":"one","created_at":1,"expires_at":0}`) - fake.NewHandler(). - Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate"). - Reply(201). - BodyString(`{"id":"k2","key":"sk_two","description":"two","created_at":1,"expires_at":0}`) - - 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: "service-account api-keys create -s test-sa -d ci --output json" + args, - goldenRegex: `sk_created`, - }, - { - wantError: false, - name: "list prints the returned keys", - cmd: "service-account api-keys list -s test-sa --output json" + args, - goldenRegex: `id-1`, - }, - { - wantError: false, - name: "rotate of multiple keys prints all new key values", - cmd: "service-account api-keys rotate k1 k2 -s test-sa --output json" + args, - goldenRegex: `(?s)sk_one.*sk_two`, - }, - } - - runTestCmd(suite.T(), tests) -} - -// TestRotatePartialFailure 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 *ServiceAccountApiKeysCommandTestSuite) TestRotatePartialFailure() { - 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(`{"id":"k1","key":"sk_one","description":"one","created_at":1,"expires_at":0}`) - fake.NewHandler(). - Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate"). - Reply(404). - BodyString(`{"message": "API key not found"}`) - - 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: "service-account api-keys rotate k1 k2 -s test-sa --output json" + args, - goldenRegex: `(?s)sk_one.*Error: API key not found`, - }, - } - - runTestCmd(suite.T(), tests) -} - -// TestRevokePartialFailure verifies that when one key in a multi-key revoke -// fails, the keys already revoked are reported (revocation is destructive and -// one-way) before the error is surfaced. -func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokePartialFailure() { - 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(`"revoked"`) - fake.NewHandler(). - Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2"). - Reply(404). - BodyString(`{"message": "API key not found"}`) - - args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) - tests := []cmdTestCase{ - { - wantError: true, - name: "revoke reports revoked keys before a later key fails", - cmd: "service-account api-keys revoke k1 k2 -s test-sa --assume-yes" + args, - goldenRegex: `(?s)API key k1 for service account test-sa was revoked.*already revoked before this failure: k1.*failed to revoke API key k2.*API key not found`, - }, - } - - runTestCmd(suite.T(), tests) -} - -// TestRevokeApiKeyNotFound stubs the API with a 404 to verify that revoking a -// non-existing key surfaces the server's "API key not found" error instead of -// reporting success. -func (suite *ServiceAccountApiKeysCommandTestSuite) TestRevokeApiKeyNotFound() { - 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(`{"message": "API key not found"}`) - - args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken) - tests := []cmdTestCase{ - { - wantError: true, - name: "revoke surfaces a 404 from the API as an error", - cmd: "service-account api-keys revoke missing-key --service-account test-sa --assume-yes" + args, - goldenRegex: `(?s)failed to revoke 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 *ServiceAccountApiKeysCommandTestSuite) 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(`{"message": "Service account not found"}`) - fake.NewHandler(). - Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key/rotate"). - Reply(404). - BodyString(`{"message": "API key not found"}`) - fake.NewHandler(). - Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys"). - Reply(403). - BodyString(`{"message": "You don't have permission to access this resource"}`) - - 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: "service-account api-keys create --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: "service-account api-keys rotate 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: "service-account api-keys list --service-account missing-sa" + args, - goldenRegex: `Error: You don't have permission to access this resource`, - }, - } - - runTestCmd(suite.T(), tests) -} - -func (suite *ServiceAccountApiKeysCommandTestSuite) TestListApiKeysCmd() { - tests := []cmdTestCase{ - { - wantError: true, - name: "list fails when --service-account is missing", - cmd: "service-account api-keys list" + suite.defaultKosliArguments, - golden: "Error: required flag(s) \"service-account\" not set\n", - }, - } - - runTestCmd(suite.T(), tests) -} - -func TestServiceAccountApiKeysCommandTestSuite(t *testing.T) { - suite.Run(t, new(ServiceAccountApiKeysCommandTestSuite)) -} From fe5bdeb0e5cff3a4193f1f7a09ed902377366d81 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 8 Jun 2026 23:53:41 +0100 Subject: [PATCH 10/10] refactor: switch to rotate verb --- cmd/kosli/apiKey_test.go | 52 ++++------- cmd/kosli/deleteApiKey.go | 32 ++++--- cmd/kosli/disable.go | 7 +- cmd/kosli/enable.go | 7 +- cmd/kosli/get.go | 7 +- cmd/kosli/listApiKeys.go | 2 +- cmd/kosli/log.go | 7 +- cmd/kosli/rename.go | 7 +- cmd/kosli/root.go | 3 +- cmd/kosli/rotate.go | 25 +++++ .../{updateApiKey.go => rotateApiKey.go} | 46 ++++------ cmd/kosli/status.go | 9 +- cmd/kosli/update.go | 25 ----- go.mod | 46 +++++----- go.sum | 92 +++++++++---------- 15 files changed, 171 insertions(+), 196 deletions(-) create mode 100644 cmd/kosli/rotate.go rename cmd/kosli/{updateApiKey.go => rotateApiKey.go} (74%) delete mode 100644 cmd/kosli/update.go diff --git a/cmd/kosli/apiKey_test.go b/cmd/kosli/apiKey_test.go index 2e93f80ff..2351e045c 100644 --- a/cmd/kosli/apiKey_test.go +++ b/cmd/kosli/apiKey_test.go @@ -43,7 +43,7 @@ func TestPrintApiKeyAsTable(t *testing.T) { } func TestPrintApiKeysAsTable(t *testing.T) { - // The update --rotate command aggregates one or more rotated keys into a JSON array. + // 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}]` @@ -163,60 +163,48 @@ func (suite *ApiKeyCommandTestSuite) TestCreateApiKeyCmd() { runTestCmd(suite.T(), tests) } -func (suite *ApiKeyCommandTestSuite) TestUpdateApiKeyCmd() { +func (suite *ApiKeyCommandTestSuite) TestRotateApiKeyCmd() { tests := []cmdTestCase{ { wantError: false, name: "rotate without --grace-period-hours sends an empty payload (server owns the default)", - cmd: "update api-key key-123 --rotate --service-account test-sa --dry-run" + suite.defaultKosliArguments, + 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: "update api-key key-123 --rotate --service-account test-sa --grace-period-hours 48 --dry-run" + suite.defaultKosliArguments, + 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: "update ak key-123 --rotate -s test-sa --dry-run" + suite.defaultKosliArguments, + 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 update verb aliases (up, u) work", - cmd: "u ak key-123 --rotate -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 -R, -g and -e shorthands work (dry-run)", - cmd: "update ak key-123 -R -s test-sa -g 1 -e 2026-6-5 --dry-run" + suite.defaultKosliArguments, + 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: "update --rotate accepts multiple KEY-IDs (dry-run)", - cmd: "update api-key key-1 key-2 --rotate --service-account test-sa --dry-run" + suite.defaultKosliArguments, + 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: "update without --rotate has nothing to do", - cmd: "update api-key key-123 --service-account test-sa" + suite.defaultKosliArguments, - goldenRegex: `Error: nothing to update`, - }, { wantError: true, - name: "update fails when KEY-ID argument is missing", - cmd: "update api-key --rotate --service-account test-sa" + suite.defaultKosliArguments, + 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: "update fails when --service-account is missing", - cmd: "update api-key key-123 --rotate" + suite.defaultKosliArguments, + 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", }, } @@ -274,7 +262,7 @@ func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() { } // TestApiKeysSuccessOutput stubs successful (2xx) API responses to verify that -// create/list/update render the server's response on the happy path. +// create/list/rotate render the server's response on the happy path. func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() { fake := httpfake.New() defer fake.Close() @@ -311,8 +299,8 @@ func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() { }, { wantError: false, - name: "update --rotate of multiple keys prints all rotated keys", - cmd: "update api-key k1 k2 --rotate -s test-sa --output json" + args, + 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"}, @@ -343,7 +331,7 @@ func (suite *ApiKeyCommandTestSuite) TestUpdatePartialFailure() { { wantError: true, name: "rotate prints already-rotated keys then surfaces the error", - cmd: "update api-key k1 k2 --rotate -s test-sa --output json" + args, + cmd: "rotate api-key k1 k2 -s test-sa --output json" + args, goldenRegex: `(?s)sk_one.*Error: API key not found`, }, } @@ -404,7 +392,7 @@ func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyNotFound() { } // TestApiErrorsAreSurfaced stubs the API with 4xx responses to verify that -// create/update/list surface the server's error message instead of succeeding. +// create/rotate/list surface the server's error message instead of succeeding. func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() { fake := httpfake.New() defer fake.Close() @@ -431,8 +419,8 @@ func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() { }, { wantError: true, - name: "update --rotate surfaces a 404 from the API as an error", - cmd: "update api-key missing-key --rotate --service-account test-sa" + args, + 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`, }, { diff --git a/cmd/kosli/deleteApiKey.go b/cmd/kosli/deleteApiKey.go index e5a1f1b97..990ac723b 100644 --- a/cmd/kosli/deleteApiKey.go +++ b/cmd/kosli/deleteApiKey.go @@ -12,12 +12,13 @@ import ( "github.com/spf13/cobra" ) -const deleteApiKeyShortDesc = `Delete (revoke) one or more API keys for a service account.` +const deleteApiKeyShortDesc = `Delete one or more API keys for a service account.` const deleteApiKeyLongDesc = deleteApiKeyShortDesc + ` -This permanently revokes the API key(s) identified by KEY-ID. You are asked to confirm -before the key is deleted; use ^--assume-yes^/^--yes^ to skip the confirmation prompt.` +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): @@ -94,9 +95,23 @@ func (o *deleteApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er } } + // 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 } @@ -107,16 +122,7 @@ func (o *deleteApiKeyOptions) run(out io.Writer, in io.Reader, args []string) er Token: global.ApiToken, } if _, err := kosliClient.Do(reqParams); err != nil { - // deletion is destructive and one-way: make clear which keys were - // already deleted before this one failed (user-facing, styled green), - // while keeping the returned error plain (no ANSI). - 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, ", ")) - } + reportAlreadyDeleted(i) return fmt.Errorf("failed to delete API key %s: %w", keyID, err) } if !global.DryRun { diff --git a/cmd/kosli/disable.go b/cmd/kosli/disable.go index ef39c784f..c3e3db2df 100644 --- a/cmd/kosli/disable.go +++ b/cmd/kosli/disable.go @@ -10,10 +10,9 @@ const disableDesc = `Kosli disable commands.` func newDisableCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "disable", - Aliases: []string{"dis"}, - Short: disableDesc, - Long: disableDesc, + Use: "disable", + Short: disableDesc, + Long: disableDesc, } // Add subcommands diff --git a/cmd/kosli/enable.go b/cmd/kosli/enable.go index 749356656..d29f5f7a1 100644 --- a/cmd/kosli/enable.go +++ b/cmd/kosli/enable.go @@ -10,10 +10,9 @@ const enableDesc = `Kosli enable commands.` func newEnableCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "enable", - Aliases: []string{"en"}, - Short: enableDesc, - Long: enableDesc, + Use: "enable", + Short: enableDesc, + Long: enableDesc, } // Add subcommands diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index c3f4db0e8..385f64916 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -10,10 +10,9 @@ const getDesc = `All Kosli get commands.` func newGetCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "get", - Aliases: []string{"g"}, - Short: getDesc, - Long: getDesc, + Use: "get", + Short: getDesc, + Long: getDesc, } // Add subcommands diff --git a/cmd/kosli/listApiKeys.go b/cmd/kosli/listApiKeys.go index b5b34f4f3..861b28bcb 100644 --- a/cmd/kosli/listApiKeys.go +++ b/cmd/kosli/listApiKeys.go @@ -36,7 +36,7 @@ func newListApiKeysCmd(out io.Writer) *cobra.Command { o := new(listApiKeysOptions) cmd := &cobra.Command{ Use: "api-keys", - Aliases: []string{"api-key", "ak"}, + Aliases: []string{"ak", "aks", "api-key"}, Short: listApiKeysShortDesc, Long: listApiKeysLongDesc, Example: listApiKeysExample, diff --git a/cmd/kosli/log.go b/cmd/kosli/log.go index 94df84575..78f1fc6c9 100644 --- a/cmd/kosli/log.go +++ b/cmd/kosli/log.go @@ -10,10 +10,9 @@ const logDesc = `All Kosli log commands.` func newLogCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "log", - Aliases: []string{"lo"}, - Short: logDesc, - Long: logDesc, + Use: "log", + Short: logDesc, + Long: logDesc, } // Add subcommands diff --git a/cmd/kosli/rename.go b/cmd/kosli/rename.go index a162ecf67..0dcc76a6b 100644 --- a/cmd/kosli/rename.go +++ b/cmd/kosli/rename.go @@ -10,10 +10,9 @@ const renameDesc = `All Kosli rename commands.` func newRenameCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ - Use: "rename", - Aliases: []string{"re"}, - Short: renameDesc, - Long: renameDesc, + Use: "rename", + Short: renameDesc, + Long: renameDesc, } // Add subcommands diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 43038e9ec..c39d5d0c9 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -111,7 +111,6 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, 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." - apiKeyRotateFlag = "Rotate the API key(s): generate a new key value and start the grace period on the old one. This is currently the only supported update." 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)" @@ -421,7 +420,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newDetachPolicyCmd(out), newEvaluateCmd(out), newDeleteCmd(out), - newUpdateCmd(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/updateApiKey.go b/cmd/kosli/rotateApiKey.go similarity index 74% rename from cmd/kosli/updateApiKey.go rename to cmd/kosli/rotateApiKey.go index 5f58f59f1..f82fcfac0 100644 --- a/cmd/kosli/updateApiKey.go +++ b/cmd/kosli/rotateApiKey.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "io" "net/http" "net/url" @@ -12,44 +11,38 @@ import ( "github.com/spf13/cobra" ) -const updateApiKeyShortDesc = `Update one or more API keys for a service account.` +const rotateApiKeyShortDesc = `Rotate one or more API keys for a service account.` -const updateApiKeyLongDesc = updateApiKeyShortDesc + ` +const rotateApiKeyLongDesc = rotateApiKeyShortDesc + ` -Currently the only supported update is rotation, requested with ^--rotate^. +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.` -When rotating, 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 updateApiKeyExample = ` +const rotateApiKeyExample = ` # rotate an API key for a service account: -kosli update api-key yourApiKeyID \ - --rotate \ +kosli rotate api-key yourApiKeyID \ --service-account yourServiceAccountName \ --api-token yourAPIToken \ --org yourOrgName # rotate multiple API keys at once: -kosli update api-key keyID1 keyID2 \ - --rotate \ +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 update api-key yourApiKeyID \ - --rotate \ +kosli rotate api-key yourApiKeyID \ --grace-period-hours 48 \ --service-account yourServiceAccountName \ --api-token yourAPIToken \ --org yourOrgName ` -type updateApiKeyOptions struct { +type rotateApiKeyOptions struct { serviceAccount string - rotate bool expiresAt string gracePeriodHours int gracePeriodHoursSet bool @@ -62,14 +55,14 @@ type rotateApiKeyPayload struct { ExpiresAt *int64 `json:"expires_at,omitempty"` } -func newUpdateApiKeyCmd(out io.Writer) *cobra.Command { - o := new(updateApiKeyOptions) +func newRotateApiKeyCmd(out io.Writer) *cobra.Command { + o := new(rotateApiKeyOptions) cmd := &cobra.Command{ Use: "api-key KEY-ID [KEY-ID...]", Aliases: []string{"ak"}, - Short: updateApiKeyShortDesc, - Long: updateApiKeyLongDesc, - Example: updateApiKeyExample, + 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 { @@ -84,7 +77,6 @@ func newUpdateApiKeyCmd(out io.Writer) *cobra.Command { } cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) - cmd.Flags().BoolVarP(&o.rotate, "rotate", "R", false, apiKeyRotateFlag) 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) @@ -98,11 +90,7 @@ func newUpdateApiKeyCmd(out io.Writer) *cobra.Command { return cmd } -func (o *updateApiKeyOptions) run(out io.Writer, args []string) error { - if !o.rotate { - return fmt.Errorf("nothing to update: use --rotate to rotate the API key(s)") - } - +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 { diff --git a/cmd/kosli/status.go b/cmd/kosli/status.go index 6941ee61b..1e50dcf61 100644 --- a/cmd/kosli/status.go +++ b/cmd/kosli/status.go @@ -23,11 +23,10 @@ type statusOptions struct { func newStatusCmd(out io.Writer) *cobra.Command { o := new(statusOptions) cmd := &cobra.Command{ - Use: "status", - Aliases: []string{"s", "st"}, - Short: statusShortDesc, - Long: statusLongDesc, - Args: cobra.NoArgs, + Use: "status", + Short: statusShortDesc, + Long: statusLongDesc, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return o.run(out) }, diff --git a/cmd/kosli/update.go b/cmd/kosli/update.go deleted file mode 100644 index 537d58b87..000000000 --- a/cmd/kosli/update.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "io" - - "github.com/spf13/cobra" -) - -const updateDesc = `All Kosli update commands.` - -func newUpdateCmd(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ - Use: "update", - Aliases: []string{"up", "u"}, - Short: updateDesc, - Long: updateDesc, - } - - // Add subcommands - cmd.AddCommand( - newUpdateApiKeyCmd(out), - ) - - return cmd -} 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=