From fbe836a3334678918af45eb339a15fa4695bb256 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 11 Feb 2026 14:00:29 -0500 Subject: [PATCH] feat(keys): add API key management commands Add `nrq keys` commands (list, get, create, update, delete) as ergonomic wrappers around the NerdGraph apiAccess API. Closes #82 --- api/connection.go | 20 +- api/keys.go | 377 ++++++++++++++++++ api/keys_test.go | 530 +++++++++++++++++++++++++ api/testdata/api_key_created.json | 16 + api/testdata/api_key_current_user.json | 9 + api/testdata/api_key_deleted.json | 12 + api/testdata/api_key_get.json | 15 + api/testdata/api_key_updated.json | 16 + api/testdata/api_keys_search.json | 35 ++ api/types.go | 17 + cmd/nrq/main.go | 2 + internal/cmd/keys/keys.go | 498 +++++++++++++++++++++++ 12 files changed, 1537 insertions(+), 10 deletions(-) create mode 100644 api/keys.go create mode 100644 api/keys_test.go create mode 100644 api/testdata/api_key_created.json create mode 100644 api/testdata/api_key_current_user.json create mode 100644 api/testdata/api_key_deleted.json create mode 100644 api/testdata/api_key_get.json create mode 100644 api/testdata/api_key_updated.json create mode 100644 api/testdata/api_keys_search.json create mode 100644 internal/cmd/keys/keys.go diff --git a/api/connection.go b/api/connection.go index 4f02231..4ec5fd4 100644 --- a/api/connection.go +++ b/api/connection.go @@ -4,16 +4,16 @@ import "fmt" // ConnectionTestResult holds the result of a connection test type ConnectionTestResult struct { - APIKeyValid bool - AccountAccess bool - AccountID int - AccountName string - UserID string - UserEmail string - Region string - NerdGraphURL string - Error error - ErrorMessage string + APIKeyValid bool + AccountAccess bool + AccountID int + AccountName string + UserID string + UserEmail string + Region string + NerdGraphURL string + Error error + ErrorMessage string } // TestConnection verifies the API key and optionally account access diff --git a/api/keys.go b/api/keys.go new file mode 100644 index 0000000..5f32309 --- /dev/null +++ b/api/keys.go @@ -0,0 +1,377 @@ +package api + +import "fmt" + +// apiAccessKeyFields is the common set of GraphQL fields for API access keys +const apiAccessKeyFields = ` + id + name + notes + type + key + ... on ApiAccessIngestKey { + ingestType + } +` + +// SearchAPIKeys searches for API keys with optional type and account filters +func (c *Client) SearchAPIKeys(keyTypes []string, accountID int) ([]ApiAccessKey, error) { + // Build the types array + typesStr := "USER, INGEST" + if len(keyTypes) > 0 { + typesStr = "" + for i, t := range keyTypes { + if i > 0 { + typesStr += ", " + } + typesStr += t + } + } + + // Build scope clause + scopeClause := "" + if accountID > 0 { + scopeClause = fmt.Sprintf(", scope: {accountIds: %d}", accountID) + } + + query := fmt.Sprintf(` + { + actor { + apiAccess { + keySearch(query: {types: [%s]%s}) { + keys { + %s + } + } + } + } + }`, typesStr, scopeClause, apiAccessKeyFields) + + result, err := c.NerdGraphQuery(query, nil) + if err != nil { + return nil, err + } + + actor, ok := safeMap(result["actor"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format: missing actor"} + } + apiAccess, ok := safeMap(actor["apiAccess"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format: missing apiAccess"} + } + keySearch, ok := safeMap(apiAccess["keySearch"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format: missing keySearch"} + } + keysData, ok := safeSlice(keySearch["keys"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format: missing keys"} + } + + var keys []ApiAccessKey + for _, k := range keysData { + keys = append(keys, parseApiAccessKey(k)) + } + + return keys, nil +} + +// GetAPIAccessKey retrieves a specific API key by ID and type +func (c *Client) GetAPIAccessKey(keyID string, keyType string) (*ApiAccessKey, error) { + query := fmt.Sprintf(` + { + actor { + apiAccess { + key(id: "%s", keyType: %s) { + %s + } + } + } + }`, keyID, keyType, apiAccessKeyFields) + + result, err := c.NerdGraphQuery(query, nil) + if err != nil { + return nil, err + } + + actor, ok := safeMap(result["actor"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format: missing actor"} + } + apiAccess, ok := safeMap(actor["apiAccess"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format: missing apiAccess"} + } + keyData, ok := safeMap(apiAccess["key"]) + if !ok { + return nil, fmt.Errorf("key not found: %s", keyID) + } + + key := parseApiAccessKey(keyData) + return &key, nil +} + +// FindAPIAccessKey retrieves a key by ID, trying USER then INGEST type +func (c *Client) FindAPIAccessKey(keyID string) (*ApiAccessKey, error) { + key, err := c.GetAPIAccessKey(keyID, "USER") + if err == nil { + return key, nil + } + return c.GetAPIAccessKey(keyID, "INGEST") +} + +// GetCurrentUserID returns the current user's ID from NerdGraph +func (c *Client) GetCurrentUserID() (int, error) { + query := `{ actor { user { id } } }` + + result, err := c.NerdGraphQuery(query, nil) + if err != nil { + return 0, err + } + + actor, ok := safeMap(result["actor"]) + if !ok { + return 0, &ResponseError{Message: "unexpected response format: missing actor"} + } + user, ok := safeMap(actor["user"]) + if !ok { + return 0, &ResponseError{Message: "unexpected response format: missing user"} + } + + return safeInt(user["id"]), nil +} + +// CreateUserAPIKey creates a new user API key +func (c *Client) CreateUserAPIKey(accountID, userID int, name, notes string) (*ApiAccessKey, error) { + mutation := fmt.Sprintf(` + mutation { + apiAccessCreateKeys(keys: {user: [{accountId: %d, userId: %d, name: "%s", notes: "%s"}]}) { + createdKeys { + %s + } + errors { + message + type + } + } + }`, accountID, userID, escapeGraphQL(name), escapeGraphQL(notes), apiAccessKeyFields) + + return c.execCreateKeys(mutation) +} + +// CreateIngestAPIKey creates a new ingest API key (LICENSE or BROWSER) +func (c *Client) CreateIngestAPIKey(accountID int, ingestType, name, notes string) (*ApiAccessKey, error) { + mutation := fmt.Sprintf(` + mutation { + apiAccessCreateKeys(keys: {ingest: [{accountId: %d, ingestType: %s, name: "%s", notes: "%s"}]}) { + createdKeys { + %s + } + errors { + message + type + } + } + }`, accountID, ingestType, escapeGraphQL(name), escapeGraphQL(notes), apiAccessKeyFields) + + return c.execCreateKeys(mutation) +} + +func (c *Client) execCreateKeys(mutation string) (*ApiAccessKey, error) { + result, err := c.NerdGraphQuery(mutation, nil) + if err != nil { + return nil, err + } + + createResult, ok := safeMap(result["apiAccessCreateKeys"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format"} + } + if errors, ok := safeSlice(createResult["errors"]); ok && len(errors) > 0 { + errMap, _ := safeMap(errors[0]) + return nil, fmt.Errorf("failed to create key: %s", safeString(errMap["message"])) + } + + createdKeys, ok := safeSlice(createResult["createdKeys"]) + if !ok || len(createdKeys) == 0 { + return nil, &ResponseError{Message: "unexpected response format: no created keys returned"} + } + + key := parseApiAccessKey(createdKeys[0]) + return &key, nil +} + +// UpdateAPIAccessKey updates an existing API key's name and/or notes +func (c *Client) UpdateAPIAccessKey(keyID string, keyType string, update ApiAccessKeyUpdate) (*ApiAccessKey, error) { + // Build the update fields + fields := fmt.Sprintf(`keyId: "%s"`, keyID) + if update.Name != nil { + fields += fmt.Sprintf(`, name: "%s"`, escapeGraphQL(*update.Name)) + } + if update.Notes != nil { + fields += fmt.Sprintf(`, notes: "%s"`, escapeGraphQL(*update.Notes)) + } + + // Use the appropriate key type bucket + var keyBucket string + switch keyType { + case "USER": + keyBucket = "user" + case "INGEST": + keyBucket = "ingest" + default: + return nil, fmt.Errorf("invalid key type: %s (must be USER or INGEST)", keyType) + } + + mutation := fmt.Sprintf(` + mutation { + apiAccessUpdateKeys(keys: {%s: [{%s}]}) { + updatedKeys { + %s + } + errors { + message + } + } + }`, keyBucket, fields, apiAccessKeyFields) + + result, err := c.NerdGraphQuery(mutation, nil) + if err != nil { + return nil, err + } + + updateResult, ok := safeMap(result["apiAccessUpdateKeys"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format"} + } + if errors, ok := safeSlice(updateResult["errors"]); ok && len(errors) > 0 { + errMap, _ := safeMap(errors[0]) + return nil, fmt.Errorf("failed to update key: %s", safeString(errMap["message"])) + } + + updatedKeys, ok := safeSlice(updateResult["updatedKeys"]) + if !ok || len(updatedKeys) == 0 { + return nil, &ResponseError{Message: "unexpected response format: no updated keys returned"} + } + + key := parseApiAccessKey(updatedKeys[0]) + return &key, nil +} + +// DeleteAPIAccessKeys deletes API keys by their IDs, separated by type +func (c *Client) DeleteAPIAccessKeys(userKeyIDs, ingestKeyIDs []string) ([]string, error) { + // Build the keys argument + parts := []string{} + if len(userKeyIDs) > 0 { + ids := formatStringSlice(userKeyIDs) + parts = append(parts, fmt.Sprintf("userKeyIds: [%s]", ids)) + } + if len(ingestKeyIDs) > 0 { + ids := formatStringSlice(ingestKeyIDs) + parts = append(parts, fmt.Sprintf("ingestKeyIds: [%s]", ids)) + } + + if len(parts) == 0 { + return nil, fmt.Errorf("no key IDs provided") + } + + keysArg := "" + for i, p := range parts { + if i > 0 { + keysArg += ", " + } + keysArg += p + } + + mutation := fmt.Sprintf(` + mutation { + apiAccessDeleteKeys(keys: {%s}) { + deletedKeys { + id + } + errors { + message + } + } + }`, keysArg) + + result, err := c.NerdGraphQuery(mutation, nil) + if err != nil { + return nil, err + } + + deleteResult, ok := safeMap(result["apiAccessDeleteKeys"]) + if !ok { + return nil, &ResponseError{Message: "unexpected response format"} + } + if errors, ok := safeSlice(deleteResult["errors"]); ok && len(errors) > 0 { + errMap, _ := safeMap(errors[0]) + return nil, fmt.Errorf("failed to delete keys: %s", safeString(errMap["message"])) + } + + deletedKeys, ok := safeSlice(deleteResult["deletedKeys"]) + if !ok { + return nil, nil + } + + var deletedIDs []string + for _, k := range deletedKeys { + km, ok := safeMap(k) + if ok { + deletedIDs = append(deletedIDs, safeString(km["id"])) + } + } + + return deletedIDs, nil +} + +// parseApiAccessKey converts a NerdGraph response map to an ApiAccessKey +func parseApiAccessKey(v interface{}) ApiAccessKey { + m, ok := safeMap(v) + if !ok { + return ApiAccessKey{} + } + return ApiAccessKey{ + ID: safeString(m["id"]), + Name: safeString(m["name"]), + Notes: safeString(m["notes"]), + Type: safeString(m["type"]), + Key: safeString(m["key"]), + IngestType: safeString(m["ingestType"]), + } +} + +// escapeGraphQL escapes special characters for GraphQL string values +func escapeGraphQL(s string) string { + result := "" + for _, c := range s { + switch c { + case '"': + result += `\"` + case '\\': + result += `\\` + case '\n': + result += `\n` + case '\r': + result += `\r` + case '\t': + result += `\t` + default: + result += string(c) + } + } + return result +} + +// formatStringSlice formats a string slice as GraphQL string array items +func formatStringSlice(ss []string) string { + result := "" + for i, s := range ss { + if i > 0 { + result += ", " + } + result += fmt.Sprintf(`"%s"`, s) + } + return result +} diff --git a/api/keys_test.go b/api/keys_test.go new file mode 100644 index 0000000..5fdba3f --- /dev/null +++ b/api/keys_test.go @@ -0,0 +1,530 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSearchAPIKeys(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_keys_search.json")) + + client := NewTestClient(server) + keys, err := client.SearchAPIKeys(nil, 0) + + require.NoError(t, err) + require.Len(t, keys, 3) + + assert.Equal(t, "NRAK-ABCDEF1234567890", keys[0].ID) + assert.Equal(t, "My User Key", keys[0].Name) + assert.Equal(t, "USER", keys[0].Type) + assert.Equal(t, "", keys[0].IngestType) + + assert.Equal(t, "NRII-ABCDEF1234567890", keys[1].ID) + assert.Equal(t, "INGEST", keys[1].Type) + assert.Equal(t, "LICENSE", keys[1].IngestType) + + assert.Equal(t, "NRII-BROWSER1234567890", keys[2].ID) + assert.Equal(t, "BROWSER", keys[2].IngestType) + + server.AssertLastPath(t, "/graphql") +} + +func TestSearchAPIKeys_FilterByType(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_keys_search.json")) + + client := NewTestClient(server) + _, err := client.SearchAPIKeys([]string{"USER"}, 0) + + require.NoError(t, err) + + // Verify the query contained USER type + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "USER") +} + +func TestSearchAPIKeys_WithAccountFilter(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_keys_search.json")) + + client := NewTestClient(server) + _, err := client.SearchAPIKeys(nil, 12345) + + require.NoError(t, err) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "12345") +} + +func TestSearchAPIKeys_EmptyResult(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "actor": { + "apiAccess": { + "keySearch": { + "keys": [] + } + } + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + keys, err := client.SearchAPIKeys(nil, 0) + + require.NoError(t, err) + assert.Empty(t, keys) +} + +func TestGetAPIAccessKey(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_get.json")) + + client := NewTestClient(server) + key, err := client.GetAPIAccessKey("NRAK-ABCDEF1234567890", "USER") + + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, "NRAK-ABCDEF1234567890", key.ID) + assert.Equal(t, "My User Key", key.Name) + assert.Equal(t, "USER", key.Type) + assert.Equal(t, "For automation", key.Notes) + + // Verify request contained key ID and type + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "NRAK-ABCDEF1234567890") + assert.Contains(t, string(req.Body), "USER") +} + +func TestGetAPIAccessKey_NotFound(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "actor": { + "apiAccess": { + "key": null + } + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + _, err := client.GetAPIAccessKey("nonexistent", "USER") + + require.Error(t, err) + assert.Contains(t, err.Error(), "key not found") +} + +func TestFindAPIAccessKey_FoundAsUser(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_get.json")) + + client := NewTestClient(server) + key, err := client.FindAPIAccessKey("NRAK-ABCDEF1234567890") + + require.NoError(t, err) + require.NotNil(t, key) + assert.Equal(t, "USER", key.Type) + + // Should have only made one request (found on first try) + server.AssertRequestCount(t, 1) +} + +func TestFindAPIAccessKey_FoundAsIngest(t *testing.T) { + server := NewMockServer() + defer server.Close() + + requestCount := 0 + server.SetHandler(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + if requestCount == 1 { + // First request (USER) returns null key + w.Write([]byte(`{"data": {"actor": {"apiAccess": {"key": null}}}}`)) + } else { + // Second request (INGEST) returns the key + w.Write([]byte(`{ + "data": { + "actor": { + "apiAccess": { + "key": { + "id": "NRII-ABC123", + "name": "License Key", + "notes": "", + "type": "INGEST", + "key": "", + "ingestType": "LICENSE" + } + } + } + } + }`)) + } + }) + + client := NewTestClient(server) + key, err := client.FindAPIAccessKey("NRII-ABC123") + + require.NoError(t, err) + require.NotNil(t, key) + assert.Equal(t, "INGEST", key.Type) + assert.Equal(t, 2, requestCount) +} + +func TestGetCurrentUserID(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_current_user.json")) + + client := NewTestClient(server) + userID, err := client.GetCurrentUserID() + + require.NoError(t, err) + assert.Equal(t, 99999, userID) +} + +func TestCreateUserAPIKey(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_created.json")) + + client := NewTestClient(server) + key, err := client.CreateUserAPIKey(12345, 99999, "New Key", "Fresh key") + + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, "NRAK-NEW1234567890", key.ID) + assert.Equal(t, "New Key", key.Name) + assert.Equal(t, "Fresh key", key.Notes) + assert.Equal(t, "USER", key.Type) + assert.NotEmpty(t, key.Key) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "12345") + assert.Contains(t, string(req.Body), "99999") +} + +func TestCreateUserAPIKey_Error(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "apiAccessCreateKeys": { + "createdKeys": [], + "errors": [ + {"message": "Unauthorized", "type": "UNAUTHORIZED"} + ] + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + _, err := client.CreateUserAPIKey(12345, 99999, "Test", "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "Unauthorized") +} + +func TestCreateIngestAPIKey(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "apiAccessCreateKeys": { + "createdKeys": [ + { + "id": "NRII-NEW123", + "name": "License Key", + "notes": "", + "type": "INGEST", + "key": "license-key-value", + "ingestType": "LICENSE" + } + ], + "errors": [] + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + key, err := client.CreateIngestAPIKey(12345, "LICENSE", "License Key", "") + + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, "INGEST", key.Type) + assert.Equal(t, "LICENSE", key.IngestType) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "LICENSE") +} + +func TestUpdateAPIAccessKey(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_updated.json")) + + client := NewTestClient(server) + name := "Updated Name" + notes := "Updated notes" + key, err := client.UpdateAPIAccessKey("NRAK-ABCDEF1234567890", "USER", ApiAccessKeyUpdate{ + Name: &name, + Notes: ¬es, + }) + + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, "NRAK-ABCDEF1234567890", key.ID) + assert.Equal(t, "Updated Name", key.Name) + assert.Equal(t, "Updated notes", key.Notes) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "user") + assert.Contains(t, string(req.Body), "Updated Name") +} + +func TestUpdateAPIAccessKey_IngestType(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "apiAccessUpdateKeys": { + "updatedKeys": [ + { + "id": "NRII-ABC123", + "name": "Updated Ingest", + "notes": "", + "type": "INGEST", + "ingestType": "LICENSE" + } + ], + "errors": [] + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + name := "Updated Ingest" + key, err := client.UpdateAPIAccessKey("NRII-ABC123", "INGEST", ApiAccessKeyUpdate{ + Name: &name, + }) + + require.NoError(t, err) + require.NotNil(t, key) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "ingest") +} + +func TestUpdateAPIAccessKey_InvalidType(t *testing.T) { + server := NewMockServer() + defer server.Close() + + client := NewTestClient(server) + name := "Test" + _, err := client.UpdateAPIAccessKey("key-id", "INVALID", ApiAccessKeyUpdate{ + Name: &name, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid key type") +} + +func TestUpdateAPIAccessKey_Error(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "apiAccessUpdateKeys": { + "updatedKeys": [], + "errors": [ + {"message": "Key not found"} + ] + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + name := "Test" + _, err := client.UpdateAPIAccessKey("nonexistent", "USER", ApiAccessKeyUpdate{ + Name: &name, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Key not found") +} + +func TestDeleteAPIAccessKeys_UserKeys(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_deleted.json")) + + client := NewTestClient(server) + deleted, err := client.DeleteAPIAccessKeys([]string{"NRAK-ABCDEF1234567890"}, nil) + + require.NoError(t, err) + require.Len(t, deleted, 1) + assert.Equal(t, "NRAK-ABCDEF1234567890", deleted[0]) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "userKeyIds") +} + +func TestDeleteAPIAccessKeys_IngestKeys(t *testing.T) { + server := NewMockServer() + defer server.Close() + + server.SetResponse(http.StatusOK, LoadTestFixture(t, "api_key_deleted.json")) + + client := NewTestClient(server) + deleted, err := client.DeleteAPIAccessKeys(nil, []string{"NRII-ABC123"}) + + require.NoError(t, err) + require.Len(t, deleted, 1) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Contains(t, string(req.Body), "ingestKeyIds") +} + +func TestDeleteAPIAccessKeys_NoIDs(t *testing.T) { + server := NewMockServer() + defer server.Close() + + client := NewTestClient(server) + _, err := client.DeleteAPIAccessKeys(nil, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no key IDs provided") +} + +func TestDeleteAPIAccessKeys_Error(t *testing.T) { + server := NewMockServer() + defer server.Close() + + response := `{ + "data": { + "apiAccessDeleteKeys": { + "deletedKeys": [], + "errors": [ + {"message": "Key not found"} + ] + } + } + }` + server.SetResponse(http.StatusOK, response) + + client := NewTestClient(server) + _, err := client.DeleteAPIAccessKeys([]string{"nonexistent"}, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Key not found") +} + +func TestEscapeGraphQL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple string", "hello", "hello"}, + {"double quotes", `say "hello"`, `say \"hello\"`}, + {"backslash", `path\to\file`, `path\\to\\file`}, + {"newline", "line1\nline2", `line1\nline2`}, + {"tab", "col1\tcol2", `col1\tcol2`}, + {"mixed", "a \"b\" c\nd", `a \"b\" c\nd`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := escapeGraphQL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatStringSlice(t *testing.T) { + tests := []struct { + name string + input []string + expected string + }{ + {"single", []string{"abc"}, `"abc"`}, + {"multiple", []string{"a", "b", "c"}, `"a", "b", "c"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatStringSlice(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseApiAccessKey(t *testing.T) { + data := map[string]interface{}{ + "id": "NRAK-123", + "name": "Test Key", + "notes": "Some notes", + "type": "USER", + "key": "actual-key-value", + "ingestType": "", + } + + key := parseApiAccessKey(data) + + assert.Equal(t, "NRAK-123", key.ID) + assert.Equal(t, "Test Key", key.Name) + assert.Equal(t, "Some notes", key.Notes) + assert.Equal(t, "USER", key.Type) + assert.Equal(t, "actual-key-value", key.Key) +} + +func TestParseApiAccessKey_InvalidInput(t *testing.T) { + key := parseApiAccessKey("not a map") + assert.Equal(t, ApiAccessKey{}, key) +} diff --git a/api/testdata/api_key_created.json b/api/testdata/api_key_created.json new file mode 100644 index 0000000..7f64d5a --- /dev/null +++ b/api/testdata/api_key_created.json @@ -0,0 +1,16 @@ +{ + "data": { + "apiAccessCreateKeys": { + "createdKeys": [ + { + "id": "NRAK-NEW1234567890", + "name": "New Key", + "notes": "Fresh key", + "type": "USER", + "key": "NRAK-NEW1234567890ABCDEF1234567890" + } + ], + "errors": [] + } + } +} diff --git a/api/testdata/api_key_current_user.json b/api/testdata/api_key_current_user.json new file mode 100644 index 0000000..2b4cc24 --- /dev/null +++ b/api/testdata/api_key_current_user.json @@ -0,0 +1,9 @@ +{ + "data": { + "actor": { + "user": { + "id": 99999 + } + } + } +} diff --git a/api/testdata/api_key_deleted.json b/api/testdata/api_key_deleted.json new file mode 100644 index 0000000..69f56c8 --- /dev/null +++ b/api/testdata/api_key_deleted.json @@ -0,0 +1,12 @@ +{ + "data": { + "apiAccessDeleteKeys": { + "deletedKeys": [ + { + "id": "NRAK-ABCDEF1234567890" + } + ], + "errors": [] + } + } +} diff --git a/api/testdata/api_key_get.json b/api/testdata/api_key_get.json new file mode 100644 index 0000000..d71f92f --- /dev/null +++ b/api/testdata/api_key_get.json @@ -0,0 +1,15 @@ +{ + "data": { + "actor": { + "apiAccess": { + "key": { + "id": "NRAK-ABCDEF1234567890", + "name": "My User Key", + "notes": "For automation", + "type": "USER", + "key": "NRAK-ABCDEF1234567890ABCDEF1234567890" + } + } + } + } +} diff --git a/api/testdata/api_key_updated.json b/api/testdata/api_key_updated.json new file mode 100644 index 0000000..933d3ff --- /dev/null +++ b/api/testdata/api_key_updated.json @@ -0,0 +1,16 @@ +{ + "data": { + "apiAccessUpdateKeys": { + "updatedKeys": [ + { + "id": "NRAK-ABCDEF1234567890", + "name": "Updated Name", + "notes": "Updated notes", + "type": "USER", + "key": "" + } + ], + "errors": [] + } + } +} diff --git a/api/testdata/api_keys_search.json b/api/testdata/api_keys_search.json new file mode 100644 index 0000000..89b6d28 --- /dev/null +++ b/api/testdata/api_keys_search.json @@ -0,0 +1,35 @@ +{ + "data": { + "actor": { + "apiAccess": { + "keySearch": { + "keys": [ + { + "id": "NRAK-ABCDEF1234567890", + "name": "My User Key", + "notes": "For automation", + "type": "USER", + "key": "" + }, + { + "id": "NRII-ABCDEF1234567890", + "name": "My License Key", + "notes": "Production license", + "type": "INGEST", + "key": "", + "ingestType": "LICENSE" + }, + { + "id": "NRII-BROWSER1234567890", + "name": "My Browser Key", + "notes": "", + "type": "INGEST", + "key": "", + "ingestType": "BROWSER" + } + ] + } + } + } + } +} diff --git a/api/types.go b/api/types.go index 3c3d0ec..3d465d0 100644 --- a/api/types.go +++ b/api/types.go @@ -343,6 +343,23 @@ type LogParsingRule struct { UpdatedAt string `json:"updatedAt"` } +// ApiAccessKey represents a New Relic API access key (user or ingest) +type ApiAccessKey struct { + ID string `json:"id"` + Name string `json:"name"` + Notes string `json:"notes,omitempty"` + Type string `json:"type"` + Key string `json:"key,omitempty"` + IngestType string `json:"ingestType,omitempty"` +} + +// ApiAccessKeyUpdate contains the fields that can be updated on an API key. +// All fields are optional - only non-nil values will be included in the update. +type ApiAccessKeyUpdate struct { + Name *string + Notes *string +} + // NerdGraphRequest represents a GraphQL request type NerdGraphRequest struct { Query string `json:"query"` diff --git a/cmd/nrq/main.go b/cmd/nrq/main.go index cb82443..c4b5337 100644 --- a/cmd/nrq/main.go +++ b/cmd/nrq/main.go @@ -13,6 +13,7 @@ import ( "github.com/open-cli-collective/newrelic-cli/internal/cmd/deployments" "github.com/open-cli-collective/newrelic-cli/internal/cmd/entities" "github.com/open-cli-collective/newrelic-cli/internal/cmd/initcmd" + "github.com/open-cli-collective/newrelic-cli/internal/cmd/keys" "github.com/open-cli-collective/newrelic-cli/internal/cmd/logs" "github.com/open-cli-collective/newrelic-cli/internal/cmd/nerdgraph" "github.com/open-cli-collective/newrelic-cli/internal/cmd/nrql" @@ -33,6 +34,7 @@ func main() { deployments.Register, entities.Register, initcmd.Register, + keys.Register, logs.Register, nerdgraph.Register, nrql.Register, diff --git a/internal/cmd/keys/keys.go b/internal/cmd/keys/keys.go new file mode 100644 index 0000000..ebce1b2 --- /dev/null +++ b/internal/cmd/keys/keys.go @@ -0,0 +1,498 @@ +package keys + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/newrelic-cli/api" + "github.com/open-cli-collective/newrelic-cli/internal/cmd/root" + "github.com/open-cli-collective/newrelic-cli/internal/confirm" + "github.com/open-cli-collective/newrelic-cli/internal/view" +) + +// Register adds the keys commands to the root command +func Register(rootCmd *cobra.Command, opts *root.Options) { + keysCmd := &cobra.Command{ + Use: "keys", + Aliases: []string{"key"}, + Short: "Manage API keys", + Long: `Manage New Relic API keys (user and ingest keys). + +Wraps the NerdGraph apiAccess API to list, inspect, create, update, +and delete API keys without hand-crafting GraphQL.`, + } + + keysCmd.AddCommand(newListCmd(opts)) + keysCmd.AddCommand(newGetCmd(opts)) + keysCmd.AddCommand(newCreateCmd(opts)) + keysCmd.AddCommand(newUpdateCmd(opts)) + keysCmd.AddCommand(newDeleteCmd(opts)) + + rootCmd.AddCommand(keysCmd) +} + +// --- list --- + +type listOptions struct { + *root.Options + keyType string + account int + limit int +} + +func newListCmd(opts *root.Options) *cobra.Command { + listOpts := &listOptions{Options: opts} + + cmd := &cobra.Command{ + Use: "list", + Short: "List API keys", + Long: `List API keys for your account. + +By default lists both user and ingest keys. Use --type to filter.`, + Example: ` nrq keys list + nrq keys list --type user + nrq keys list --type ingest --account 12345 + nrq keys list -o json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(listOpts) + }, + } + + cmd.Flags().StringVarP(&listOpts.keyType, "type", "t", "", "Filter by key type: user or ingest") + cmd.Flags().IntVar(&listOpts.account, "account", 0, "Filter by account ID") + cmd.Flags().IntVarP(&listOpts.limit, "limit", "l", 0, "Limit number of results (0 = no limit)") + + return cmd +} + +func runList(opts *listOptions) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + var keyTypes []string + if opts.keyType != "" { + t := strings.ToUpper(opts.keyType) + if t != "USER" && t != "INGEST" { + return fmt.Errorf("invalid key type %q: must be user or ingest", opts.keyType) + } + keyTypes = []string{t} + } + + keys, err := client.SearchAPIKeys(keyTypes, opts.account) + if err != nil { + return err + } + + if opts.limit > 0 && len(keys) > opts.limit { + keys = keys[:opts.limit] + } + + v := opts.View() + + if len(keys) == 0 { + v.Println("No API keys found") + return nil + } + + headers := []string{"ID", "NAME", "TYPE", "INGEST TYPE", "NOTES"} + rows := make([][]string, len(keys)) + for i, k := range keys { + rows[i] = []string{ + k.ID, + view.Truncate(k.Name, 30), + k.Type, + k.IngestType, + view.Truncate(k.Notes, 30), + } + } + + return v.Render(headers, rows, keys) +} + +// --- get --- + +type getOptions struct { + *root.Options + keyType string +} + +func newGetCmd(opts *root.Options) *cobra.Command { + getOpts := &getOptions{Options: opts} + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get details for an API key", + Long: `Get details for a specific API key. + +If --type is not specified, tries USER then INGEST to find the key.`, + Example: ` nrq keys get NRAK-XXXXXXXXXXXX + nrq keys get NRAK-XXXXXXXXXXXX --type user + nrq keys get NRAK-XXXXXXXXXXXX -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGet(getOpts, args[0]) + }, + } + + cmd.Flags().StringVarP(&getOpts.keyType, "type", "t", "", "Key type: user or ingest (auto-detected if omitted)") + + return cmd +} + +func runGet(opts *getOptions, keyID string) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + var key *api.ApiAccessKey + + if opts.keyType != "" { + t := strings.ToUpper(opts.keyType) + if t != "USER" && t != "INGEST" { + return fmt.Errorf("invalid key type %q: must be user or ingest", opts.keyType) + } + key, err = client.GetAPIAccessKey(keyID, t) + } else { + key, err = client.FindAPIAccessKey(keyID) + } + if err != nil { + return err + } + + v := opts.View() + + switch v.Format { + case "json": + return v.JSON(key) + case "plain": + return v.Plain([][]string{ + {key.ID, key.Name, key.Type, key.IngestType, key.Notes}, + }) + default: + v.Print("ID: %s\n", key.ID) + v.Print("Name: %s\n", key.Name) + v.Print("Type: %s\n", key.Type) + if key.IngestType != "" { + v.Print("Ingest Type: %s\n", key.IngestType) + } + if key.Key != "" { + v.Print("Key: %s\n", key.Key) + } + if key.Notes != "" { + v.Print("Notes: %s\n", key.Notes) + } + return nil + } +} + +// --- create --- + +type createOptions struct { + *root.Options + keyType string + name string + notes string + account int + userID int + ingestType string +} + +func newCreateCmd(opts *root.Options) *cobra.Command { + createOpts := &createOptions{Options: opts} + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an API key", + Long: `Create a new API key. + +For user keys, the current user is used by default. Use --user-id to +create a key for a different user. + +For ingest keys, --ingest-type is required (license or browser).`, + Example: ` # Create a user key + nrq keys create --type user --name "my-key" --notes "For automation" + + # Create a user key for a specific account + nrq keys create --type user --name "my-key" --account 12345 + + # Create an ingest (license) key + nrq keys create --type ingest --ingest-type license --name "my-license-key" + + # Create a browser ingest key + nrq keys create --type ingest --ingest-type browser --name "my-browser-key"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreate(createOpts) + }, + } + + cmd.Flags().StringVarP(&createOpts.keyType, "type", "t", "", "Key type: user or ingest (required)") + cmd.Flags().StringVarP(&createOpts.name, "name", "n", "", "Key name (required)") + cmd.Flags().StringVar(&createOpts.notes, "notes", "", "Key notes/description") + cmd.Flags().IntVar(&createOpts.account, "account", 0, "Account ID (defaults to configured account)") + cmd.Flags().IntVar(&createOpts.userID, "user-id", 0, "User ID for user keys (defaults to current user)") + cmd.Flags().StringVar(&createOpts.ingestType, "ingest-type", "", "Ingest type for ingest keys: license or browser") + cmd.MarkFlagRequired("type") + cmd.MarkFlagRequired("name") + + return cmd +} + +func runCreate(opts *createOptions) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + keyType := strings.ToUpper(opts.keyType) + if keyType != "USER" && keyType != "INGEST" { + return fmt.Errorf("invalid key type %q: must be user or ingest", opts.keyType) + } + + // Resolve account ID + accountID := opts.account + if accountID == 0 { + accountID, err = client.GetAccountIDInt() + if err != nil { + return fmt.Errorf("no account ID specified and none configured: %w", err) + } + } + + var key *api.ApiAccessKey + + switch keyType { + case "USER": + userID := opts.userID + if userID == 0 { + userID, err = client.GetCurrentUserID() + if err != nil { + return fmt.Errorf("could not determine current user ID: %w", err) + } + } + key, err = client.CreateUserAPIKey(accountID, userID, opts.name, opts.notes) + case "INGEST": + ingestType := strings.ToUpper(opts.ingestType) + if ingestType != "LICENSE" && ingestType != "BROWSER" { + return fmt.Errorf("--ingest-type is required for ingest keys: license or browser") + } + key, err = client.CreateIngestAPIKey(accountID, ingestType, opts.name, opts.notes) + } + if err != nil { + return err + } + + v := opts.View() + + switch v.Format { + case "json": + return v.JSON(key) + case "plain": + return v.Plain([][]string{ + {key.ID, key.Name, key.Type, key.Key}, + }) + default: + v.Success("API key created successfully") + v.Print("ID: %s\n", key.ID) + v.Print("Name: %s\n", key.Name) + v.Print("Type: %s\n", key.Type) + if key.IngestType != "" { + v.Print("Ingest Type: %s\n", key.IngestType) + } + if key.Key != "" { + v.Print("Key: %s\n", key.Key) + } + return nil + } +} + +// --- update --- + +type updateOptions struct { + *root.Options + keyType string + name string + notes string +} + +func newUpdateCmd(opts *root.Options) *cobra.Command { + updateOpts := &updateOptions{Options: opts} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an API key", + Long: `Update an existing API key's name and/or notes. + +If --type is not specified, the key type is auto-detected. +Only the specified fields will be modified.`, + Example: ` nrq keys update NRAK-XXXXXXXXXXXX --name "new-name" + nrq keys update NRAK-XXXXXXXXXXXX --name "new-name" --notes "updated notes" + nrq keys update NRAK-XXXXXXXXXXXX --notes "new notes" --type user`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(updateOpts, args[0], cmd) + }, + } + + cmd.Flags().StringVarP(&updateOpts.keyType, "type", "t", "", "Key type: user or ingest (auto-detected if omitted)") + cmd.Flags().StringVarP(&updateOpts.name, "name", "n", "", "New key name") + cmd.Flags().StringVar(&updateOpts.notes, "notes", "", "New key notes") + + return cmd +} + +func runUpdate(opts *updateOptions, keyID string, cmd *cobra.Command) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + // Determine key type + keyType := strings.ToUpper(opts.keyType) + if keyType == "" { + // Auto-detect by looking up the key + existing, findErr := client.FindAPIAccessKey(keyID) + if findErr != nil { + return fmt.Errorf("could not determine key type (use --type to specify): %w", findErr) + } + keyType = existing.Type + } else if keyType != "USER" && keyType != "INGEST" { + return fmt.Errorf("invalid key type %q: must be user or ingest", opts.keyType) + } + + // Build update + update := api.ApiAccessKeyUpdate{} + if cmd.Flags().Changed("name") { + update.Name = &opts.name + } + if cmd.Flags().Changed("notes") { + update.Notes = &opts.notes + } + + key, err := client.UpdateAPIAccessKey(keyID, keyType, update) + if err != nil { + return err + } + + v := opts.View() + + switch v.Format { + case "json": + return v.JSON(key) + case "plain": + return v.Plain([][]string{ + {key.ID, key.Name, key.Type}, + }) + default: + v.Success("API key updated successfully") + v.Print("ID: %s\n", key.ID) + v.Print("Name: %s\n", key.Name) + if key.Notes != "" { + v.Print("Notes: %s\n", key.Notes) + } + return nil + } +} + +// --- delete --- + +type deleteOptions struct { + *root.Options + keyType string + force bool +} + +func newDeleteCmd(opts *root.Options) *cobra.Command { + deleteOpts := &deleteOptions{Options: opts} + + cmd := &cobra.Command{ + Use: "delete [key-id...]", + Short: "Delete one or more API keys", + Long: `Delete one or more API keys. + +If --type is specified, all keys are treated as that type. +Otherwise, each key is looked up to determine its type.`, + Example: ` nrq keys delete NRAK-XXXXXXXXXXXX + nrq keys delete NRAK-XXXXXXXXXXXX NRAK-YYYYYYYYYYYY + nrq keys delete NRAK-XXXXXXXXXXXX --type user --force`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(deleteOpts, args) + }, + } + + cmd.Flags().StringVarP(&deleteOpts.keyType, "type", "t", "", "Key type: user or ingest (auto-detected if omitted)") + cmd.Flags().BoolVarP(&deleteOpts.force, "force", "f", false, "Skip confirmation prompt") + + return cmd +} + +func runDelete(opts *deleteOptions, keyIDs []string) error { + v := opts.View() + + if !opts.force { + msg := fmt.Sprintf("Delete %d API key(s)?", len(keyIDs)) + if len(keyIDs) == 1 { + msg = fmt.Sprintf("Delete API key %s?", keyIDs[0]) + } + p := &confirm.Prompter{ + In: opts.Stdin, + Out: opts.Stderr, + } + if !p.Confirm(msg) { + v.Warning("Operation canceled") + return nil + } + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + var userKeyIDs, ingestKeyIDs []string + + if opts.keyType != "" { + t := strings.ToUpper(opts.keyType) + if t != "USER" && t != "INGEST" { + return fmt.Errorf("invalid key type %q: must be user or ingest", opts.keyType) + } + switch t { + case "USER": + userKeyIDs = keyIDs + case "INGEST": + ingestKeyIDs = keyIDs + } + } else { + // Look up each key to determine its type + for _, id := range keyIDs { + key, findErr := client.FindAPIAccessKey(id) + if findErr != nil { + return fmt.Errorf("could not determine type for key %s (use --type to specify): %w", id, findErr) + } + switch key.Type { + case "USER": + userKeyIDs = append(userKeyIDs, id) + case "INGEST": + ingestKeyIDs = append(ingestKeyIDs, id) + default: + return fmt.Errorf("unexpected key type %q for key %s", key.Type, id) + } + } + } + + deletedIDs, err := client.DeleteAPIAccessKeys(userKeyIDs, ingestKeyIDs) + if err != nil { + return err + } + + if len(deletedIDs) == 1 { + v.Success("API key %s deleted", deletedIDs[0]) + } else { + v.Success("%d API keys deleted", len(deletedIDs)) + } + return nil +}