diff --git a/cmd/kosli/apiKey.go b/cmd/kosli/apiKey.go index ffc155051..9e48d851d 100644 --- a/cmd/kosli/apiKey.go +++ b/cmd/kosli/apiKey.go @@ -29,6 +29,19 @@ type apiKeyMetadata struct { LastUsedAt float64 `json:"last_used_at"` } +// apiKeyMetadataTimestamps formats the created/expires/last-used timestamps +// of a key's metadata for table output. +func apiKeyMetadataTimestamps(key apiKeyMetadata) (createdAt, expiresAt, lastUsedAt string, err error) { + if createdAt, err = formattedTimestamp(key.CreatedAt, false); err != nil { + return + } + if expiresAt, err = optionalTimestamp(key.ExpiresAt); err != nil { + return + } + lastUsedAt, err = optionalTimestamp(key.LastUsedAt) + return +} + // 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. @@ -93,6 +106,30 @@ func printApiKeysAsTable(raw string, out io.Writer, page int) error { return nil } +// printApiKeyMetadataAsTable renders a single api key's metadata (the get +// response) as a table. The secret key value is never part of this response. +func printApiKeyMetadataAsTable(raw string, out io.Writer, page int) error { + var key apiKeyMetadata + if err := json.Unmarshal([]byte(raw), &key); err != nil { + return err + } + + createdAt, expiresAt, lastUsedAt, err := apiKeyMetadataTimestamps(key) + if err != nil { + return err + } + + rows := []string{ + fmt.Sprintf("ID:\t%s", key.Id), + fmt.Sprintf("Description:\t%s", key.Description), + fmt.Sprintf("Created At:\t%s", createdAt), + fmt.Sprintf("Expires At:\t%s", expiresAt), + fmt.Sprintf("Last Used:\t%s", lastUsedAt), + } + 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) { diff --git a/cmd/kosli/apiKey_test.go b/cmd/kosli/apiKey_test.go index 3e19f31f0..11116b6dd 100644 --- a/cmd/kosli/apiKey_test.go +++ b/cmd/kosli/apiKey_test.go @@ -58,6 +58,25 @@ func TestPrintApiKeysAsTable(t *testing.T) { require.Contains(t, out, "sk_two") } +func TestPrintApiKeyMetadataAsTable(t *testing.T) { + // The get endpoint returns key metadata only (no secret key value), with + // timestamps as floating-point epoch seconds. + raw := `{"id":"key-1","description":"ci key","created_at":1780584129.6878593,"expires_at":0,"last_used_at":0}` + + var buf bytes.Buffer + err := printApiKeyMetadataAsTable(raw, &buf, 0) + require.NoError(t, err) + + out := buf.String() + require.Contains(t, out, "key-1") + require.Contains(t, out, "ci key") + // expires_at and last_used_at of 0 mean "never" and must render as N/A, + // not epoch zero (1970) + require.Regexp(t, `Expires At:\s+N/A`, out) + require.Regexp(t, `Last Used:\s+N/A`, out) + require.NotContains(t, out, "1970") +} + 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},` + @@ -261,8 +280,33 @@ func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() { runTestCmd(suite.T(), tests) } +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 KEY-ID argument is missing", + cmd: "get api-key --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "get fails when more than one KEY-ID argument is given", + cmd: "get api-key key-1 key-2 --service-account test-sa" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 2\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. +// create/list/rotate/get render the server's response on the happy path. func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() { fake := httpfake.New() defer fake.Close() @@ -282,9 +326,34 @@ func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() { 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")) + fake.NewHandler(). + Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/id-1"). + Reply(200). + BodyString(apiKeyFixture(suite.T(), "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: "get prints the key metadata as json", + cmd: "get api-key id-1 -s test-sa --output json" + args, + goldenJson: []jsonCheck{ + {Path: "id", Want: "id-1"}, + {Path: "description", Want: "ci"}, + }, + }, + { + wantError: false, + name: "get renders the key metadata as a table by default", + cmd: "get api-key id-1 -s test-sa" + args, + goldenRegex: `(?s)ID:\s+id-1.*Description:\s+ci.*Expires At:\s+N/A.*Last Used:\s+N/A`, + }, + { + wantError: false, + name: "the get api-key alias (ak) and -s shorthand work", + cmd: "get ak id-1 -s test-sa --output json" + args, + goldenRegex: `id-1`, + }, { wantError: false, name: "create prints the new key value", @@ -408,6 +477,10 @@ func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() { Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys"). Reply(403). BodyString(apiKeyFixture(suite.T(), "error_forbidden.json")) + fake.NewHandler(). + Get("/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{ @@ -429,6 +502,12 @@ func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() { cmd: "list api-keys --service-account missing-sa" + args, goldenRegex: `Error: You don't have permission to access this resource`, }, + { + wantError: true, + name: "get surfaces a 404 from the API as an error", + cmd: "get api-key missing-key --service-account test-sa" + args, + goldenRegex: `Error: API key not found`, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index 1c60ed89f..5851bc6ac 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -18,6 +18,7 @@ func newGetCmd(out io.Writer) *cobra.Command { // Add subcommands cmd.AddCommand( + newGetApiKeyCmd(out), newGetApprovalCmd(out), newGetArtifactCmd(out), newGetEnvironmentCmd(out), diff --git a/cmd/kosli/getApiKey.go b/cmd/kosli/getApiKey.go new file mode 100644 index 000000000..b8ac5feb8 --- /dev/null +++ b/cmd/kosli/getApiKey.go @@ -0,0 +1,85 @@ +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 getApiKeyLongDesc = getApiKeyShortDesc + ` + +Only the metadata of the API key is returned; the key value itself is never +returned (it is only shown once, at creation or rotation time).` + +const getApiKeyExample = ` +# get the metadata of an API key: +kosli get api-key yourApiKeyID \ + --service-account yourServiceAccountName \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type getApiKeyOptions struct { + serviceAccount string + output string +} + +func newGetApiKeyCmd(out io.Writer) *cobra.Command { + o := new(getApiKeyOptions) + cmd := &cobra.Command{ + Use: "api-key KEY-ID", + Aliases: []string{"ak"}, + Short: getApiKeyShortDesc, + Long: getApiKeyLongDesc, + Example: getApiKeyExample, + Args: cobra.ExactArgs(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.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]) + 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": printApiKeyMetadataAsTable, + "json": output.PrintJson, + }) +} diff --git a/cmd/kosli/listApiKeys.go b/cmd/kosli/listApiKeys.go index 861b28bcb..69bdb2082 100644 --- a/cmd/kosli/listApiKeys.go +++ b/cmd/kosli/listApiKeys.go @@ -100,15 +100,7 @@ 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.CreatedAt, false) - if err != nil { - return err - } - expiresAt, err := optionalTimestamp(key.ExpiresAt) - if err != nil { - return err - } - lastUsedAt, err := optionalTimestamp(key.LastUsedAt) + createdAt, expiresAt, lastUsedAt, err := apiKeyMetadataTimestamps(key) if err != nil { return err } diff --git a/cmd/kosli/testdata/service-account/README.md b/cmd/kosli/testdata/service-account/README.md index 333d978f1..00ee38812 100644 --- a/cmd/kosli/testdata/service-account/README.md +++ b/cmd/kosli/testdata/service-account/README.md @@ -13,6 +13,7 @@ response contract lives in one place. | `created_api_key.json` | `POST .../{name}/api-keys` → `201` (create) | | `rotated_api_key.json` | `POST .../{name}/api-keys/{key_id}/rotate` → `201` (rotate; includes `grace_period_expires_at`) | | `listed_api_keys.json` | `GET .../{name}/api-keys` → `200` (list) | +| `api_key.json` | `GET .../{name}/api-keys/{key_id}` → `200` (get; metadata only) | | `revoke_success.json` | `DELETE .../{name}/api-keys/{key_id}` → `200` (bare string) | ### Service account management endpoints diff --git a/cmd/kosli/testdata/service-account/api_key.json b/cmd/kosli/testdata/service-account/api_key.json new file mode 100644 index 000000000..1b3b17cab --- /dev/null +++ b/cmd/kosli/testdata/service-account/api_key.json @@ -0,0 +1 @@ +{"id":"id-1","description":"ci","created_at":1780584129.5,"expires_at":0,"last_used_at":0}