Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/kernel/cli/pkg/interactive"
"github.com/kernel/cli/pkg/util"
Expand Down Expand Up @@ -30,7 +33,9 @@ type APIKeysCmd struct {
type APIKeysCreateInput struct {
Name string
DaysToExpire Int64Flag
ExpiresAt string
ProjectID string
Policy string
Output string
}

Expand Down Expand Up @@ -71,6 +76,29 @@ type APIKeysDeleteInput struct {
SkipConfirm bool
}

func parseAPIKeyPolicy(raw string) (kernel.APIKeyNewParamsPolicy, bool, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return kernel.APIKeyNewParamsPolicy{}, false, nil
}
if !strings.HasPrefix(trimmed, "{") {
return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy must be a JSON object with a statements array")
}
var policy kernel.APIKeyNewParamsPolicy
if err := json.Unmarshal([]byte(trimmed), &policy); err != nil {
return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy must be a JSON object with a statements array: %w", err)
}
if len(policy.Statements) < 1 || len(policy.Statements) > 256 {
return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy statements must contain between 1 and 256 entries")
}
for i, statement := range policy.Statements {
if strings.TrimSpace(statement.Action) == "" || strings.TrimSpace(statement.Resource) == "" {
return kernel.APIKeyNewParamsPolicy{}, false, fmt.Errorf("--policy statements[%d] requires non-empty action and resource", i)
}
}
return policy, true, nil
}

