diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index be9624fe14..d0286247ff 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -4,11 +4,7 @@ package openapi import ( - "encoding/json" - "fmt" - "github.com/oapi-codegen/nullable" - "github.com/oapi-codegen/runtime" ) const ( @@ -1019,25 +1015,13 @@ type V2KeysGetKeyRequestBody struct { // Decryption requests are audited and may trigger security alerts in enterprise environments. Decrypt *bool `json:"decrypt,omitempty"` - // Key The complete API key string provided by you, including any prefix. - // Never log, cache, or store API keys in your system as they provide full access to user resources. - // Include the full key exactly as provided - even minor modifications will cause a not found error. - Key *string `json:"key,omitempty"` - // KeyId Specifies which key to retrieve using the database identifier returned from `keys.createKey`. // Do not confuse this with the actual API key string that users include in requests. // Key data includes metadata, permissions, usage statistics, and configuration but never the plaintext key value unless `decrypt=true`. // Find this ID in creation responses, key listings, dashboard, or verification responses. - KeyId *string `json:"keyId,omitempty"` - union json.RawMessage + KeyId string `json:"keyId"` } -// V2KeysGetKeyRequestBody0 defines model for . -type V2KeysGetKeyRequestBody0 = interface{} - -// V2KeysGetKeyRequestBody1 defines model for . -type V2KeysGetKeyRequestBody1 = interface{} - // V2KeysGetKeyResponseBody defines model for V2KeysGetKeyResponseBody. type V2KeysGetKeyResponseBody struct { Data KeyResponseData `json:"data"` @@ -1545,6 +1529,22 @@ type V2KeysVerifyKeyResponseData struct { // `EXPIRED` (key has passed its expiration date). type V2KeysVerifyKeyResponseDataCode string +// V2KeysWhoamiRequestBody defines model for V2KeysWhoamiRequestBody. +type V2KeysWhoamiRequestBody struct { + // Key The complete API key string provided by you, including any prefix. + // Never log, cache, or store API keys in your system as they provide full access to user resources. + // Include the full key exactly as provided - even minor modifications will cause a not found error. + Key string `json:"key"` +} + +// V2KeysWhoamiResponseBody defines model for V2KeysWhoamiResponseBody. +type V2KeysWhoamiResponseBody struct { + Data KeyResponseData `json:"data"` + + // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. + Meta Meta `json:"meta"` +} + // V2LivenessResponseBody defines model for V2LivenessResponseBody. type V2LivenessResponseBody struct { // Data Response data for the liveness check endpoint. This provides a simple indication of whether the Unkey API service is running and able to process requests. Monitoring systems can use this endpoint to track service availability and trigger alerts if the service becomes unhealthy. @@ -2164,6 +2164,9 @@ type UpdateKeyJSONRequestBody = V2KeysUpdateKeyRequestBody // VerifyKeyJSONRequestBody defines body for VerifyKey for application/json ContentType. type VerifyKeyJSONRequestBody = V2KeysVerifyKeyRequestBody +// WhoamiJSONRequestBody defines body for Whoami for application/json ContentType. +type WhoamiJSONRequestBody = V2KeysWhoamiRequestBody + // CreatePermissionJSONRequestBody defines body for CreatePermission for application/json ContentType. type CreatePermissionJSONRequestBody = V2PermissionsCreatePermissionRequestBody @@ -2202,127 +2205,3 @@ type RatelimitListOverridesJSONRequestBody = V2RatelimitListOverridesRequestBody // RatelimitSetOverrideJSONRequestBody defines body for RatelimitSetOverride for application/json ContentType. type RatelimitSetOverrideJSONRequestBody = V2RatelimitSetOverrideRequestBody - -// AsV2KeysGetKeyRequestBody0 returns the union data inside the V2KeysGetKeyRequestBody as a V2KeysGetKeyRequestBody0 -func (t V2KeysGetKeyRequestBody) AsV2KeysGetKeyRequestBody0() (V2KeysGetKeyRequestBody0, error) { - var body V2KeysGetKeyRequestBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2KeysGetKeyRequestBody0 overwrites any union data inside the V2KeysGetKeyRequestBody as the provided V2KeysGetKeyRequestBody0 -func (t *V2KeysGetKeyRequestBody) FromV2KeysGetKeyRequestBody0(v V2KeysGetKeyRequestBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2KeysGetKeyRequestBody0 performs a merge with any union data inside the V2KeysGetKeyRequestBody, using the provided V2KeysGetKeyRequestBody0 -func (t *V2KeysGetKeyRequestBody) MergeV2KeysGetKeyRequestBody0(v V2KeysGetKeyRequestBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV2KeysGetKeyRequestBody1 returns the union data inside the V2KeysGetKeyRequestBody as a V2KeysGetKeyRequestBody1 -func (t V2KeysGetKeyRequestBody) AsV2KeysGetKeyRequestBody1() (V2KeysGetKeyRequestBody1, error) { - var body V2KeysGetKeyRequestBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2KeysGetKeyRequestBody1 overwrites any union data inside the V2KeysGetKeyRequestBody as the provided V2KeysGetKeyRequestBody1 -func (t *V2KeysGetKeyRequestBody) FromV2KeysGetKeyRequestBody1(v V2KeysGetKeyRequestBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2KeysGetKeyRequestBody1 performs a merge with any union data inside the V2KeysGetKeyRequestBody, using the provided V2KeysGetKeyRequestBody1 -func (t *V2KeysGetKeyRequestBody) MergeV2KeysGetKeyRequestBody1(v V2KeysGetKeyRequestBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V2KeysGetKeyRequestBody) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - if err != nil { - return nil, err - } - object := make(map[string]json.RawMessage) - if t.union != nil { - err = json.Unmarshal(b, &object) - if err != nil { - return nil, err - } - } - - if t.Decrypt != nil { - object["decrypt"], err = json.Marshal(t.Decrypt) - if err != nil { - return nil, fmt.Errorf("error marshaling 'decrypt': %w", err) - } - } - - if t.Key != nil { - object["key"], err = json.Marshal(t.Key) - if err != nil { - return nil, fmt.Errorf("error marshaling 'key': %w", err) - } - } - - if t.KeyId != nil { - object["keyId"], err = json.Marshal(t.KeyId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'keyId': %w", err) - } - } - b, err = json.Marshal(object) - return b, err -} - -func (t *V2KeysGetKeyRequestBody) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - if err != nil { - return err - } - object := make(map[string]json.RawMessage) - err = json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["decrypt"]; found { - err = json.Unmarshal(raw, &t.Decrypt) - if err != nil { - return fmt.Errorf("error reading 'decrypt': %w", err) - } - } - - if raw, found := object["key"]; found { - err = json.Unmarshal(raw, &t.Key) - if err != nil { - return fmt.Errorf("error reading 'key': %w", err) - } - } - - if raw, found := object["keyId"]; found { - err = json.Unmarshal(raw, &t.KeyId) - if err != nil { - return fmt.Errorf("error reading 'keyId': %w", err) - } - } - - return err -} diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index c87f6909b7..41edd5c7ca 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-28T12:12:38Z +# Generated at: 2025-07-28T13:27:42Z # Source: openapi-split.yaml components: @@ -891,21 +891,9 @@ components: Use only for legitimate recovery scenarios like user password resets or emergency access. Most applications should keep this false to maintain security best practices and avoid accidental key exposure. Decryption requests are audited and may trigger security alerts in enterprise environments. - key: - type: string - minLength: 1 - maxLength: 512 - description: | - The complete API key string provided by you, including any prefix. - Never log, cache, or store API keys in your system as they provide full access to user resources. - Include the full key exactly as provided - even minor modifications will cause a not found error. - example: sk_1234abcdef5678 additionalProperties: false - oneOf: - - required: - - keyId - - required: - - key + required: + - keyId V2KeysGetKeyResponseBody: type: object required: @@ -1430,6 +1418,31 @@ components: data: "$ref": "#/components/schemas/V2KeysVerifyKeyResponseData" additionalProperties: false + V2KeysWhoamiRequestBody: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 512 + description: | + The complete API key string provided by you, including any prefix. + Never log, cache, or store API keys in your system as they provide full access to user resources. + Include the full key exactly as provided - even minor modifications will cause a not found error. + example: sk_1234abcdef5678 + additionalProperties: false + required: + - key + V2KeysWhoamiResponseBody: + type: object + required: + - meta + - data + properties: + meta: + "$ref": "#/components/schemas/Meta" + data: + "$ref": "#/components/schemas/KeyResponseData" V2LivenessResponseBody: type: object required: @@ -4987,6 +5000,69 @@ paths: tags: - keys x-speakeasy-name-override: verifyKey + /v2/keys.whoami: + post: + description: | + Find out what key this is. + + **Required Permissions** + + Your root key must have one of the following permissions for basic key information: + - `api.*.read_key` (to read keys from any API) + - `api..read_key` (to read keys from a specific API) + + If your rootkey lacks permissions but the key exists, we may return a 404 status here to prevent leaking the existance of a key to unauthorized clients. If you believe that a key should exist, but receive a 404, please double check your root key has the correct permissions. + operationId: whoami + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/V2KeysWhoamiRequestBody' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/V2KeysWhoamiResponseBody' + description: | + Successfully retrieved key information. + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequestErrorResponse' + description: Bad request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ForbiddenErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/NotFoundErrorResponse' + description: Not found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorResponse' + description: Internal server error + security: + - rootKey: [] + summary: Get API key by hash + tags: + - keys + x-speakeasy-name-override: whoami /v2/liveness: get: description: | diff --git a/go/apps/api/openapi/openapi-split.yaml b/go/apps/api/openapi/openapi-split.yaml index 346e4d17fe..8f85e8b985 100644 --- a/go/apps/api/openapi/openapi-split.yaml +++ b/go/apps/api/openapi/openapi-split.yaml @@ -148,6 +148,8 @@ paths: $ref: "./spec/paths/v2/keys/deleteKey/index.yaml" /v2/keys.getKey: $ref: "./spec/paths/v2/keys/getKey/index.yaml" + /v2/keys.whoami: + $ref: "./spec/paths/v2/keys/whoami/index.yaml" /v2/keys.verifyKey: $ref: "./spec/paths/v2/keys/verifyKey/index.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml index 328d6e633f..091ee8b5db 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml @@ -22,21 +22,9 @@ properties: Use only for legitimate recovery scenarios like user password resets or emergency access. Most applications should keep this false to maintain security best practices and avoid accidental key exposure. Decryption requests are audited and may trigger security alerts in enterprise environments. - key: - type: string - minLength: 1 - maxLength: 512 # Reasonable upper bound for API key strings - description: | - The complete API key string provided by you, including any prefix. - Never log, cache, or store API keys in your system as they provide full access to user resources. - Include the full key exactly as provided - even minor modifications will cause a not found error. - example: sk_1234abcdef5678 additionalProperties: false -oneOf: - - required: - - keyId - - required: - - key +required: + - keyId examples: dashboardKeyDetails: summary: Dashboard key information display diff --git a/go/apps/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiRequestBody.yaml new file mode 100644 index 0000000000..6a56f3be98 --- /dev/null +++ b/go/apps/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiRequestBody.yaml @@ -0,0 +1,20 @@ +type: object +properties: + key: + type: string + minLength: 1 + maxLength: 512 # Reasonable upper bound for API key strings + description: | + The complete API key string provided by you, including any prefix. + Never log, cache, or store API keys in your system as they provide full access to user resources. + Include the full key exactly as provided - even minor modifications will cause a not found error. + example: sk_1234abcdef5678 +additionalProperties: false +required: + - key +examples: + playground: + summary: Lookup by actual key string + description: Look up key details when provided the actual key string + value: + key: sk_1234abcdef5678 diff --git a/go/apps/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiResponseBody.yaml new file mode 100644 index 0000000000..99ae983950 --- /dev/null +++ b/go/apps/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiResponseBody.yaml @@ -0,0 +1,121 @@ +type: object +required: + - meta + - data +properties: + meta: + "$ref": "../../../../common/Meta.yaml" + data: + "$ref": "../../../../common/KeyResponseData.yaml" +examples: + dashboardKeyDetails: + summary: Dashboard key information for display + description: Typical response for displaying key details in a dashboard without decryption + value: + meta: + requestId: req_1234abcd + data: + keyId: key_1234abcd + start: sk_prod + enabled: true + name: Production API Key + createdAt: 1704067200000 + expires: 1735689600000 + meta: + plan: premium + userId: user_5678 + environment: production + permissions: + - documents.read + - documents.write + roles: + - editor + credits: + remaining: 8500 + refill: + amount: 10000 + interval: monthly + refillDay: 1 + ratelimits: + - id: rl_1234 + name: api_requests + limit: 1000 + duration: 60000 + autoApply: true + apiPlaygroundWithDecryption: + summary: Decrypted key for API playground + description: Response including plaintext key for testing API calls in the dashboard + value: + meta: + requestId: req_5678efgh + data: + keyId: key_5678efgh + start: sk_test + enabled: true + name: Development Testing Key + plaintext: sk_test_1234abcdef5678 + createdAt: 1704067200000 + meta: + environment: development + team: backend + permissions: + - documents.read + credits: + remaining: 500 + ratelimits: + - id: rl_5678 + name: dev_requests + limit: 100 + duration: 60000 + autoApply: true + keyWithIdentity: + summary: Key with associated identity information + description: Response showing key linked to an identity with shared rate limits + value: + meta: + requestId: req_9876zyxw + data: + keyId: key_9876zyxw + start: sk_user + enabled: true + name: User Mobile App Key + createdAt: 1704067200000 + meta: + device: mobile + platform: ios + identity: + externalId: user_9876 + meta: + plan: pro + signupDate: "2024-01-15" + ratelimits: + - id: rl_identity_1 + name: user_requests + limit: 5000 + duration: 3600000 + autoApply: true + permissions: + - profile.read + - profile.update + credits: + remaining: 2500 + expiredKey: + summary: Expired key information + description: Response for a key that has passed its expiration date + value: + meta: + requestId: req_expired + data: + keyId: key_expired_123 + start: sk_old + enabled: false + name: Legacy API Key + createdAt: 1672531200000 + expires: 1703980800000 + meta: + plan: legacy + migrationRequired: true + permissions: + - legacy.read + credits: + remaining: 0 diff --git a/go/apps/api/openapi/spec/paths/v2/keys/whoami/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/whoami/index.yaml new file mode 100644 index 0000000000..65a357d868 --- /dev/null +++ b/go/apps/api/openapi/spec/paths/v2/keys/whoami/index.yaml @@ -0,0 +1,63 @@ +post: + tags: + - keys + summary: Get API key by hash + description: | + Find out what key this is. + + **Required Permissions** + + Your root key must have one of the following permissions for basic key information: + - `api.*.read_key` (to read keys from any API) + - `api..read_key` (to read keys from a specific API) + + If your rootkey lacks permissions but the key exists, we may return a 404 status here to prevent leaking the existance of a key to unauthorized clients. If you believe that a key should exist, but receive a 404, please double check your root key has the correct permissions. + + operationId: whoami + x-speakeasy-name-override: whoami + security: + - rootKey: [] + requestBody: + content: + application/json: + schema: + "$ref": "./V2KeysWhoamiRequestBody.yaml" + required: true + responses: + "200": + content: + application/json: + schema: + "$ref": "./V2KeysWhoamiResponseBody.yaml" + description: | + Successfully retrieved key information. + "400": + description: Bad request + content: + application/json: + schema: + "$ref": "../../../../error/BadRequestErrorResponse.yaml" + "401": + description: Unauthorized + content: + application/json: + schema: + "$ref": "../../../../error/UnauthorizedErrorResponse.yaml" + "403": + description: Forbidden + content: + application/json: + schema: + "$ref": "../../../../error/ForbiddenErrorResponse.yaml" + "404": + description: Not found + content: + application/json: + schema: + "$ref": "../../../../error/NotFoundErrorResponse.yaml" + "500": + description: Internal server error + content: + application/json: + schema: + "$ref": "../../../../error/InternalServerErrorResponse.yaml" diff --git a/go/apps/api/routes/register.go b/go/apps/api/routes/register.go index 93fc933d7c..2a11a12604 100644 --- a/go/apps/api/routes/register.go +++ b/go/apps/api/routes/register.go @@ -46,6 +46,7 @@ import ( v2KeysUpdateCredits "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_update_credits" v2KeysUpdateKey "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_update_key" v2KeysVerifyKey "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_verify_key" + v2KeysWhoami "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_whoami" zen "github.com/unkeyed/unkey/go/pkg/zen" ) @@ -412,6 +413,18 @@ func Register(srv *zen.Server, svc *Services) { }, ) + // v2/keys.whoami + srv.RegisterRoute( + defaultMiddlewares, + &v2KeysWhoami.Handler{ + Logger: svc.Logger, + DB: svc.Database, + Keys: svc.Keys, + Auditlogs: svc.Auditlogs, + Vault: svc.Vault, + }, + ) + // v2/keys.updateCredits srv.RegisterRoute( defaultMiddlewares, diff --git a/go/apps/api/routes/v2_keys_get_key/200_test.go b/go/apps/api/routes/v2_keys_get_key/200_test.go index 625452b93a..90460806e6 100644 --- a/go/apps/api/routes/v2_keys_get_key/200_test.go +++ b/go/apps/api/routes/v2_keys_get_key/200_test.go @@ -97,7 +97,7 @@ func TestGetKeyByKeyID(t *testing.T) { // This also tests that we have the correct data for the key. t.Run("get key by keyId without decrypting", func(t *testing.T) { req := handler.Request{ - KeyId: ptr.P(keyID), + KeyId: keyID, Decrypt: ptr.P(false), } @@ -109,7 +109,7 @@ func TestGetKeyByKeyID(t *testing.T) { t.Run("get key by keyId with decrypting", func(t *testing.T) { req := handler.Request{ - KeyId: ptr.P(keyID), + KeyId: keyID, Decrypt: ptr.P(true), } @@ -119,28 +119,6 @@ func TestGetKeyByKeyID(t *testing.T) { require.Equal(t, ptr.SafeDeref(res.Body.Data.Plaintext), key.Key) }) - t.Run("get key by plaintext key", func(t *testing.T) { - req := handler.Request{ - Key: ptr.P(key.Key), - } - - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.Equal(t, res.Body.Data.KeyId, keyID) - }) - - t.Run("get key by plaintext key with decrypting", func(t *testing.T) { - req := handler.Request{ - Key: ptr.P(key.Key), - Decrypt: ptr.P(true), - } - - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.Equal(t, ptr.SafeDeref(res.Body.Data.Plaintext), key.Key) - }) } func TestGetKey_AdditionalScenarios(t *testing.T) { @@ -195,7 +173,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { keyID := keyResponse.KeyID req := handler.Request{ - KeyId: ptr.P(keyID), + KeyId: keyID, Decrypt: ptr.P(false), } @@ -219,7 +197,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { }) req := handler.Request{ - KeyId: ptr.P(keyResponse.KeyID), + KeyId: keyResponse.KeyID, Decrypt: ptr.P(false), } @@ -239,7 +217,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { }) req := handler.Request{ - KeyId: ptr.P(keyResponse.KeyID), + KeyId: keyResponse.KeyID, Decrypt: ptr.P(false), } @@ -263,7 +241,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { }) req := handler.Request{ - KeyId: ptr.P(keyResponse.KeyID), + KeyId: keyResponse.KeyID, Decrypt: ptr.P(false), } @@ -299,7 +277,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { }) req := handler.Request{ - KeyId: ptr.P(keyResponse.KeyID), + KeyId: keyResponse.KeyID, Decrypt: ptr.P(false), } @@ -346,7 +324,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { }) req := handler.Request{ - KeyId: ptr.P(keyResponse.KeyID), + KeyId: keyResponse.KeyID, Decrypt: ptr.P(false), } @@ -388,7 +366,7 @@ func TestGetKey_AdditionalScenarios(t *testing.T) { }) req := handler.Request{ - KeyId: ptr.P(keyResponse.KeyID), + KeyId: keyResponse.KeyID, Decrypt: ptr.P(false), } diff --git a/go/apps/api/routes/v2_keys_get_key/400_test.go b/go/apps/api/routes/v2_keys_get_key/400_test.go index d00c5b746b..1a9c1518db 100644 --- a/go/apps/api/routes/v2_keys_get_key/400_test.go +++ b/go/apps/api/routes/v2_keys_get_key/400_test.go @@ -33,37 +33,9 @@ func TestGetKeyBadRequest(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("missing both keyId and key", func(t *testing.T) { - req := handler.Request{ - KeyId: nil, - Key: nil, - Decrypt: ptr.P(false), - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "POST request body for '/v2/keys.getKey' failed to validate schema") - }) - - t.Run("both keyId and key provided", func(t *testing.T) { - req := handler.Request{ - KeyId: ptr.P("key_123"), - Key: ptr.P("test_key"), - Decrypt: ptr.P(false), - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "POST request body for '/v2/keys.getKey' failed to validate schema") - }) - t.Run("empty keyId string", func(t *testing.T) { req := handler.Request{ - KeyId: ptr.P(""), + KeyId: "", Decrypt: ptr.P(false), } @@ -73,14 +45,4 @@ func TestGetKeyBadRequest(t *testing.T) { require.NotNil(t, res.Body.Error) }) - t.Run("empty key string", func(t *testing.T) { - req := handler.Request{ - Key: ptr.P(""), - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - }) } diff --git a/go/apps/api/routes/v2_keys_get_key/401_test.go b/go/apps/api/routes/v2_keys_get_key/401_test.go index c28ffb01f0..b3a339abed 100644 --- a/go/apps/api/routes/v2_keys_get_key/401_test.go +++ b/go/apps/api/routes/v2_keys_get_key/401_test.go @@ -26,7 +26,7 @@ func TestGetKeyUnauthorized(t *testing.T) { h.Register(route) req := handler.Request{ - KeyId: ptr.P(uid.New(uid.KeyPrefix)), + KeyId: uid.New(uid.KeyPrefix), Decrypt: ptr.P(false), } diff --git a/go/apps/api/routes/v2_keys_get_key/403_test.go b/go/apps/api/routes/v2_keys_get_key/403_test.go index 6423ebb4e4..1a0ef725ef 100644 --- a/go/apps/api/routes/v2_keys_get_key/403_test.go +++ b/go/apps/api/routes/v2_keys_get_key/403_test.go @@ -122,7 +122,7 @@ func TestGetKeyForbidden(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: ptr.P(keyID), + KeyId: keyID, Decrypt: ptr.P(true), } diff --git a/go/apps/api/routes/v2_keys_get_key/404_test.go b/go/apps/api/routes/v2_keys_get_key/404_test.go index 8c98fb80a1..9a94d54bb5 100644 --- a/go/apps/api/routes/v2_keys_get_key/404_test.go +++ b/go/apps/api/routes/v2_keys_get_key/404_test.go @@ -36,7 +36,7 @@ func TestGetKeyNotFound(t *testing.T) { t.Run("nonexistent keyId", func(t *testing.T) { nonexistentKeyID := uid.New(uid.KeyPrefix) req := handler.Request{ - KeyId: ptr.P(nonexistentKeyID), + KeyId: nonexistentKeyID, Decrypt: ptr.P(false), } @@ -46,15 +46,4 @@ func TestGetKeyNotFound(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "We could not find the requested key") }) - t.Run("nonexistent raw key", func(t *testing.T) { - nonexistentKey := uid.New("api") - req := handler.Request{ - Key: ptr.P(nonexistentKey), - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "We could not find the requested key") - }) } diff --git a/go/apps/api/routes/v2_keys_get_key/412_test.go b/go/apps/api/routes/v2_keys_get_key/412_test.go index d73c574687..7341af7ce5 100644 --- a/go/apps/api/routes/v2_keys_get_key/412_test.go +++ b/go/apps/api/routes/v2_keys_get_key/412_test.go @@ -50,7 +50,7 @@ func TestPreconditionError(t *testing.T) { t.Run("Try getting a recoverable key without being opt-in", func(t *testing.T) { req := handler.Request{ Decrypt: ptr.P(true), - KeyId: ptr.P(key.KeyID), + KeyId: key.KeyID, } res := testutil.CallRoute[handler.Request, openapi.PreconditionFailedErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_get_key/handler.go b/go/apps/api/routes/v2_keys_get_key/handler.go index b18fb3db21..f86ea69367 100644 --- a/go/apps/api/routes/v2_keys_get_key/handler.go +++ b/go/apps/api/routes/v2_keys_get_key/handler.go @@ -14,7 +14,6 @@ import ( "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/fault" - "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -60,18 +59,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // nolint:exhaustruct - args := db.FindKeyByIdOrHashParams{} - if req.KeyId != nil { - args.ID = sql.NullString{String: *req.KeyId, Valid: true} - } else if req.Key != nil { - args.Hash = sql.NullString{String: hash.Sha256(*req.Key), Valid: true} - } else { - return fault.New("invalid request", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("missing keyId or key identifier"), - fault.Public("Either keyId or key must be provided."), - ) + args := db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, } key, err := db.Query.FindKeyByIdOrHash(ctx, h.DB.RO(), args) @@ -101,15 +91,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Check if API is deleted - if key.Api.DeletedAtM.Valid { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to deleted api"), - fault.Public("The specified key was not found."), - ) - } - // Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ @@ -177,7 +158,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { // If the key is encrypted and the encryption key ID is valid, decrypt the key. // Otherwise the key was never encrypted to begin with. - if key.EncryptedKey.Valid && key.EncryptionKeyID.Valid && req.Key == nil { + if key.EncryptedKey.Valid && key.EncryptionKeyID.Valid { decrypted, decryptErr := h.Vault.Decrypt(ctx, &vaultv1.DecryptRequest{ Keyring: key.WorkspaceID, Encrypted: key.EncryptedKey.String, @@ -193,10 +174,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - if req.Key != nil { - // Only respond with the plaintext key if EXPLICITLY requested. - plaintext = req.Key - } } k := openapi.KeyResponseData{ diff --git a/go/apps/api/routes/v2_keys_whoami/200_test.go b/go/apps/api/routes/v2_keys_whoami/200_test.go new file mode 100644 index 0000000000..c5a7e13f7a --- /dev/null +++ b/go/apps/api/routes/v2_keys_whoami/200_test.go @@ -0,0 +1,355 @@ +package handler_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/oapi-codegen/nullable" + vaultv1 "github.com/unkeyed/unkey/go/gen/proto/vault/v1" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_whoami" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/testutil/seed" +) + +func TestGetKeyByKey(t *testing.T) { + h := testutil.NewHarness(t) + ctx := context.Background() + + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + Auditlogs: h.Auditlogs, + Vault: h.Vault, + } + + h.Register(route) + + // Create a workspace and user + workspace := h.Resources().UserWorkspace + + // Create a test API with encrypted keys using testutil helper + apiName := "Test API" + api := h.CreateApi(seed.CreateApiRequest{ + WorkspaceID: workspace.ID, + Name: &apiName, + EncryptedKeys: true, + }) + + // Create test identity with ratelimit using testutil helper + identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: workspace.ID, + ExternalID: "test_user", + Meta: []byte(`{"role": "admin"}`), + Ratelimits: []seed.CreateRatelimitRequest{ + { + WorkspaceID: workspace.ID, + Name: "api_calls", + Limit: 100, + Duration: 60000, + }, + }, + }) + + // Create test key with identity and encryption using testutil helper + keyName := "test-key" + key := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Name: &keyName, + IdentityID: &identityID, + }) + keyID := key.KeyID + + // Add encryption for the key since API has encrypted keys enabled + encryption, err := h.Vault.Encrypt(ctx, &vaultv1.EncryptRequest{ + Keyring: workspace.ID, + Data: key.Key, + }) + require.NoError(t, err) + + err = db.Query.InsertKeyEncryption(ctx, h.DB.RW(), db.InsertKeyEncryptionParams{ + WorkspaceID: workspace.ID, + KeyID: keyID, + CreatedAt: time.Now().UnixMilli(), + Encrypted: encryption.GetEncrypted(), + EncryptionKeyID: encryption.GetKeyId(), + }) + require.NoError(t, err) + + // Create a root key with appropriate permissions + rootKey := h.CreateRootKey(workspace.ID, "api.*.read_key", "api.*.decrypt_key") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("get key by plaintext key", func(t *testing.T) { + req := handler.Request{ + Key: key.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status, "Expected status code 200, got %s", res.RawBody) + require.NotNil(t, res.Body) + require.Equal(t, res.Body.Data.KeyId, keyID) + }) + +} + +func TestGetKey_AdditionalScenarios(t *testing.T) { + h := testutil.NewHarness(t) + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + Auditlogs: h.Auditlogs, + Vault: h.Vault, + } + + h.Register(route) + + workspace := h.Resources().UserWorkspace + + // Create test API using testutil helper + apiName := "Test API" + api := h.CreateApi(seed.CreateApiRequest{ + WorkspaceID: workspace.ID, + Name: &apiName, + }) + + // Create root key with appropriate permissions + rootKey := h.CreateRootKey(workspace.ID, "api.*.read_key") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("key with complex meta data", func(t *testing.T) { + // Create test key with complex meta using testutil helper + complexMeta := map[string]interface{}{ + "user_id": 12345, + "plan": "premium", + "features": []string{"analytics", "webhooks"}, + "created_by": "admin@example.com", + "nested": map[string]string{ + "department": "engineering", + "team": "backend", + }, + } + metaBytes, _ := json.Marshal(complexMeta) + metaString := string(metaBytes) + keyName := "complex-meta-key" + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Name: &keyName, + Meta: &metaString, + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data.Meta) + + // Verify meta data was properly unmarshaled + metaMap := *res.Body.Data.Meta + require.Equal(t, float64(12345), metaMap["user_id"]) // JSON numbers become float64 + require.Equal(t, "premium", metaMap["plan"]) + }) + + t.Run("key with expiration date", func(t *testing.T) { + futureDate := time.Now().Add(24 * time.Hour).Truncate(time.Hour) + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Expires: &futureDate, + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data.Expires) + require.Equal(t, futureDate.UnixMilli(), *res.Body.Data.Expires) + }) + + t.Run("key with credits and daily refill", func(t *testing.T) { + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Remaining: ptr.P(int32(50)), + RefillAmount: ptr.P(int32(100)), + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data.Credits) + require.Equal(t, nullable.NewNullableWithValue(int64(50)), res.Body.Data.Credits.Remaining) + require.NotNil(t, res.Body.Data.Credits.Refill) + require.Equal(t, int64(100), res.Body.Data.Credits.Refill.Amount) + require.Equal(t, "daily", string(res.Body.Data.Credits.Refill.Interval)) + }) + + t.Run("key with monthly refill", func(t *testing.T) { + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Remaining: ptr.P(int32(50)), + RefillAmount: ptr.P(int32(100)), + RefillDay: ptr.P(int16(1)), + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data.Credits) + require.NotNil(t, res.Body.Data.Credits.Refill) + require.Equal(t, "monthly", string(res.Body.Data.Credits.Refill.Interval)) + require.Equal(t, 1, *res.Body.Data.Credits.Refill.RefillDay) + }) + + t.Run("key with roles and permissions", func(t *testing.T) { + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Permissions: []seed.CreatePermissionRequest{{ + Name: "read_data", + Slug: "read_data", + Description: nil, + WorkspaceID: workspace.ID, + }, { + Name: "write_data", + Slug: "write_data", + Description: nil, + WorkspaceID: workspace.ID, + }}, + Roles: []seed.CreateRoleRequest{{ + Name: "data_admin", + Description: nil, + WorkspaceID: workspace.ID, + }}, + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data.Permissions) + require.NotNil(t, res.Body.Data.Roles) + + permissions := *res.Body.Data.Permissions + require.Len(t, permissions, 2) + require.Contains(t, permissions, "read_data") + require.Contains(t, permissions, "write_data") + + roles := *res.Body.Data.Roles + require.Len(t, roles, 1) + require.Contains(t, roles, "data_admin") + }) + + t.Run("key with ratelimits", func(t *testing.T) { + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Ratelimits: []seed.CreateRatelimitRequest{ + { + Name: "api_calls", + WorkspaceID: workspace.ID, + AutoApply: false, + Duration: 60000, // 1minute + Limit: 100, + IdentityID: nil, + KeyID: nil, + }, + { + Name: "data_transfer", + WorkspaceID: workspace.ID, + AutoApply: true, + Duration: 3600000, // 1 hour + Limit: 1000, + IdentityID: nil, + KeyID: nil, + }, + }, + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data.Ratelimits) + + ratelimits := *res.Body.Data.Ratelimits + require.Len(t, ratelimits, 2) + + // Find each ratelimit and verify + var apiCallsRL, dataTransferRL *openapi.RatelimitResponse + for _, rl := range ratelimits { + switch rl.Name { + case "api_calls": + apiCallsRL = &rl + case "data_transfer": + dataTransferRL = &rl + } + } + + require.NotNil(t, apiCallsRL) + require.Equal(t, int64(100), apiCallsRL.Limit) + require.Equal(t, int64(60000), apiCallsRL.Duration) + require.False(t, apiCallsRL.AutoApply) + + require.NotNil(t, dataTransferRL) + require.Equal(t, int64(1000), dataTransferRL.Limit) + require.Equal(t, int64(3600000), dataTransferRL.Duration) + require.True(t, dataTransferRL.AutoApply) + }) + + t.Run("disabled key", func(t *testing.T) { + keyResponse := h.CreateKey(seed.CreateKeyRequest{ + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Disabled: true, + }) + + req := handler.Request{ + Key: keyResponse.Key, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.False(t, res.Body.Data.Enabled) + }) +} diff --git a/go/apps/api/routes/v2_keys_whoami/400_test.go b/go/apps/api/routes/v2_keys_whoami/400_test.go new file mode 100644 index 0000000000..195be5171f --- /dev/null +++ b/go/apps/api/routes/v2_keys_whoami/400_test.go @@ -0,0 +1,45 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_whoami" + "github.com/unkeyed/unkey/go/pkg/testutil" +) + +func TestGetKeyBadRequest(t *testing.T) { + h := testutil.NewHarness(t) + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Auditlogs: h.Auditlogs, + Vault: h.Vault, + } + + h.Register(route) + + // Create root key with read permissions + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.read_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("empty key string", func(t *testing.T) { + req := handler.Request{ + Key: "", + } + + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) +} diff --git a/go/apps/api/routes/v2_keys_whoami/401_test.go b/go/apps/api/routes/v2_keys_whoami/401_test.go new file mode 100644 index 0000000000..2e9e86942b --- /dev/null +++ b/go/apps/api/routes/v2_keys_whoami/401_test.go @@ -0,0 +1,89 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_whoami" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestGetKeyUnauthorized(t *testing.T) { + h := testutil.NewHarness(t) + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Auditlogs: h.Auditlogs, + Vault: h.Vault, + } + + h.Register(route) + + req := handler.Request{ + Key: uid.New(uid.TestPrefix), + } + + t.Run("missing authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("empty authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {""}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("malformed authorization header - no Bearer prefix", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"invalid_token_without_bearer"}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("malformed authorization header - Bearer only", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer"}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("nonexistent root key", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer " + uid.New(uid.KeyPrefix)}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 401, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) +} diff --git a/go/apps/api/routes/v2_keys_whoami/404_test.go b/go/apps/api/routes/v2_keys_whoami/404_test.go new file mode 100644 index 0000000000..7d26841e7f --- /dev/null +++ b/go/apps/api/routes/v2_keys_whoami/404_test.go @@ -0,0 +1,224 @@ +package handler_test + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_whoami" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/hash" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestGetKeyNotFound(t *testing.T) { + h := testutil.NewHarness(t) + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Auditlogs: h.Auditlogs, + Vault: h.Vault, + } + + h.Register(route) + + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.read_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("nonexistent raw key", func(t *testing.T) { + nonexistentKey := uid.New("api") + req := handler.Request{ + Key: nonexistentKey, + } + + res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + require.Contains(t, res.Body.Error.Detail, "We could not find the requested key") + }) +} + +func TestGetKeyForbidden(t *testing.T) { + + h := testutil.NewHarness(t) + ctx := context.Background() + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Auditlogs: h.Auditlogs, + Vault: h.Vault, + } + + h.Register(route) + + // Create API for testing + keyAuthID := uid.New(uid.KeyAuthPrefix) + err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: keyAuthID, + WorkspaceID: h.Resources().UserWorkspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false, String: ""}, + DefaultBytes: sql.NullInt32{Valid: false, Int32: 0}, + }) + require.NoError(t, err) + + apiID := uid.New(uid.APIPrefix) + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: apiID, + Name: "test-api", + WorkspaceID: h.Resources().UserWorkspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: keyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create another API for cross-API testing + otherKeyAuthID := uid.New(uid.KeyAuthPrefix) + err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: otherKeyAuthID, + WorkspaceID: h.Resources().UserWorkspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false, String: ""}, + DefaultBytes: sql.NullInt32{Valid: false, Int32: 0}, + }) + require.NoError(t, err) + + otherApiID := uid.New(uid.APIPrefix) + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: otherApiID, + Name: "other-api", + WorkspaceID: h.Resources().UserWorkspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: otherKeyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create another Workspace for cross-API testing + otherWorkspace := h.CreateWorkspace() + + otherWsKeyAuthID := uid.New(uid.KeyAuthPrefix) + err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: otherWsKeyAuthID, + WorkspaceID: otherWorkspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false, String: ""}, + DefaultBytes: sql.NullInt32{Valid: false, Int32: 0}, + }) + require.NoError(t, err) + + otherWsApiID := uid.New(uid.APIPrefix) + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: otherWsApiID, + Name: "test-api", + WorkspaceID: otherWorkspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: otherWsKeyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create a test key + keyID := uid.New(uid.KeyPrefix) + keyString := "test_" + uid.New("") + err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ + ID: keyID, + KeyringID: keyAuthID, + Hash: hash.Sha256(keyString), + Start: keyString[:4], + WorkspaceID: h.Resources().UserWorkspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "Test Key"}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false}, + Meta: sql.NullString{Valid: false}, + Expires: sql.NullTime{Valid: false}, + RemainingRequests: sql.NullInt32{Valid: false}, + }) + require.NoError(t, err) + + req := handler.Request{ + Key: keyString, + } + + t.Run("no permissions", func(t *testing.T) { + // Create root key with no permissions + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + // We do not want to leak the existence of a key to someone who doesn't have permissions, + // so we return a 404 here + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("wrong permission - has create but not read", func(t *testing.T) { + // Create root key with read permission instead of create + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.create_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + // We do not want to leak the existence of a key to someone who doesn't have permissions, + // so we return a 404 here + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("cross workspace access", func(t *testing.T) { + // Create a different workspace + differentWorkspace := h.CreateWorkspace() + + // Create a root key for the different workspace with full permissions + rootKey := h.CreateRootKey(differentWorkspace.ID, "api.*.read_key", "api.*.read_api") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("cross api access", func(t *testing.T) { + // Create root key with read permission for a single api + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("api.%s.read_key", otherApiID)) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + // We do not want to leak the existence of a key to someone who doesn't have permissions, + // so we return a 404 here + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + }) +} diff --git a/go/apps/api/routes/v2_keys_whoami/handler.go b/go/apps/api/routes/v2_keys_whoami/handler.go new file mode 100644 index 0000000000..181f8eb1be --- /dev/null +++ b/go/apps/api/routes/v2_keys_whoami/handler.go @@ -0,0 +1,277 @@ +package handler + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + + "github.com/oapi-codegen/nullable" + "github.com/unkeyed/unkey/go/apps/api/openapi" + "github.com/unkeyed/unkey/go/internal/services/auditlogs" + "github.com/unkeyed/unkey/go/internal/services/keys" + "github.com/unkeyed/unkey/go/pkg/codes" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/hash" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/rbac" + "github.com/unkeyed/unkey/go/pkg/vault" + "github.com/unkeyed/unkey/go/pkg/zen" +) + +type Request = openapi.V2KeysWhoamiRequestBody +type Response = openapi.V2KeysWhoamiResponseBody + +type Handler struct { + // Services as public fields + Logger logging.Logger + DB db.Database + Keys keys.KeyService + Auditlogs auditlogs.AuditLogService + Vault *vault.Service +} + +// Method returns the HTTP method this route responds to +func (h *Handler) Method() string { + return "POST" +} + +// Path returns the URL path pattern this route matches +func (h *Handler) Path() string { + return "/v2/keys.whoami" +} + +func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { + + // Authentication + auth, err := h.Keys.GetRootKey(ctx, s) + if err != nil { + return err + } + + // Request validation + req, err := zen.BindBody[Request](s) + if err != nil { + return err + } + + args := db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: "", Valid: false}, + Hash: sql.NullString{String: hash.Sha256(req.Key), Valid: true}, + } + + key, err := db.Query.FindKeyByIdOrHash(ctx, h.DB.RO(), args) + if err != nil { + if db.IsNotFound(err) { + return fault.Wrap( + err, + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key does not exist"), + fault.Public("We could not find the requested key."), + ) + } + + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to retrieve Key information."), + ) + } + + // Validate key belongs to authorized workspace + if key.WorkspaceID != auth.AuthorizedWorkspaceID { + return fault.New("key not found", + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key belongs to different workspace"), + fault.Public("The specified key was not found."), + ) + } + + // Permission check + err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.ReadKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.ReadKey, + }), + ))) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("user doesn't have permissions and we don't want to leak the existence of the key"), + fault.Public("The specified key was not found."), + ) + } + + k := openapi.KeyResponseData{ + CreatedAt: key.CreatedAtM, + Enabled: key.Enabled, + KeyId: key.ID, + Start: key.Start, + Plaintext: nil, + Name: nil, + Meta: nil, + Identity: nil, + Credits: nil, + Expires: nil, + Permissions: nil, + Ratelimits: nil, + Roles: nil, + UpdatedAt: nil, + } + + if key.Name.Valid { + k.Name = ptr.P(key.Name.String) + } + + if key.UpdatedAtM.Valid { + k.UpdatedAt = ptr.P(key.UpdatedAtM.Int64) + } + + if key.Expires.Valid { + k.Expires = ptr.P(key.Expires.Time.UnixMilli()) + } + + if key.RemainingRequests.Valid { + k.Credits = &openapi.KeyCreditsData{ + Remaining: nullable.NewNullableWithValue(int64(key.RemainingRequests.Int32)), + Refill: nil, + } + + if key.RefillAmount.Valid { + var refillDay *int + interval := openapi.Daily + if key.RefillDay.Valid { + interval = openapi.Monthly + refillDay = ptr.P(int(key.RefillDay.Int16)) + } + + k.Credits.Refill = &openapi.KeyCreditsRefill{ + Amount: int64(key.RefillAmount.Int32), + Interval: interval, + RefillDay: refillDay, + } + } + } + + if key.IdentityID.Valid { + identity, idErr := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ID: key.IdentityID.String, Deleted: false}) + if idErr != nil { + if db.IsNotFound(idErr) { + return fault.New("identity not found for key", + fault.Code(codes.Data.Identity.NotFound.URN()), + fault.Internal("identity not found"), + fault.Public("The requested identity does not exist or has been deleted."), + ) + } + + return fault.Wrap(idErr, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to retrieve Identity information."), + ) + } + + k.Identity = &openapi.Identity{ + ExternalId: identity.ExternalID, + Meta: nil, + Ratelimits: nil, + } + + if len(identity.Meta) > 0 { + err = json.Unmarshal(identity.Meta, &k.Identity.Meta) + if err != nil { + return fault.Wrap(err, fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Internal("unable to unmarshal identity meta"), + fault.Public("We encountered an error while trying to unmarshal the identity meta data."), + ) + } + } + + ratelimits, rlErr := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{Valid: true, String: identity.ID}) + if rlErr != nil && !db.IsNotFound(rlErr) { + return fault.Wrap(rlErr, fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Internal("unable to retrieve identity ratelimits"), + fault.Public("We encountered an error while trying to retrieve the identity ratelimits."), + ) + } + + for _, ratelimit := range ratelimits { + k.Identity.Ratelimits = append(k.Identity.Ratelimits, openapi.RatelimitResponse{ + Id: ratelimit.ID, + Duration: ratelimit.Duration, + Limit: int64(ratelimit.Limit), + Name: ratelimit.Name, + AutoApply: ratelimit.AutoApply, + }) + } + } + + ratelimits, err := db.Query.ListRatelimitsByKeyID(ctx, h.DB.RO(), sql.NullString{String: key.ID, Valid: true}) + if err != nil && !db.IsNotFound(err) { + return fault.Wrap(err, fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Internal("unable to retrieve key ratelimits"), + fault.Public("We encountered an error while trying to retrieve the key ratelimits."), + ) + } + + ratelimitsResponse := make([]openapi.RatelimitResponse, len(ratelimits)) + for idx, ratelimit := range ratelimits { + ratelimitsResponse[idx] = openapi.RatelimitResponse{ + Id: ratelimit.ID, + Duration: ratelimit.Duration, + Limit: int64(ratelimit.Limit), + Name: ratelimit.Name, + AutoApply: ratelimit.AutoApply, + } + } + + k.Ratelimits = ptr.P(ratelimitsResponse) + + if key.Meta.Valid { + err = json.Unmarshal([]byte(key.Meta.String), &k.Meta) + if err != nil { + return fault.Wrap(err, fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Internal("unable to unmarshal key meta"), + fault.Public("We encountered an error while trying to unmarshal the key meta data."), + ) + } + } + + permissionSlugs, err := db.Query.ListPermissionsByKeyID(ctx, h.DB.RO(), db.ListPermissionsByKeyIDParams{ + KeyID: k.KeyId, + }) + if err != nil { + return fault.Wrap(err, fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Internal("unable to find permissions for key"), fault.Public("Could not load permissions for key.")) + } + k.Permissions = ptr.P(permissionSlugs) + + // Get roles for the key + roles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), k.KeyId) + if err != nil { + return fault.Wrap(err, fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Internal("unable to find roles for key"), fault.Public("Could not load roles for key.")) + } + + roleNames := make([]string, len(roles)) + for i, role := range roles { + roleNames[i] = role.Name + } + + k.Roles = ptr.P(roleNames) + + return s.JSON(http.StatusOK, Response{ + Meta: openapi.Meta{ + RequestId: s.RequestID(), + }, + Data: k, + }) +}