Skip to content
39 changes: 39 additions & 0 deletions cmd/kosli/apiKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
168 changes: 148 additions & 20 deletions cmd/kosli/apiKey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
},
}

Expand Down Expand Up @@ -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`,
},
}

Expand All @@ -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,
Expand Down Expand Up @@ -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!`,
},
}

Expand Down
30 changes: 19 additions & 11 deletions cmd/kosli/createApiKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down
Loading
Loading