func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error {
if err := validateJSONOutput(in.Output); err != nil {
return err
Expand All @@ -79,16 +107,37 @@ func (c APIKeysCmd) Create(ctx context.Context, in APIKeysCreateInput) error {
return fmt.Errorf("--name is required")
}

if in.DaysToExpire.Set && in.ExpiresAt != "" {
return fmt.Errorf("--days-to-expire and --expires-at are mutually exclusive")
}

params := kernel.APIKeyNewParams{Name: in.Name}
if in.DaysToExpire.Set {
if in.DaysToExpire.Value < 1 || in.DaysToExpire.Value > 3650 {
return fmt.Errorf("--days-to-expire must be between 1 and 3650")
}
params.DaysToExpire = kernel.Int(in.DaysToExpire.Value)
}
if in.ExpiresAt != "" {
expiresAt, err := time.Parse(time.RFC3339, in.ExpiresAt)
if err != nil {
return fmt.Errorf("--expires-at must be an RFC3339 timestamp")
}
if !expiresAt.After(time.Now()) {
return fmt.Errorf("--expires-at must be in the future")
}
params.ExpiresAt = kernel.Opt(expiresAt)
}
if in.ProjectID != "" {
params.ProjectID = kernel.String(in.ProjectID)
}
policy, policySet, err := parseAPIKeyPolicy(in.Policy)
if err != nil {
return err
}
if policySet {
params.Policy = policy
}

key, err := c.apiKeys.New(ctx, params)
if err != nil {
Expand Down Expand Up @@ -152,8 +201,10 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error {
params.SortBy = kernel.APIKeyListParamsSortByName
case "expires_at":
params.SortBy = kernel.APIKeyListParamsSortByExpiresAt
case "last_used_at":
params.SortBy = kernel.APIKeyListParamsSortByLastUsedAt
default:
return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', or 'expires_at')", in.SortBy)
return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', 'expires_at', or 'last_used_at')", in.SortBy)
}
}
if in.SortDirection != "" {
Expand Down Expand Up @@ -188,7 +239,7 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error {

// Only surface Deleted At when the filter can actually return deleted keys.
showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all"
header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"}
header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Last Used At", "Created At"}
if showDeletedAt {
header = append(header, "Deleted At")
}
Expand All @@ -201,6 +252,7 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error {
formatAPIKeyProject(key),
key.MaskedKey,
formatAPIKeyExpiresAt(key),
formatAPIKeyLastUsedAt(key),
util.FormatLocal(key.CreatedAt),
}
if showDeletedAt {
Expand Down Expand Up @@ -356,6 +408,7 @@ func renderAPIKeyDetails(key *kernel.APIKey) {
{"Masked Key", key.MaskedKey},
{"Created By", formatAPIKeyCreator(*key)},
{"Expires At", formatAPIKeyExpiresAt(*key)},
{"Last Used At", formatAPIKeyLastUsedAt(*key)},
{"Created At", util.FormatLocal(key.CreatedAt)},
}
PrintTableNoPad(rows, true)
Expand Down Expand Up @@ -395,6 +448,13 @@ func formatAPIKeyExpiresAt(key kernel.APIKey) string {
return util.FormatLocal(key.ExpiresAt)
}

func formatAPIKeyLastUsedAt(key kernel.APIKey) string {
if !key.JSON.LastUsedAt.Valid() {
return "-"
}
return util.FormatLocal(key.LastUsedAt)
}

func getAPIKeysHandler(cmd *cobra.Command) APIKeysCmd {
client := getKernelClient(cmd)
return APIKeysCmd{apiKeys: &client.APIKeys}
Expand All @@ -404,7 +464,9 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error {
c := getAPIKeysHandler(cmd)
name, _ := cmd.Flags().GetString("name")
daysToExpire, _ := cmd.Flags().GetInt64("days-to-expire")
expiresAt, _ := cmd.Flags().GetString("expires-at")
projectID, _ := cmd.Flags().GetString("project-id")
policy, _ := cmd.Flags().GetString("policy")
output, _ := cmd.Flags().GetString("output")

return c.Create(cmd.Context(), APIKeysCreateInput{
Expand All @@ -413,7 +475,9 @@ func runAPIKeysCreate(cmd *cobra.Command, args []string) error {
Set: cmd.Flags().Changed("days-to-expire"),
Value: daysToExpire,
},
ExpiresAt: expiresAt,
ProjectID: projectID,
Policy: policy,
Output: output,
})
}
Expand Down Expand Up @@ -489,21 +553,23 @@ var apiKeysCmd = &cobra.Command{
var apiKeysCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an API key",
Long: "Create an API key.\n\nBy default the new key is org-wide. Use --project-id to create a key whose own access is scoped to that project. The global --project flag only scopes this CLI request.",
Long: "Create an API key.\n\nBy default the new key is org-wide. Use --project-id to create a key whose own access is scoped to that project. The global --project flag only scopes this CLI request. Use --policy to issue a least-privilege key: every action and resource not listed in its allow statements is denied.",
Args: cobra.NoArgs,
RunE: runAPIKeysCreate,
}

var apiKeysListCmd = &cobra.Command{
Use: "list",
Short: "List API keys",
Long: "List API keys. Last-used timestamps are accurate to within about 20 minutes; '-' means no use has been recorded since tracking began.",
Args: cobra.NoArgs,
RunE: runAPIKeysList,
}

var apiKeysGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get an API key",
Long: "Get an API key. The last-used timestamp is accurate to within about 20 minutes; '-' means no use has been recorded since tracking began.",
Args: cobra.ExactArgs(1),
RunE: runAPIKeysGet,
}
Expand Down Expand Up @@ -533,8 +599,11 @@ var apiKeysDeleteCmd = &cobra.Command{
func init() {
addJSONOutputFlag(apiKeysCreateCmd)
apiKeysCreateCmd.Flags().String("name", "", "API key name (required)")
apiKeysCreateCmd.Flags().Int64("days-to-expire", 0, "Number of days until expiry (1-3650); omit for never")
apiKeysCreateCmd.Flags().Int64("days-to-expire", 0, "Number of days until expiry (1-3650); omit for never; mutually exclusive with --expires-at")
apiKeysCreateCmd.Flags().String("expires-at", "", "Exact future expiry time in RFC3339 format; cannot be later than the issuing credential's expiry; mutually exclusive with --days-to-expire")
apiKeysCreateCmd.Flags().String("project-id", "", "Create a project-scoped API key for this project ID; omit for org-wide")
apiKeysCreateCmd.Flags().String("policy", "", `JSON policy object, for example {"statements":[{"action":"browsers:use","resource":"projects/proj_123/browsers/*"}]}; unlisted access is denied`)
apiKeysCreateCmd.MarkFlagsMutuallyExclusive("days-to-expire", "expires-at")
_ = apiKeysCreateCmd.MarkFlagRequired("name")

addJSONOutputFlag(apiKeysListCmd)
Expand All @@ -544,7 +613,7 @@ func init() {
apiKeysListCmd.Flags().String("query", "", "Search API keys by name, creator, or project (identifiers and masked keys match by exact value or prefix)")
apiKeysListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'")
apiKeysListCmd.Flags().Bool("include-deleted", false, "Deprecated: Use --status all instead. Include soft-deleted API keys in the results")
apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, or expires_at")
apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, expires_at, or last_used_at. Keys without an expiry sort last for expires_at; keys without recorded use sort last for last_used_at, in either direction")
apiKeysListCmd.Flags().String("sort-direction", "", "Sort direction: asc or desc")

addJSONOutputFlag(apiKeysGetCmd)
Expand Down
88 changes: 85 additions & 3 deletions cmd/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"errors"
"net/http"
"testing"
"time"

"github.com/kernel/cli/pkg/util"
"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
Expand Down Expand Up @@ -112,6 +114,62 @@ func TestAPIKeysCreateBuildsParamsAndPrintsPlaintextKey(t *testing.T) {
assert.Contains(t, out, "Prod")
}

func TestAPIKeysCreateSupportsExactExpiryAndPolicy(t *testing.T) {
capturePtermOutput(t)
expiresAt := "2099-01-15T09:00:00Z"
fake := &FakeAPIKeysService{
NewFunc: func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) {
expectedExpiry, err := time.Parse(time.RFC3339, expiresAt)
require.NoError(t, err)
assert.True(t, body.ExpiresAt.Valid())
assert.Equal(t, expectedExpiry, body.ExpiresAt.Value)
require.Len(t, body.Policy.Statements, 2)
assert.Equal(t, "browsers:use", body.Policy.Statements[0].Action)
assert.Equal(t, "projects/proj_123/browsers/*", body.Policy.Statements[0].Resource)
assert.Equal(t, "vaults:read", body.Policy.Statements[1].Action)
return createdAPIKeyFromJSON(`{"id":"key_123","name":"ci","key":"sk_live_123","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123"},"expires_at":"2099-01-15T09:00:00Z","project_id":"proj_123"}`), nil
},
}
c := APIKeysCmd{apiKeys: fake}

err := c.Create(context.Background(), APIKeysCreateInput{
Name: "ci",
ExpiresAt: expiresAt,
ProjectID: "proj_123",
Policy: `{"statements":[{"action":"browsers:use","resource":"projects/proj_123/browsers/*"},{"action":"vaults:read","resource":"projects/proj_123/vaults/*"}]}`,
})
require.NoError(t, err)
}

func TestAPIKeysCreateRejectsInvalidExpiryAndPolicy(t *testing.T) {
called := false
fake := &FakeAPIKeysService{NewFunc: func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) {
called = true
return nil, nil
}}
c := APIKeysCmd{apiKeys: fake}
tests := []struct {
name string
input APIKeysCreateInput
wantErr string
}{
{"both expiry forms", APIKeysCreateInput{Name: "ci", DaysToExpire: Int64Flag{Set: true, Value: 30}, ExpiresAt: "2099-01-15T09:00:00Z"}, "mutually exclusive"},
{"invalid expiry", APIKeysCreateInput{Name: "ci", ExpiresAt: "tomorrow"}, "RFC3339"},
{"past expiry", APIKeysCreateInput{Name: "ci", ExpiresAt: "2020-01-01T00:00:00Z"}, "future"},
{"invalid policy json", APIKeysCreateInput{Name: "ci", Policy: "[]"}, "JSON object"},
{"empty statements", APIKeysCreateInput{Name: "ci", Policy: `{"statements":[]}`}, "between 1 and 256"},
{"empty statement field", APIKeysCreateInput{Name: "ci", Policy: `{"statements":[{"action":"","resource":"projects/*"}]}`}, "non-empty action and resource"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called = false
err := c.Create(context.Background(), tt.input)
require.ErrorContains(t, err, tt.wantErr)
assert.False(t, called)
})
}
}

func TestAPIKeysCreateRejectsInvalidDaysToExpire(t *testing.T) {
c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}}

Expand Down Expand Up @@ -183,7 +241,7 @@ func TestAPIKeysRejectInvalidOutputBeforeCallingAPI(t *testing.T) {

func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) {
buf := capturePtermOutput(t)
key := *apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"[email protected]","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`)
key := *apiKeyFromJSON(`{"id":"key_123","name":"ci","masked_key":"sk_...123","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"[email protected]","name":"Dev"},"expires_at":null,"last_used_at":"2026-05-28T13:30:00Z","project_id":null,"project_name":null}`)
fake := &FakeAPIKeysService{
ListFunc: func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) {
assert.True(t, query.Limit.Valid())
Expand All @@ -202,6 +260,27 @@ func TestAPIKeysListPassesPaginationAndRendersRows(t *testing.T) {
assert.Contains(t, out, "key_123")
assert.Contains(t, out, "ci")
assert.Contains(t, out, "Never")
assert.Contains(t, out, "Last Used At")
assert.Contains(t, out, util.FormatLocal(key.LastUsedAt))
}

func TestAPIKeysListSupportsLastUsedAtSort(t *testing.T) {
capturePtermOutput(t)
fake := &FakeAPIKeysService{
ListFunc: func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error) {
assert.Equal(t, kernel.APIKeyListParamsSortByLastUsedAt, query.SortBy)
return &pagination.OffsetPagination[kernel.APIKey]{Items: []kernel.APIKey{}}, nil
},
}
c := APIKeysCmd{apiKeys: fake}

err := c.List(context.Background(), APIKeysListInput{SortBy: "last_used_at"})
require.NoError(t, err)
}

func TestFormatAPIKeyLastUsedAtHandlesUnrecordedUse(t *testing.T) {
key := apiKeyFromJSON(`{"last_used_at":null}`)
assert.Equal(t, "-", formatAPIKeyLastUsedAt(*key))
}

func TestAPIKeysUpdateRequiresName(t *testing.T) {
Expand Down Expand Up @@ -338,17 +417,20 @@ func TestAPIKeysRotateOmitsUnsetParams(t *testing.T) {
}

func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) {
capturePtermOutput(t)
buf := capturePtermOutput(t)
key := apiKeyFromJSON(`{"id":"key_123","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"[email protected]","name":"Dev"},"expires_at":null,"last_used_at":"2026-05-28T13:30:00Z","project_id":null,"project_name":null}`)
var captured kernel.APIKeyGetParams
fake := &FakeAPIKeysService{
GetFunc: func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) {
captured = query
return apiKeyFromJSON(`{"id":"` + id + `","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"[email protected]","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil
return key, nil
},
}
c := APIKeysCmd{apiKeys: fake}

require.NoError(t, c.Get(context.Background(), APIKeysGetInput{ID: "key_123", IncludeDeleted: true}))
assert.True(t, captured.IncludeDeleted.Valid())
assert.True(t, captured.IncludeDeleted.Value)
assert.Contains(t, buf.String(), "Last Used At")
assert.Contains(t, buf.String(), util.FormatLocal(key.LastUsedAt))
}
Loading
Loading