diff --git a/cmd/kosli/apiKey.go b/cmd/kosli/apiKey.go index 0dfd62274..e2374f64e 100644 --- a/cmd/kosli/apiKey.go +++ b/cmd/kosli/apiKey.go @@ -4,10 +4,49 @@ import ( "encoding/json" "fmt" "io" + "net/url" "strconv" "time" + + "github.com/spf13/cobra" ) +// apiKeyOwner selects whose API keys a command manages: a service account's, +// or with --personal the caller's own. +type apiKeyOwner struct { + serviceAccount string + personal bool +} + +func (o *apiKeyOwner) addFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + cmd.Flags().BoolVar(&o.personal, "personal", false, personalApiKeyFlag) + cmd.MarkFlagsMutuallyExclusive("service-account", "personal") +} + +// validate checks the owner and the global flags it needs. The owner is not a +// cobra one-required flag group because that counts --personal=false as given. +// Org is not needed for personal keys, which belong to the user rather than to +// an org. +func (o *apiKeyOwner) validate() error { + if o.personal { + return RequireGlobalFlags(global, []string{"ApiToken"}) + } + if o.serviceAccount == "" { + return fmt.Errorf("--service-account is required unless --personal is set") + } + return RequireGlobalFlags(global, []string{"Org", "ApiToken"}) +} + +// url builds the owner's API key collection URL, followed by elem. +func (o *apiKeyOwner) url(elem ...string) (string, error) { + base := []string{"api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys"} + if o.personal { + base = []string{"api/v2/user/api-keys"} + } + return url.JoinPath(global.Host, append(base, elem...)...) +} + // 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 { diff --git a/cmd/kosli/apiKey_test.go b/cmd/kosli/apiKey_test.go index 386b92b86..67704ccd2 100644 --- a/cmd/kosli/apiKey_test.go +++ b/cmd/kosli/apiKey_test.go @@ -160,10 +160,16 @@ func (suite *ApiKeyCommandTestSuite) TestCreateApiKeyCmd() { 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 neither --service-account nor --personal is set", + cmd: "create api-key --description 'ci key'" + suite.defaultKosliArguments, + goldenRegex: `Error: --service-account is required unless --personal is set`, + }, + { + wantError: false, + name: "create --personal posts to the user's keys without --org (dry-run)", + cmd: fmt.Sprintf("create api-key --personal --description 'ci key' --dry-run --host %s --api-token %s", global.Host, global.ApiToken), + goldenRegex: `(?s)/api/v2/user/api-keys.*"description": "ci key"`, }, { wantError: true, @@ -221,10 +227,16 @@ func (suite *ApiKeyCommandTestSuite) TestRotateApiKeyCmd() { golden: "Error: requires at least 1 arg(s), only received 0\n", }, { - wantError: true, - name: "rotate fails when --service-account is missing", - cmd: "rotate api-key key-123" + suite.defaultKosliArguments, - golden: "Error: required flag(s) \"service-account\" not set\n", + wantError: true, + name: "rotate fails when neither --service-account nor --personal is set", + cmd: "rotate api-key key-123" + suite.defaultKosliArguments, + goldenRegex: `Error: --service-account is required unless --personal is set`, + }, + { + wantError: false, + name: "rotate --personal posts to the user's key without --org (dry-run)", + cmd: fmt.Sprintf("rotate api-key key-123 --personal --dry-run --host %s --api-token %s", global.Host, global.ApiToken), + goldenRegex: `/api/v2/user/api-keys/key-123/rotate`, }, } @@ -278,10 +290,23 @@ func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() { 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", + wantError: true, + name: "delete fails when neither --service-account nor --personal is set", + cmd: "delete api-key key-123" + suite.defaultKosliArguments, + goldenRegex: `Error: --service-account is required unless --personal is set`, + }, + { + wantError: false, + name: "a refusal to delete personal keys names them as personal", + cmd: fmt.Sprintf("delete api-key key-123 --personal --host %s --api-token %s", global.Host, global.ApiToken), + stdin: "n\n", + golden: "Are you sure you want to delete your personal API key(s) key-123? [y/N] Deletion of API key(s) key-123 was cancelled.\n", + }, + { + wantError: false, + name: "delete --personal targets the user's key without --org (dry-run)", + cmd: fmt.Sprintf("delete api-key key-123 --personal -y --dry-run --host %s --api-token %s", global.Host, global.ApiToken), + goldenRegex: `/api/v2/user/api-keys/key-123`, }, } @@ -291,10 +316,10 @@ func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() { func (suite *ApiKeyCommandTestSuite) TestGetApiKeyCmd() { tests := []cmdTestCase{ { - wantError: true, - name: "get fails when --service-account is missing", - cmd: "get api-key key-123" + suite.defaultKosliArguments, - golden: "Error: required flag(s) \"service-account\" not set\n", + wantError: true, + name: "get fails when neither --service-account nor --personal is set", + cmd: "get api-key key-123" + suite.defaultKosliArguments, + goldenRegex: `Error: --service-account is required unless --personal is set`, }, { wantError: true, @@ -550,10 +575,113 @@ func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() { 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", + wantError: true, + name: "list fails when neither --service-account nor --personal is set", + cmd: "list api-keys" + suite.defaultKosliArguments, + goldenRegex: `Error: --service-account is required unless --personal is set`, + }, + { + wantError: true, + name: "list fails when both --service-account and --personal are set", + cmd: "list api-keys --service-account test-sa --personal" + suite.defaultKosliArguments, + goldenRegex: `Error: if any flags in the group \[service-account personal\] are set none of the others can be`, + }, + } + + runTestCmd(suite.T(), tests) +} + +// TestPersonalFalseNeedsServiceAccount covers --personal=false: cobra counts the +// flag as given, so only the owner check stops a request with no owner at all. +func (suite *ApiKeyCommandTestSuite) TestPersonalFalseNeedsServiceAccount() { + wantErr := `Error: --service-account is required unless --personal is set` + tests := []cmdTestCase{} + for _, cmd := range []string{ + "list api-keys", + "get api-key key-123", + "create api-key --description 'ci key'", + "rotate api-key key-123", + "delete api-key key-123 --assume-yes", + } { + tests = append(tests, cmdTestCase{ + wantError: true, + name: cmd + " with --personal=false and no --service-account fails", + cmd: cmd + " --personal=false" + suite.defaultKosliArguments, + goldenRegex: wantErr, + }) + } + + runTestCmd(suite.T(), tests) +} + +func (suite *ApiKeyCommandTestSuite) TestPersonalApiKeysCmd() { + fake := httpfake.New() + defer fake.Close() + fake.NewHandler(). + Get("/api/v2/user/api-keys"). + Reply(200). + BodyString(apiKeyFixture(suite.T(), "listed_api_keys.json")) + fake.NewHandler(). + Get("/api/v2/user/api-keys/id-1"). + Reply(200). + BodyString(apiKeyFixture(suite.T(), "api_key.json")) + fake.NewHandler(). + Post("/api/v2/user/api-keys"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "created_api_key.json")) + fake.NewHandler(). + Post("/api/v2/user/api-keys/k1/rotate"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json")) + fake.NewHandler(). + Post("/api/v2/user/api-keys/k2/rotate"). + Reply(201). + BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json")) + fake.NewHandler(). + Delete("/api/v2/user/api-keys/key-123"). + Reply(200). + BodyString(apiKeyFixture(suite.T(), "revoke_success.json")) + + // No --org: personal keys belong to the user, not to an org. + args := fmt.Sprintf(" --host %s --api-token %s", fake.Server.URL, global.ApiToken) + tests := []cmdTestCase{ + { + wantError: false, + name: "list --personal prints the caller's keys without --org", + cmd: "list api-keys --personal --output json" + args, + goldenJson: []jsonCheck{ + {Path: "[0].id", Want: "id-1"}, + }, + }, + { + wantError: false, + name: "get --personal prints one of the caller's keys without --org", + cmd: "get api-key id-1 --personal --output json" + args, + goldenJson: []jsonCheck{ + {Path: "id", Want: "id-1"}, + }, + }, + { + wantError: false, + name: "create --personal prints the new key value", + cmd: "create api-key --personal -d ci --output json" + args, + goldenRegex: `sk_created`, + }, + { + wantError: false, + name: "rotate --personal of multiple keys prints all rotated keys", + cmd: "rotate api-key k1 k2 --personal --output json" + args, + goldenJson: []jsonCheck{ + {Path: "", Want: "length:2"}, + {Path: "[0].key", Want: "sk_one"}, + }, + }, + { + wantError: false, + name: "delete --personal confirms and reports the personal key deleted", + cmd: "delete api-key key-123 --personal" + args, + stdin: "y", + goldenRegex: `Personal API key key-123 was deleted!`, }, } diff --git a/cmd/kosli/createApiKey.go b/cmd/kosli/createApiKey.go index a7f30cc6f..47f19f6d2 100644 --- a/cmd/kosli/createApiKey.go +++ b/cmd/kosli/createApiKey.go @@ -3,21 +3,23 @@ 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 createApiKeyShortDesc = `Create an API key for a service account, or a personal API key for yourself.` const createApiKeyLongDesc = createApiKeyShortDesc + ` The key value is only returned once, at creation time, so make sure to store it securely. Every API key expires. Without ^--expires-at^ the key is given the maximum allowed -lifetime, and an ^--expires-at^ beyond that maximum is capped to it.` +lifetime, and an ^--expires-at^ beyond that maximum is capped to it. + +With ^--personal^, a key created using another API key is revoked along with that key, +and expires no later than it.` const createApiKeyExample = ` # create an API key for a service account: @@ -34,13 +36,19 @@ kosli create api-key \ --expires-at 2026-12-31 \ --api-token yourAPIToken \ --org yourOrgName + +# create a personal API key for yourself: +kosli create api-key \ + --personal \ + --description "key for CI" \ + --api-token yourAPIToken ` type createApiKeyOptions struct { - serviceAccount string - expiresAt string - output string - payload createApiKeyPayload + owner apiKeyOwner + expiresAt string + output string + payload createApiKeyPayload } type createApiKeyPayload struct { @@ -58,7 +66,7 @@ func newCreateApiKeyCmd(out io.Writer) *cobra.Command { Example: createApiKeyExample, Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { - if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + if err := o.owner.validate(); err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } return nil @@ -68,13 +76,13 @@ func newCreateApiKeyCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + o.owner.addFlags(cmd) cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", apiKeyDescriptionFlag) cmd.Flags().StringVarP(&o.expiresAt, "expires-at", "e", "", createApiKeyExpiresAtFlag) cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) addDryRunFlag(cmd) - err := RequireFlags(cmd, []string{"service-account", "description"}) + err := RequireFlags(cmd, []string{"description"}) if err != nil { logger.Error("failed to configure required flags: %v", err) } @@ -83,7 +91,7 @@ func newCreateApiKeyCmd(out io.Writer) *cobra.Command { } 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") + url, err := o.owner.url() if err != nil { return err } diff --git a/cmd/kosli/deleteApiKey.go b/cmd/kosli/deleteApiKey.go index 9a49ae028..b93dafd75 100644 --- a/cmd/kosli/deleteApiKey.go +++ b/cmd/kosli/deleteApiKey.go @@ -4,14 +4,13 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) -const deleteApiKeyShortDesc = `Delete one or more API keys for a service account.` +const deleteApiKeyShortDesc = `Delete one or more API keys for a service account, or your own personal API keys.` const deleteApiKeyLongDesc = deleteApiKeyShortDesc + ` @@ -20,7 +19,10 @@ cannot be undone. You are asked to confirm before the key is deleted; use ^--assume-yes^/^--yes^ to skip the confirmation prompt. When stdin is not interactive (e.g. in CI) the prompt cannot be answered and the -command fails without deleting anything, so pass ^--assume-yes^ there.` +command fails without deleting anything, so pass ^--assume-yes^ there. + +Deleting a key also deletes every key that was created with it. With ^--personal^ and an +API key as ^--api-token^, you can delete only that key and keys created with it.` const deleteApiKeyExample = ` # delete an API key for a service account (asks for confirmation): @@ -41,11 +43,16 @@ kosli delete api-key yourApiKeyID \ --assume-yes \ --api-token yourAPIToken \ --org yourOrgName + +# delete one of your own personal API keys: +kosli delete api-key yourApiKeyID \ + --personal \ + --api-token yourAPIToken ` type deleteApiKeyOptions struct { - serviceAccount string - assumeYes bool + owner apiKeyOwner + assumeYes bool } func newDeleteApiKeyCmd(out io.Writer) *cobra.Command { @@ -58,7 +65,7 @@ func newDeleteApiKeyCmd(out io.Writer) *cobra.Command { Example: deleteApiKeyExample, Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + if err := o.owner.validate(); err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } return nil @@ -68,7 +75,7 @@ func newDeleteApiKeyCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + o.owner.addFlags(cmd) 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) @@ -77,17 +84,12 @@ func newDeleteApiKeyCmd(out io.Writer) *cobra.Command { } 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(in io.Reader, args []string) error { if !o.assumeYes && !global.DryRun { - confirmed, err := confirmApiKeyDeletion(args, o.serviceAccount, in) + confirmed, err := confirmApiKeyDeletion(args, o.owner, in) if err != nil { return err } @@ -106,7 +108,7 @@ func (o *deleteApiKeyOptions) run(in io.Reader, args []string) error { } for i, keyID := range args { - url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys", keyID) + url, err := o.owner.url(keyID) if err != nil { reportAlreadyDeleted(i) return err @@ -123,7 +125,12 @@ func (o *deleteApiKeyOptions) run(in io.Reader, args []string) error { return fmt.Errorf("failed to delete API key: %w", err) } if !global.DryRun { - logger.Info("API key %s for service account %s was deleted!", style(logger.Out, keyID, ansiBold, ansiCyan), o.serviceAccount) + styledKey := style(logger.Out, keyID, ansiBold, ansiCyan) + if o.owner.personal { + logger.Info("Personal API key %s was deleted!", styledKey) + } else { + logger.Info("API key %s for service account %s was deleted!", styledKey, o.owner.serviceAccount) + } } } return nil @@ -142,8 +149,12 @@ func styleApiKeyIDs(keyIDs []string) []string { // confirmApiKeyDeletion prompts the user to confirm deletion and returns true // only when the answer is an affirmative "y"/"yes" (case-insensitive). It errors // when the prompt cannot be answered at all, see confirmDeletion. -func confirmApiKeyDeletion(keyIDs []string, serviceAccount string, in io.Reader) (bool, error) { - prompt := fmt.Sprintf("Are you sure you want to delete API key(s) %s for service account %s? [y/N] ", - strings.Join(styleApiKeyIDs(keyIDs), ", "), style(logger.Out, serviceAccount, ansiBold, ansiGreen)) +func confirmApiKeyDeletion(keyIDs []string, owner apiKeyOwner, in io.Reader) (bool, error) { + keys := strings.Join(styleApiKeyIDs(keyIDs), ", ") + prompt := fmt.Sprintf("Are you sure you want to delete your personal API key(s) %s? [y/N] ", keys) + if !owner.personal { + prompt = fmt.Sprintf("Are you sure you want to delete API key(s) %s for service account %s? [y/N] ", + keys, style(logger.Out, owner.serviceAccount, ansiBold, ansiGreen)) + } return confirmDeletion(prompt, in) } diff --git a/cmd/kosli/getApiKey.go b/cmd/kosli/getApiKey.go index b8ac5feb8..7436f2e91 100644 --- a/cmd/kosli/getApiKey.go +++ b/cmd/kosli/getApiKey.go @@ -3,14 +3,13 @@ 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 getApiKeyShortDesc = `Get an API key's metadata for a service account.` +const getApiKeyShortDesc = `Get an API key's metadata for a service account, or one of your own personal API keys.` const getApiKeyLongDesc = getApiKeyShortDesc + ` @@ -23,11 +22,16 @@ kosli get api-key yourApiKeyID \ --service-account yourServiceAccountName \ --api-token yourAPIToken \ --org yourOrgName + +# get the metadata of one of your own personal API keys: +kosli get api-key yourApiKeyID \ + --personal \ + --api-token yourAPIToken ` type getApiKeyOptions struct { - serviceAccount string - output string + owner apiKeyOwner + output string } func newGetApiKeyCmd(out io.Writer) *cobra.Command { @@ -40,7 +44,7 @@ func newGetApiKeyCmd(out io.Writer) *cobra.Command { Example: getApiKeyExample, Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + if err := o.owner.validate(); err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } return nil @@ -50,19 +54,14 @@ func newGetApiKeyCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + o.owner.addFlags(cmd) 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 *getApiKeyOptions) run(out io.Writer, args []string) error { - url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys", args[0]) + url, err := o.owner.url(args[0]) if err != nil { return err } diff --git a/cmd/kosli/listApiKeys.go b/cmd/kosli/listApiKeys.go index 69bdb2082..9fad9bd9c 100644 --- a/cmd/kosli/listApiKeys.go +++ b/cmd/kosli/listApiKeys.go @@ -5,14 +5,13 @@ import ( "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 listApiKeysShortDesc = `List API keys for a service account, or your own personal API keys.` const listApiKeysLongDesc = listApiKeysShortDesc + ` @@ -25,11 +24,16 @@ kosli list api-keys \ --service-account yourServiceAccountName \ --api-token yourAPIToken \ --org yourOrgName + +# list your own personal API keys: +kosli list api-keys \ + --personal \ + --api-token yourAPIToken ` type listApiKeysOptions struct { - serviceAccount string - output string + owner apiKeyOwner + output string } func newListApiKeysCmd(out io.Writer) *cobra.Command { @@ -42,7 +46,7 @@ func newListApiKeysCmd(out io.Writer) *cobra.Command { Example: listApiKeysExample, Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { - if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + if err := o.owner.validate(); err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } return nil @@ -52,19 +56,14 @@ func newListApiKeysCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + o.owner.addFlags(cmd) 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") + url, err := o.owner.url() if err != nil { return err } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index db0a779a8..074431e5a 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -171,7 +171,8 @@ Paths the list already matches stay excluded whatever is later added there, so k outputFlagWithMarkdown = "[defaulted] The format of the output. Valid formats are: [table, json, markdown]." searchByNameFlag = "[optional] Only list flows whose name contains this substring. The Kosli API supports alphanumeric characters and '-'." ignoreCaseFlag = "[optional] Perform case-insensitive matching for --name. By default matching is case sensitive." - serviceAccountNameFlag = "The name of the service account whose API keys are managed." + serviceAccountNameFlag = "The name of the service account whose API keys are managed. Either this or --personal is required." + personalApiKeyFlag = "Manage your own personal API keys instead of a service account's. --org is not needed. Either this or --service-account is required." serviceAccountDescriptionFlag = "[optional] A description for the service account." serviceAccountPrivilegeFlag = "The privilege granted to the service account. One of: [" + validServiceAccountPrivilegesList + "]." serviceAccountAssumeYesFlag = "[optional] Skip the confirmation prompt and delete the service account without asking. (alias: --yes)" diff --git a/cmd/kosli/rotateApiKey.go b/cmd/kosli/rotateApiKey.go index 18697fe66..d6527fe20 100644 --- a/cmd/kosli/rotateApiKey.go +++ b/cmd/kosli/rotateApiKey.go @@ -5,21 +5,24 @@ import ( "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 rotateApiKeyShortDesc = `Rotate one or more API keys for a service account.` +const rotateApiKeyShortDesc = `Rotate one or more API keys for a service account, or your own personal API keys.` 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; that grace period has a standard length unless overridden with ^--grace-period-hours^. The new key value is only returned once, so make -sure to store it securely.` +sure to store it securely. + +With ^--personal^ and an API key as ^--api-token^, you can rotate only that key and keys +created with it, and the rotated key expires no later than that key. To get a key that +outlives it, create one from your profile page in the Kosli app.` const rotateApiKeyExample = ` # rotate an API key for a service account: @@ -40,10 +43,15 @@ kosli rotate api-key yourApiKeyID \ --service-account yourServiceAccountName \ --api-token yourAPIToken \ --org yourOrgName + +# rotate one of your own personal API keys: +kosli rotate api-key yourApiKeyID \ + --personal \ + --api-token yourAPIToken ` type rotateApiKeyOptions struct { - serviceAccount string + owner apiKeyOwner expiresAt string gracePeriodHours int gracePeriodHoursSet bool @@ -66,7 +74,7 @@ func newRotateApiKeyCmd(out io.Writer) *cobra.Command { Example: rotateApiKeyExample, Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil { + if err := o.owner.validate(); err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } return nil @@ -77,17 +85,12 @@ func newRotateApiKeyCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag) + o.owner.addFlags(cmd) cmd.Flags().StringVarP(&o.expiresAt, "expires-at", "e", "", rotateApiKeyExpiresAtFlag) 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 } @@ -111,7 +114,7 @@ func (o *rotateApiKeyOptions) run(out io.Writer, args []string) error { 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") + url, err := o.owner.url(keyID, "rotate") if err != nil { runErr = err break diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index d2247dba3..51bd5e8e6 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -523,6 +523,7 @@ "dry-run": "bool", "expires-at": "string", "output": "string", + "personal": "bool", "service-account": "string" }, "create attestation-type": { @@ -569,6 +570,7 @@ "delete api-key": { "assume-yes": "bool", "dry-run": "bool", + "personal": "bool", "service-account": "string", "yes": "bool" }, @@ -647,6 +649,7 @@ }, "get api-key": { "output": "string", + "personal": "bool", "service-account": "string" }, "get artifact": { @@ -705,6 +708,7 @@ }, "list api-keys": { "output": "string", + "personal": "bool", "service-account": "string" }, "list artifacts": { @@ -831,6 +835,7 @@ "expires-at": "string", "grace-period-hours": "int", "output": "string", + "personal": "bool", "service-account": "string" }, "search": { diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index bd3a1cf80..18bdc5fc2 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1352,6 +1352,7 @@ "dry-run", "expires-at", "output", + "personal", "service-account" ], "flag_values": { @@ -1359,6 +1360,7 @@ "dry-run": "true", "expires-at": "4102444800", "output": "json", + "personal": "true", "service-account": "{account}" }, "setup": [ @@ -1372,7 +1374,12 @@ ] } ], - "verify": [] + "verify": [], + "flags_for_flag": { + "personal": { + "description": "probe-description" + } + } }, "create control": { "args": [ @@ -1644,12 +1651,14 @@ "flags_to_test": [ "assume-yes", "dry-run", + "personal", "service-account", "yes" ], "flag_values": { "assume-yes": "true", "dry-run": "true", + "personal": "true", "service-account": "{account}", "yes": "true" }, @@ -1676,9 +1685,32 @@ "apikey", "ID:\\s+(\\S+)" ] + }, + { + "argv": [ + "create", + "api-key", + "--personal", + "--description", + "probe" + ], + "capture": [ + "personalkey", + "ID:\\s+(\\S+)" + ] } ], - "verify": [] + "verify": [], + "flags_for_flag": { + "personal": { + "assume-yes": "true" + } + }, + "args_for_flag": { + "personal": [ + "{personalkey}" + ] + } }, "diff snapshots": { "args": [ @@ -1932,10 +1964,12 @@ "baseline_output": "ID: {apikey}\nDescription: probe\nCreated At: {timestamp} \u2022 {ago}\nExpires At: N/A\nLast Used: N/A", "flags_to_test": [ "output", + "personal", "service-account" ], "flag_values": { "output": "json", + "personal": "true", "service-account": "{account}" }, "setup": [ @@ -1961,9 +1995,30 @@ "apikey", "ID:\\s+(\\S+)" ] + }, + { + "argv": [ + "create", + "api-key", + "--personal", + "--description", + "probe" + ], + "capture": [ + "personalkey", + "ID:\\s+(\\S+)" + ] } ], - "verify": [] + "verify": [], + "flags_for_flag": { + "personal": {} + }, + "args_for_flag": { + "personal": [ + "{personalkey}" + ] + } }, "get artifact": { "args": [ @@ -2379,10 +2434,12 @@ "baseline_output": "ID DESCRIPTION CREATED EXPIRES LAST USED\n{apikey} probe {timestamp} \u2022 {ago} N/A N/A", "flags_to_test": [ "output", + "personal", "service-account" ], "flag_values": { "output": "json", + "personal": "true", "service-account": "{account}" }, "setup": [ @@ -2410,7 +2467,10 @@ ] } ], - "verify": [] + "verify": [], + "flags_for_flag": { + "personal": {} + } }, "list artifacts": { "args": [], @@ -2775,6 +2835,7 @@ "expires-at", "grace-period-hours", "output", + "personal", "service-account" ], "flag_values": { @@ -2782,6 +2843,7 @@ "expires-at": "4102444800", "grace-period-hours": "24", "output": "json", + "personal": "true", "service-account": "{account}" }, "setup": [ @@ -2807,9 +2869,30 @@ "apikey", "ID:\\s+(\\S+)" ] + }, + { + "argv": [ + "create", + "api-key", + "--personal", + "--description", + "probe" + ], + "capture": [ + "personalkey", + "ID:\\s+(\\S+)" + ] } ], - "verify": [] + "verify": [], + "flags_for_flag": { + "personal": {} + }, + "args_for_flag": { + "personal": [ + "{personalkey}" + ] + } }, "search": { "args": [