diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 7a94e32d2a0..dca78adea38 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -241,6 +241,7 @@
"errors/unkey/data/key_auth_not_found",
"errors/unkey/data/key_not_found",
"errors/unkey/data/key_space_not_found",
+ "errors/unkey/data/migration_not_found",
"errors/unkey/data/permission_already_exists",
"errors/unkey/data/permission_not_found",
"errors/unkey/data/ratelimit_namespace_gone",
diff --git a/apps/docs/errors/unkey/data/migration_not_found.mdx b/apps/docs/errors/unkey/data/migration_not_found.mdx
new file mode 100644
index 00000000000..1e1812dd294
--- /dev/null
+++ b/apps/docs/errors/unkey/data/migration_not_found.mdx
@@ -0,0 +1,51 @@
+---
+title: "migration_not_found"
+description: "The requested Key Migration was not found"
+---
+
+err:unkey:data:migration_not_found
+
+```json Example
+{
+ "meta": {
+ "requestId": "req_2c9a0jf23l4k567"
+ },
+ "error": {
+ "detail": "The requested Migration could not be found",
+ "status": 404,
+ "title": "Not Found",
+ "type": "https://unkey.com/docs/api-reference/errors-v2/unkey/data/migration_not_found"
+ }
+}
+```
+
+## What Happened?
+
+This error occurs when you're trying to migrate api keys for a migration that doesn't exist in the Unkey system.
+
+Common scenarios that trigger this error:
+
+- Using an incorrect or expired migrationId
+- The migration was deleted
+- The migration belongs to a different workspace
+- Typos in the migrationId
+
+Here's an example of a request that would trigger this error:
+
+```bash
+# Attempting to migrate keys with a non-existent migrationId
+curl -X POST https://api.unkey.com/v2/keys.migrateKeys \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer " \
+ -d '{
+ "apiId": "api_123",
+ "migrationId": "migration_456",
+ "keys": [{ "hash": "deadbeef" }]
+ }'
+```
+
+## How To Fix
+
+
+ If you’re unsure about your migrationId or setup, contact support@unkey.com.
+
diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go
index bbb2b49c2f6..e8f65ed9a31 100644
--- a/go/apps/api/openapi/gen.go
+++ b/go/apps/api/openapi/gen.go
@@ -1059,6 +1059,101 @@ type V2KeysGetKeyResponseBody struct {
Meta Meta `json:"meta"`
}
+// V2KeysMigrateKeyData defines model for V2KeysMigrateKeyData.
+type V2KeysMigrateKeyData struct {
+ // Credits Credit configuration and remaining balance for this key.
+ Credits *KeyCreditsData `json:"credits,omitempty"`
+
+ // Enabled Controls whether the key is active immediately upon creation.
+ // When set to `false`, the key exists but all verification attempts fail with `code=DISABLED`.
+ // Useful for pre-creating keys that will be activated later or for keys requiring manual approval.
+ // Most keys should be created with `enabled=true` for immediate use.
+ Enabled *bool `json:"enabled,omitempty"`
+
+ // Expires Sets when this key automatically expires as a Unix timestamp in milliseconds.
+ // Verification fails with code=EXPIRED immediately after this time passes.
+ // Omitting this field creates a permanent key that never expires.
+ //
+ // Avoid setting timestamps in the past as they immediately invalidate the key.
+ // Keys expire based on server time, not client time, which prevents timezone-related issues.
+ // Essential for trial periods, temporary access, and security compliance requiring key rotation.
+ Expires *int64 `json:"expires,omitempty"`
+
+ // ExternalId Links this key to a user or entity in your system using your own identifier.
+ // Returned during verification to identify the key owner without additional database lookups.
+ // Essential for user-specific analytics, billing, and multi-tenant key management.
+ // Use your primary user ID, organization ID, or tenant ID for best results.
+ // Accepts letters, numbers, underscores, dots, and hyphens for flexible identifier formats.
+ ExternalId *string `json:"externalId,omitempty"`
+
+ // Hash The current hash of the key on your side
+ Hash string `json:"hash"`
+
+ // Meta Stores arbitrary JSON metadata returned during key verification for contextual information.
+ // Eliminates additional database lookups during verification, improving performance for stateless services.
+ // Avoid storing sensitive data here as it's returned in verification responses.
+ // Large metadata objects increase verification latency and should stay under 10KB total size.
+ Meta *map[string]interface{} `json:"meta,omitempty"`
+
+ // Name Sets a human-readable identifier for internal organization and dashboard display.
+ // Never exposed to end users, only visible in management interfaces and API responses.
+ // Avoid generic names like "API Key" when managing multiple keys for the same user or service.
+ Name *string `json:"name,omitempty"`
+
+ // Permissions Grants specific permissions directly to this key without requiring role membership.
+ // Wildcard permissions like `documents.*` grant access to all sub-permissions including `documents.read` and `documents.write`.
+ // Direct permissions supplement any permissions inherited from assigned roles.
+ Permissions *[]string `json:"permissions,omitempty"`
+
+ // Ratelimits Defines time-based rate limits that protect against abuse by controlling request frequency.
+ // Unlike credits which track total usage, rate limits reset automatically after each window expires.
+ // Multiple rate limits can control different operation types with separate thresholds and windows.
+ // Essential for preventing API abuse while maintaining good performance for legitimate usage.
+ Ratelimits *[]RatelimitRequest `json:"ratelimits,omitempty"`
+
+ // Roles Assigns existing roles to this key for permission management through role-based access control.
+ // Roles must already exist in your workspace before assignment.
+ // During verification, all permissions from assigned roles are checked against requested permissions.
+ // Roles provide a convenient way to group permissions and apply consistent access patterns across multiple keys.
+ Roles *[]string `json:"roles,omitempty"`
+}
+
+// V2KeysMigrateKeysMigration defines model for V2KeysMigrateKeysMigration.
+type V2KeysMigrateKeysMigration struct {
+ // Hash The hash provided in the migration request
+ Hash string `json:"hash"`
+
+ // KeyId The unique identifier for this key in Unkey's system. This is NOT the actual API key, but a reference ID used for management operations like updating or deleting the key. Store this ID in your database to reference the key later. This ID is not sensitive and can be logged or displayed in dashboards.
+ KeyId string `json:"keyId"`
+}
+
+// V2KeysMigrateKeysRequestBody defines model for V2KeysMigrateKeysRequestBody.
+type V2KeysMigrateKeysRequestBody struct {
+ // ApiId The ID of the API that the keys should be inserted into
+ ApiId string `json:"apiId"`
+ Keys []V2KeysMigrateKeyData `json:"keys"`
+
+ // MigrationId Identifier of the configured migration provider/strategy to use (e.g., "your_company").
+ MigrationId string `json:"migrationId"`
+}
+
+// V2KeysMigrateKeysResponseBody defines model for V2KeysMigrateKeysResponseBody.
+type V2KeysMigrateKeysResponseBody struct {
+ Data V2KeysMigrateKeysResponseData `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"`
+}
+
+// V2KeysMigrateKeysResponseData defines model for V2KeysMigrateKeysResponseData.
+type V2KeysMigrateKeysResponseData struct {
+ // Failed Hashes that could not be migrated (e.g., already exist in the system)
+ Failed []string `json:"failed"`
+
+ // Migrated Successfully migrated keys with their hash and generated keyId
+ Migrated []V2KeysMigrateKeysMigration `json:"migrated"`
+}
+
// V2KeysRemovePermissionsRequestBody defines model for V2KeysRemovePermissionsRequestBody.
type V2KeysRemovePermissionsRequestBody struct {
// KeyId Specifies which key to remove permissions from using the database identifier returned from `keys.createKey`.
@@ -1413,6 +1508,9 @@ type V2KeysVerifyKeyRequestBody struct {
// Include any prefix - even small changes will cause verification to fail.
Key string `json:"key"`
+ // MigrationId Migrate keys on demand from your previous system. Reach out for migration support at support@unkey.dev
+ MigrationId *string `json:"migrationId,omitempty"`
+
// Permissions Checks if the key has the specified permission(s) using a query syntax.
// Supports single permissions, logical operators (AND, OR), and parentheses for grouping.
// Examples:
@@ -2196,6 +2294,9 @@ type DeleteKeyJSONRequestBody = V2KeysDeleteKeyRequestBody
// GetKeyJSONRequestBody defines body for GetKey for application/json ContentType.
type GetKeyJSONRequestBody = V2KeysGetKeyRequestBody
+// MigrateKeysJSONRequestBody defines body for MigrateKeys for application/json ContentType.
+type MigrateKeysJSONRequestBody = V2KeysMigrateKeysRequestBody
+
// RemovePermissionsJSONRequestBody defines body for RemovePermissions for application/json ContentType.
type RemovePermissionsJSONRequestBody = V2KeysRemovePermissionsRequestBody
diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml
index 96ffe1b6994..27ec718ed9f 100644
--- a/go/apps/api/openapi/openapi-generated.yaml
+++ b/go/apps/api/openapi/openapi-generated.yaml
@@ -692,7 +692,7 @@ components:
name:
type: string
minLength: 1
- maxLength: 200
+ maxLength: 255
description: |
Sets a human-readable identifier for internal organization and dashboard display.
Never exposed to end users, only visible in management interfaces and API responses.
@@ -918,6 +918,41 @@ components:
"$ref": "#/components/schemas/Meta"
data:
"$ref": "#/components/schemas/KeyResponseData"
+ V2KeysMigrateKeysRequestBody:
+ type: object
+ properties:
+ migrationId:
+ type: string
+ minLength: 3
+ maxLength: 255
+ description: Identifier of the configured migration provider/strategy to use (e.g., "your_company").
+ example: your_company
+ apiId:
+ type: string
+ minLength: 3
+ maxLength: 255
+ description: The ID of the API that the keys should be inserted into
+ example: api_123456789
+ keys:
+ type: array
+ minItems: 1
+ items:
+ $ref: "#/components/schemas/V2KeysMigrateKeyData"
+ additionalProperties: false
+ required:
+ - migrationId
+ - apiId
+ - keys
+ V2KeysMigrateKeysResponseBody:
+ type: object
+ required:
+ - meta
+ - data
+ properties:
+ meta:
+ "$ref": "#/components/schemas/Meta"
+ data:
+ "$ref": "#/components/schemas/V2KeysMigrateKeysResponseData"
V2KeysRemovePermissionsRequestBody:
type: object
required:
@@ -1393,6 +1428,11 @@ components:
Omitting this field skips rate limit checks entirely, relying only on configured key rate limits.
Multiple rate limits can be checked simultaneously, each with different costs and temporary overrides.
Rate limit checks are optimized for performance but may allow brief bursts during high concurrency.
+ migrationId:
+ type: string
+ maxLength: 256
+ description: Migrate keys on demand from your previous system. Reach out for migration support at support@unkey.dev
+ example: "m_1234abcd"
V2KeysVerifyKeyResponseBody:
type: object
required:
@@ -2660,6 +2700,165 @@ components:
required:
- keyId
- key
+ V2KeysMigrateKeyData:
+ type: object
+ properties:
+ hash:
+ type: string
+ minLength: 3
+ description: The current hash of the key on your side
+ example: qwerty123
+ name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ description: |
+ Sets a human-readable identifier for internal organization and dashboard display.
+ Never exposed to end users, only visible in management interfaces and API responses.
+ Avoid generic names like "API Key" when managing multiple keys for the same user or service.
+ example: Payment Service Production Key
+ externalId:
+ type: string
+ minLength: 1
+ maxLength: 255
+ description: |
+ Links this key to a user or entity in your system using your own identifier.
+ Returned during verification to identify the key owner without additional database lookups.
+ Essential for user-specific analytics, billing, and multi-tenant key management.
+ Use your primary user ID, organization ID, or tenant ID for best results.
+ Accepts letters, numbers, underscores, dots, and hyphens for flexible identifier formats.
+ example: user_1234abcd
+ meta:
+ type: object
+ additionalProperties: true
+ maxProperties: 100
+ description: |
+ Stores arbitrary JSON metadata returned during key verification for contextual information.
+ Eliminates additional database lookups during verification, improving performance for stateless services.
+ Avoid storing sensitive data here as it's returned in verification responses.
+ Large metadata objects increase verification latency and should stay under 10KB total size.
+ example:
+ plan: enterprise
+ featureFlags:
+ betaAccess: true
+ concurrentConnections: 10
+ customerName: Acme Corp
+ billing:
+ tier: premium
+ renewal: "2024-12-31"
+ roles:
+ type: array
+ maxItems: 100
+ items:
+ type: string
+ minLength: 1
+ maxLength: 100
+ description: |
+ Assigns existing roles to this key for permission management through role-based access control.
+ Roles must already exist in your workspace before assignment.
+ During verification, all permissions from assigned roles are checked against requested permissions.
+ Roles provide a convenient way to group permissions and apply consistent access patterns across multiple keys.
+ example:
+ - api_admin
+ - billing_reader
+ permissions:
+ type: array
+ maxItems: 1000
+ items:
+ type: string
+ minLength: 1
+ maxLength: 100
+ description: |
+ Grants specific permissions directly to this key without requiring role membership.
+ Wildcard permissions like `documents.*` grant access to all sub-permissions including `documents.read` and `documents.write`.
+ Direct permissions supplement any permissions inherited from assigned roles.
+ example:
+ - documents.read
+ - documents.write
+ - settings.view
+ expires:
+ type: integer
+ format: int64
+ minimum: 0
+ maximum: 4102444800000
+ description: |
+ Sets when this key automatically expires as a Unix timestamp in milliseconds.
+ Verification fails with code=EXPIRED immediately after this time passes.
+ Omitting this field creates a permanent key that never expires.
+
+ Avoid setting timestamps in the past as they immediately invalidate the key.
+ Keys expire based on server time, not client time, which prevents timezone-related issues.
+ Essential for trial periods, temporary access, and security compliance requiring key rotation.
+ enabled:
+ type: boolean
+ default: true
+ description: |
+ Controls whether the key is active immediately upon creation.
+ When set to `false`, the key exists but all verification attempts fail with `code=DISABLED`.
+ Useful for pre-creating keys that will be activated later or for keys requiring manual approval.
+ Most keys should be created with `enabled=true` for immediate use.
+ example: true
+ credits:
+ "$ref": "#/components/schemas/KeyCreditsData"
+ description: |
+ Controls usage-based limits through credit consumption with optional automatic refills.
+ Unlike rate limits which control frequency, credits control total usage with global consistency.
+ Essential for implementing usage-based pricing, subscription tiers, and hard usage quotas.
+ Omitting this field creates unlimited usage, while setting null is not allowed during creation.
+ ratelimits:
+ type: array
+ maxItems: 50
+ items:
+ "$ref": "#/components/schemas/RatelimitRequest"
+ description: |
+ Defines time-based rate limits that protect against abuse by controlling request frequency.
+ Unlike credits which track total usage, rate limits reset automatically after each window expires.
+ Multiple rate limits can control different operation types with separate thresholds and windows.
+ Essential for preventing API abuse while maintaining good performance for legitimate usage.
+ example:
+ - name: requests
+ limit: 100
+ duration: 60000
+ autoApply: true
+ - name: heavy_operations
+ limit: 10
+ duration: 3600000
+ autoApply: false
+ additionalProperties: false
+ required:
+ - hash
+ V2KeysMigrateKeysResponseData:
+ type: object
+ required:
+ - migrated
+ - failed
+ properties:
+ migrated:
+ type: array
+ description: Successfully migrated keys with their hash and generated keyId
+ items:
+ "$ref": "#/components/schemas/V2KeysMigrateKeysMigration"
+ failed:
+ type: array
+ description: Hashes that could not be migrated (e.g., already exist in the system)
+ items:
+ type: string
+ description: The hash that failed to migrate
+ example: sha256_ghi789jkl012
+ V2KeysMigrateKeysMigration:
+ type: object
+ required:
+ - hash
+ - keyId
+ properties:
+ hash:
+ type: string
+ description: The hash provided in the migration request
+ example: sha256_abc123def456
+ keyId:
+ type: string
+ description: The unique identifier for this key in Unkey's system. This is NOT the actual API key, but a reference ID used for management operations like updating or deleting the key. Store this ID in your database to reference the key later. This ID is not sensitive and can be logged or displayed in dashboards.
+ example: key_2cGKbMxRyIzhCxo1Idjz8q
V2KeysRemovePermissionsResponseData:
type: array
description: |-
@@ -4666,6 +4865,65 @@ paths:
tags:
- keys
x-speakeasy-name-override: getKey
+ /v2/keys.migrateKeys:
+ post:
+ description: |
+ Returns HTTP 200 even on partial success; hashes that could not be migrated are listed under `data.failed`.
+
+ **Required Permissions**
+ Your root key must have one of the following permissions for basic key information:
+ - `api.*.create_key` (to migrate keys to any API)
+ - `api..create_key` (to migrate keys to a specific API)
+ operationId: migrateKeys
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/V2KeysMigrateKeysRequestBody'
+ required: true
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/V2KeysMigrateKeysResponseBody'
+ description: Successfully migrated keys.
+ "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: Migrate API key(s)
+ tags:
+ - keys
+ x-speakeasy-name-override: migrateKeys
/v2/keys.removePermissions:
post:
description: |
diff --git a/go/apps/api/openapi/openapi-split.yaml b/go/apps/api/openapi/openapi-split.yaml
index 913ffb00344..376c1445df0 100644
--- a/go/apps/api/openapi/openapi-split.yaml
+++ b/go/apps/api/openapi/openapi-split.yaml
@@ -174,6 +174,8 @@ paths:
$ref: "./spec/paths/v2/keys/whoami/index.yaml"
/v2/keys.verifyKey:
$ref: "./spec/paths/v2/keys/verifyKey/index.yaml"
+ /v2/keys.migrateKeys:
+ $ref: "./spec/paths/v2/keys/migrateKeys/index.yaml"
# Ratelimit Endpoints
/v2/ratelimit.limit:
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
index 09d89085a45..27088810a88 100644
--- a/go/apps/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
+++ b/go/apps/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
@@ -24,7 +24,7 @@ properties:
name:
type: string
minLength: 1
- maxLength: 200 # Human-readable names should be concise but descriptive
+ maxLength: 255
description: |
Sets a human-readable identifier for internal organization and dashboard display.
Never exposed to end users, only visible in management interfaces and API responses.
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeyData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeyData.yaml
new file mode 100644
index 00000000000..af81d9b73b6
--- /dev/null
+++ b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeyData.yaml
@@ -0,0 +1,149 @@
+type: object
+properties:
+ hash:
+ type: string
+ minLength: 3
+ description: The current hash of the key on your side
+ example: your_already_hashed_key
+ name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ description: |
+ Sets a human-readable identifier for internal organization and dashboard display.
+ Never exposed to end users, only visible in management interfaces and API responses.
+ Avoid generic names like "API Key" when managing multiple keys for the same user or service.
+ example: Payment Service Production Key
+ externalId:
+ type: string
+ minLength: 1
+ maxLength: 255
+ description: |
+ Links this key to a user or entity in your system using your own identifier.
+ Returned during verification to identify the key owner without additional database lookups.
+ Essential for user-specific analytics, billing, and multi-tenant key management.
+ Use your primary user ID, organization ID, or tenant ID for best results.
+ Accepts letters, numbers, underscores, dots, and hyphens for flexible identifier formats.
+ example: user_1234abcd
+ meta:
+ type: object
+ additionalProperties: true
+ maxProperties: 100 # Prevent DoS while allowing rich metadata
+ description: |
+ Stores arbitrary JSON metadata returned during key verification for contextual information.
+ Eliminates additional database lookups during verification, improving performance for stateless services.
+ Avoid storing sensitive data here as it's returned in verification responses.
+ Large metadata objects increase verification latency and should stay under 10KB total size.
+ example:
+ plan: enterprise
+ featureFlags:
+ betaAccess: true
+ concurrentConnections: 10
+ customerName: Acme Corp
+ billing:
+ tier: premium
+ renewal: "2024-12-31"
+ roles:
+ type: array
+ maxItems: 100 # Reasonable limit for role assignments per key
+ items:
+ type: string
+ minLength: 1
+ maxLength: 100 # Keep role names concise and readable
+ description: |
+ Assigns existing roles to this key for permission management through role-based access control.
+ Roles must already exist in your workspace before assignment.
+ During verification, all permissions from assigned roles are checked against requested permissions.
+ Roles provide a convenient way to group permissions and apply consistent access patterns across multiple keys.
+ example:
+ - api_admin
+ - billing_reader
+ permissions:
+ type: array
+ maxItems: 1000 # Allow extensive permission sets for complex applications
+ items:
+ type: string
+ minLength: 1
+ maxLength: 100 # Keep permission names concise and readable
+ description: |
+ Grants specific permissions directly to this key without requiring role membership.
+ Wildcard permissions like `documents.*` grant access to all sub-permissions including `documents.read` and `documents.write`.
+ Direct permissions supplement any permissions inherited from assigned roles.
+ example:
+ - documents.read
+ - documents.write
+ - settings.view
+ expires:
+ type: integer
+ format: int64
+ minimum: 0
+ maximum: 4102444800000 # January 1, 2100 - reasonable future limit
+ description: |
+ Sets when this key automatically expires as a Unix timestamp in milliseconds.
+ Verification fails with code=EXPIRED immediately after this time passes.
+ Omitting this field creates a permanent key that never expires.
+
+ Avoid setting timestamps in the past as they immediately invalidate the key.
+ Keys expire based on server time, not client time, which prevents timezone-related issues.
+ Essential for trial periods, temporary access, and security compliance requiring key rotation.
+ enabled:
+ type: boolean
+ default: true
+ description: |
+ Controls whether the key is active immediately upon creation.
+ When set to `false`, the key exists but all verification attempts fail with `code=DISABLED`.
+ Useful for pre-creating keys that will be activated later or for keys requiring manual approval.
+ Most keys should be created with `enabled=true` for immediate use.
+ example: true
+ credits:
+ "$ref": "../../../../common/KeyCreditsData.yaml"
+ description: |
+ Controls usage-based limits through credit consumption with optional automatic refills.
+ Unlike rate limits which control frequency, credits control total usage with global consistency.
+ Essential for implementing usage-based pricing, subscription tiers, and hard usage quotas.
+ Omitting this field creates unlimited usage, while setting null is not allowed during creation.
+ ratelimits:
+ type: array
+ maxItems: 50 # Reasonable limit for rate limit configurations per identity
+ items:
+ "$ref": "../../../../common/RatelimitRequest.yaml"
+ description: |
+ Defines time-based rate limits that protect against abuse by controlling request frequency.
+ Unlike credits which track total usage, rate limits reset automatically after each window expires.
+ Multiple rate limits can control different operation types with separate thresholds and windows.
+ Essential for preventing API abuse while maintaining good performance for legitimate usage.
+ example:
+ - name: requests
+ limit: 100
+ duration: 60000
+ autoApply: true
+ - name: heavy_operations
+ limit: 10
+ duration: 3600000
+ autoApply: false
+additionalProperties: false
+required:
+ - hash
+examples:
+ basicKey:
+ summary: Basic key
+ description: Migrate this basic key
+ value:
+ hash: c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64
+ name: Production API Key
+ externalId: user_abc123
+ enabled: true
+ meta:
+ plan: enterprise
+ tier: production
+ permissions:
+ - api.read
+ - api.write
+ roles:
+ - admin
+ expires: 1735689600000
+ ratelimits:
+ - name: requests
+ limit: 100
+ duration: 60000
+ autoApply: true
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysMigration.yaml b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysMigration.yaml
new file mode 100644
index 00000000000..4d1a0f7be8d
--- /dev/null
+++ b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysMigration.yaml
@@ -0,0 +1,17 @@
+type: object
+required:
+ - hash
+ - keyId
+properties:
+ hash:
+ type: string
+ description: The hash provided in the migration request
+ example: sha256_abc123def456
+ keyId:
+ type: string
+ description: The unique identifier for this key in Unkey's system. This
+ is NOT the actual API key, but a reference ID used for management operations
+ like updating or deleting the key. Store this ID in your database to reference
+ the key later. This ID is not sensitive and can be logged or displayed
+ in dashboards.
+ example: key_2cGKbMxRyIzhCxo1Idjz8q
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysRequestBody.yaml
new file mode 100644
index 00000000000..4ed494f2fb6
--- /dev/null
+++ b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysRequestBody.yaml
@@ -0,0 +1,35 @@
+type: object
+properties:
+ migrationId:
+ type: string
+ minLength: 3
+ maxLength: 255
+ description: Identifier of the configured migration provider/strategy to use (e.g., "your_company"). You will receive this from Unkey's support staff.
+ example: your_company
+ apiId:
+ type: string
+ minLength: 3
+ maxLength: 255
+ description: The ID of the API that the keys should be inserted into
+ example: api_123456789
+ keys:
+ type: array
+ minItems: 1
+ items:
+ $ref: "./V2KeysMigrateKeyData.yaml"
+additionalProperties: false
+required:
+ - migrationId
+ - apiId
+ - keys
+examples:
+ basic:
+ summary: Basic migration example
+ description: A simple migration example with a single key
+ value:
+ migrationId: your_company
+ apiId: api_123456789
+ keys:
+ - hash: c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64
+ name: Production API Key
+ enabled: true
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseBody.yaml
new file mode 100644
index 00000000000..8fa652ef28f
--- /dev/null
+++ b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseBody.yaml
@@ -0,0 +1,9 @@
+type: object
+required:
+ - meta
+ - data
+properties:
+ meta:
+ "$ref": "../../../../common/Meta.yaml"
+ data:
+ "$ref": "./V2KeysMigrateKeysResponseData.yaml"
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseData.yaml
new file mode 100644
index 00000000000..69cc1537dec
--- /dev/null
+++ b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseData.yaml
@@ -0,0 +1,17 @@
+type: object
+required:
+ - migrated
+ - failed
+properties:
+ migrated:
+ type: array
+ description: Successfully migrated keys with their hash and generated keyId
+ items:
+ "$ref": "./V2KeysMigrateKeysMigration.yaml"
+ failed:
+ type: array
+ description: Hashes that could not be migrated (e.g., already exist in the system)
+ items:
+ type: string
+ description: The hash that failed to migrate
+ example: sha256_ghi789jkl012
diff --git a/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/index.yaml
new file mode 100644
index 00000000000..49cca2e7a51
--- /dev/null
+++ b/go/apps/api/openapi/spec/paths/v2/keys/migrateKeys/index.yaml
@@ -0,0 +1,58 @@
+post:
+ tags:
+ - keys
+ summary: Migrate API key(s)
+ description: |
+ Returns HTTP 200 even on partial success; hashes that could not be migrated are listed under `data.failed`.
+
+ **Required Permissions**
+ Your root key must have one of the following permissions for basic key information:
+ - `api.*.create_key` (to migrate keys to any API)
+ - `api..create_key` (to migrate keys to a specific API)
+ operationId: migrateKeys
+ x-speakeasy-name-override: migrateKeys
+ security:
+ - rootKey: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ "$ref": "./V2KeysMigrateKeysRequestBody.yaml"
+ required: true
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ "$ref": "./V2KeysMigrateKeysResponseBody.yaml"
+ description: Successfully migrated keys.
+ "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/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
index e683c6b754f..b7c35790618 100644
--- a/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
+++ b/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
@@ -68,3 +68,8 @@ properties:
Omitting this field skips rate limit checks entirely, relying only on configured key rate limits.
Multiple rate limits can be checked simultaneously, each with different costs and temporary overrides.
Rate limit checks are optimized for performance but may allow brief bursts during high concurrency.
+ migrationId:
+ type: string
+ maxLength: 256
+ description: Migrate keys on demand from your previous system. Reach out for migration support at support@unkey.dev
+ example: "m_1234abcd"
diff --git a/go/apps/api/routes/register.go b/go/apps/api/routes/register.go
index 8fb40c4a43c..430521ad0a6 100644
--- a/go/apps/api/routes/register.go
+++ b/go/apps/api/routes/register.go
@@ -45,6 +45,7 @@ import (
v2KeysCreateKey "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_create_key"
v2KeysDeleteKey "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_delete_key"
v2KeysGetKey "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_get_key"
+ v2KeysMigrateKeys "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_migrate_keys"
v2KeysRemovePermissions "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions"
v2KeysRemoveRoles "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_roles"
v2KeysRerollKey "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_reroll_key"
@@ -420,6 +421,18 @@ func Register(srv *zen.Server, svc *Services, info zen.InstanceInfo) {
},
)
+ // v2/keys.migrateKeys
+ srv.RegisterRoute(
+ defaultMiddlewares,
+ &v2KeysMigrateKeys.Handler{
+ Logger: svc.Logger,
+ ApiCache: svc.Caches.LiveApiByID,
+ DB: svc.Database,
+ Auditlogs: svc.Auditlogs,
+ Keys: svc.Keys,
+ },
+ )
+
// v2/keys.createKey
srv.RegisterRoute(
defaultMiddlewares,
diff --git a/go/apps/api/routes/v2_keys_migrate_keys/200_test.go b/go/apps/api/routes/v2_keys_migrate_keys/200_test.go
new file mode 100644
index 00000000000..4e0e7a9389b
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_migrate_keys/200_test.go
@@ -0,0 +1,231 @@
+package handler_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/oapi-codegen/nullable"
+ "github.com/stretchr/testify/require"
+ "github.com/unkeyed/unkey/go/apps/api/openapi"
+ handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_migrate_keys"
+ "github.com/unkeyed/unkey/go/pkg/db"
+ "github.com/unkeyed/unkey/go/pkg/prefixedapikey"
+ "github.com/unkeyed/unkey/go/pkg/ptr"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+)
+
+func TestMigrateKeysSuccess(t *testing.T) {
+ t.Parallel()
+
+ h := testutil.NewHarness(t)
+ ctx := context.Background()
+
+ route := &handler.Handler{
+ Logger: h.Logger,
+ DB: h.DB,
+ Keys: h.Keys,
+ Auditlogs: h.Auditlogs,
+ ApiCache: h.Caches.LiveApiByID,
+ }
+
+ h.Register(route)
+
+ // Create API using testutil helper
+ api := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ })
+
+ err := db.Query.InsertKeyMigration(ctx, h.DB.RW(), db.InsertKeyMigrationParams{
+ ID: "unkeyed",
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ Algorithm: db.KeyMigrationsAlgorithmGithubcomSeamapiPrefixedApiKey,
+ })
+ require.NoError(t, err)
+
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.create_key")
+
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
+ }
+
+ generatedKey, err := prefixedapikey.GenerateAPIKey(&prefixedapikey.GenerateAPIKeyOptions{
+ KeyPrefix: "unkeyed",
+ })
+ require.NoError(t, err)
+
+ keyToMigrate := openapi.V2KeysMigrateKeyData{
+ Hash: generatedKey.LongTokenHash,
+ Credits: &openapi.KeyCreditsData{
+ Remaining: nullable.Nullable[int64]{},
+ },
+ Enabled: ptr.P(false),
+ Expires: nil,
+ ExternalId: ptr.P("ext_123"),
+ Meta: ptr.P(map[string]interface{}{
+ "key": "value",
+ }),
+ Name: ptr.P("Migration-Key"),
+ Permissions: ptr.P([]string{"test"}),
+ Ratelimits: &[]openapi.RatelimitRequest{
+ {
+ AutoApply: true,
+ Duration: time.Hour.Milliseconds(),
+ Limit: 100,
+ Name: "default",
+ },
+ },
+ Roles: ptr.P([]string{"admin"}),
+ }
+
+ t.Run("basic migration", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: "unkeyed",
+ Keys: []openapi.V2KeysMigrateKeyData{keyToMigrate},
+ }
+
+ res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
+ require.Equal(t, 200, res.Status)
+ require.NotNil(t, res.Body)
+ require.Empty(t, res.Body.Data.Failed)
+ require.NotEmpty(t, res.Body.Data.Migrated)
+ require.Equal(t, res.Body.Data.Migrated[0].Hash, generatedKey.LongTokenHash)
+
+ // Verify key was created in database
+ key, err := db.Query.FindLiveKeyByID(ctx, h.DB.RO(), res.Body.Data.Migrated[0].KeyId)
+ require.NoError(t, err)
+
+ keydata := db.ToKeyData(key)
+
+ require.Equal(t, res.Body.Data.Migrated[0].KeyId, key.ID)
+ require.Equal(t, generatedKey.LongTokenHash, key.Hash)
+ require.Empty(t, keydata.Key.Start)
+ require.False(t, keydata.Key.Enabled)
+ require.NotNil(t, keydata.Identity)
+ require.NotEmpty(t, keydata.Identity.ID)
+ require.NotEmpty(t, keydata.Key.Name.String)
+ require.NotEmpty(t, keydata.Key.Meta.String)
+ require.Len(t, keydata.Permissions, 1)
+ require.Len(t, keydata.Roles, 1)
+ require.Len(t, keydata.RolePermissions, 0)
+ require.Len(t, keydata.Ratelimits, 1)
+ })
+
+ t.Run("Finds the correct ids and doesn't double insert", func(t *testing.T) {
+ // Generate a new key hash for this test
+ otherGeneratedKey, err := prefixedapikey.GenerateAPIKey(&prefixedapikey.GenerateAPIKeyOptions{
+ KeyPrefix: "unkeyed",
+ })
+ require.NoError(t, err)
+
+ // Create a new key with the same identity, permissions, and roles
+ keyToMigrate2 := openapi.V2KeysMigrateKeyData{
+ Hash: otherGeneratedKey.LongTokenHash,
+ Credits: keyToMigrate.Credits,
+ Enabled: keyToMigrate.Enabled,
+ Expires: keyToMigrate.Expires,
+ ExternalId: keyToMigrate.ExternalId, // Same external ID
+ Meta: keyToMigrate.Meta,
+ Name: ptr.P("Migration-Key-2"),
+ Permissions: keyToMigrate.Permissions, // Same permissions
+ Ratelimits: keyToMigrate.Ratelimits,
+ Roles: keyToMigrate.Roles, // Same roles
+ }
+
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: "unkeyed",
+ Keys: []openapi.V2KeysMigrateKeyData{keyToMigrate2},
+ }
+
+ // First, verify the identity, permission, and role exist from the first migration
+ identity, err := db.Query.FindIdentitiesByExternalId(ctx, h.DB.RO(), db.FindIdentitiesByExternalIdParams{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ ExternalIds: []string{"ext_123"},
+ })
+ require.NoError(t, err, "Identity should exist from first migration")
+ require.Len(t, identity, 1, "Identity should exist from first migration")
+
+ permissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ Slugs: []string{"test"},
+ })
+ require.NoError(t, err)
+ require.Len(t, permissions, 1, "Permission should exist from first migration")
+
+ roles, err := db.Query.FindRolesByNames(ctx, h.DB.RO(), db.FindRolesByNamesParams{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ Names: []string{"admin"},
+ })
+ require.NoError(t, err)
+ require.Len(t, roles, 1, "Role should exist from first migration")
+
+ // Perform the second migration
+ res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
+ require.Equal(t, 200, res.Status)
+ require.NotNil(t, res.Body)
+ require.Empty(t, res.Body.Data.Failed)
+ require.NotEmpty(t, res.Body.Data.Migrated)
+ require.Equal(t, res.Body.Data.Migrated[0].Hash, otherGeneratedKey.LongTokenHash)
+
+ // Verify the new key was created with the same identity
+ key, err := db.Query.FindLiveKeyByID(ctx, h.DB.RO(), res.Body.Data.Migrated[0].KeyId)
+ require.NoError(t, err)
+ keydata := db.ToKeyData(key)
+
+ require.NotNil(t, keydata.Identity)
+ require.Equal(t, identity[0].ID, keydata.Identity.ID, "Should reuse existing identity")
+ require.Equal(t, "ext_123", keydata.Identity.ExternalID)
+
+ // Verify no duplicate identities were created
+ identities, err := db.Query.FindIdentitiesByExternalId(ctx, h.DB.RO(), db.FindIdentitiesByExternalIdParams{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ ExternalIds: []string{"ext_123"},
+ Deleted: false,
+ })
+ require.NoError(t, err)
+ require.Len(t, identities, 1, "Should not create duplicate identities")
+
+ // Verify no duplicate permissions were created
+ allPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ Slugs: []string{"test"},
+ })
+ require.NoError(t, err)
+ require.Len(t, allPermissions, 1, "Should not create duplicate permissions")
+
+ // Verify no duplicate roles were created
+ allRoles, err := db.Query.FindRolesByNames(ctx, h.DB.RO(), db.FindRolesByNamesParams{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ Names: []string{"admin"},
+ })
+ require.NoError(t, err)
+ require.Len(t, allRoles, 1, "Should not create duplicate roles")
+
+ // Verify the key has the correct permission and role associations
+ require.Len(t, keydata.Permissions, 1, "Key should have one permission")
+ require.Equal(t, permissions[0].ID, keydata.Permissions[0].ID)
+ require.Len(t, keydata.Roles, 1, "Key should have one role")
+ require.Equal(t, roles[0].ID, keydata.Roles[0].ID)
+ })
+
+ t.Run("Fail duplicate hashes", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: "unkeyed",
+ Keys: []openapi.V2KeysMigrateKeyData{keyToMigrate},
+ }
+
+ res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
+ require.Equal(t, 200, res.Status)
+ require.NotNil(t, res.Body)
+ require.NotEmpty(t, res.Body.Data.Failed)
+ require.Contains(t, res.Body.Data.Failed, keyToMigrate.Hash, "Hash has to be in failed array")
+ require.Empty(t, res.Body.Data.Migrated)
+ })
+}
diff --git a/go/apps/api/routes/v2_keys_migrate_keys/400_test.go b/go/apps/api/routes/v2_keys_migrate_keys/400_test.go
new file mode 100644
index 00000000000..d6e21db54a5
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_migrate_keys/400_test.go
@@ -0,0 +1,188 @@
+package handler_test
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "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_migrate_keys"
+ "github.com/unkeyed/unkey/go/pkg/ptr"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+ "github.com/unkeyed/unkey/go/pkg/uid"
+)
+
+func TestMigrateKeysBadRequest(t *testing.T) {
+ h := testutil.NewHarness(t)
+
+ route := &handler.Handler{
+ DB: h.DB,
+ Keys: h.Keys,
+ Logger: h.Logger,
+ Auditlogs: h.Auditlogs,
+ ApiCache: h.Caches.LiveApiByID,
+ }
+
+ h.Register(route)
+
+ // Create API using testutil helper
+ api := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ })
+
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.create_key")
+
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
+ }
+
+ t.Run("missing everything", func(t *testing.T) {
+ req := handler.Request{
+ // Missing everything
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("empty apiId", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: "",
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("empty migrationId", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: "",
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("no keys", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: uid.New(""),
+ Keys: []openapi.V2KeysMigrateKeyData{},
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("empty hash", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: uid.New(""),
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: "",
+ },
+ },
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("negative expires timestamp", func(t *testing.T) {
+ invalidExpires := int64(-1)
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New("prefix Prefix"),
+ Expires: &invalidExpires,
+ },
+ },
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("empty permission in list", func(t *testing.T) {
+ emptyPermissions := []string{""}
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New("prefix Prefix"),
+ Permissions: &emptyPermissions,
+ },
+ },
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("empty role in list", func(t *testing.T) {
+ emptyRoles := []string{""}
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New("prefix Prefix"),
+ Roles: &emptyRoles,
+ },
+ },
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("permission too long", func(t *testing.T) {
+ // Create a permission string that's longer than 512 characters
+ longPermission := strings.Repeat("a", 513)
+ longPermissions := []string{longPermission}
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New("prefix Prefix"),
+ Permissions: &longPermissions,
+ },
+ },
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("role too long", func(t *testing.T) {
+ // Create a role string that's longer than 512 characters
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New("prefix Prefix"),
+ Roles: ptr.P([]string{strings.Repeat("a", 513)}),
+ },
+ },
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req)
+ require.Equal(t, 400, res.Status)
+ require.NotNil(t, res.Body)
+ })
+}
diff --git a/go/apps/api/routes/v2_keys_migrate_keys/401_test.go b/go/apps/api/routes/v2_keys_migrate_keys/401_test.go
new file mode 100644
index 00000000000..b71a96b8c39
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_migrate_keys/401_test.go
@@ -0,0 +1,87 @@
+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_migrate_keys"
+ "github.com/unkeyed/unkey/go/pkg/db"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+ "github.com/unkeyed/unkey/go/pkg/uid"
+)
+
+func TestMigrateKeysUnauthorized(t *testing.T) {
+ h := testutil.NewHarness(t)
+ ctx := t.Context()
+
+ route := &handler.Handler{
+ DB: h.DB,
+ Keys: h.Keys,
+ Logger: h.Logger,
+ Auditlogs: h.Auditlogs,
+ ApiCache: h.Caches.LiveApiByID,
+ }
+
+ h.Register(route)
+
+ api := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ })
+
+ migrationID := uid.New("")
+ err := db.Query.InsertKeyMigration(ctx, h.DB.RW(), db.InsertKeyMigrationParams{
+ ID: migrationID,
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ Algorithm: db.KeyMigrationsAlgorithmGithubcomSeamapiPrefixedApiKey,
+ })
+ require.NoError(t, err)
+
+ // Basic request body
+ req := handler.Request{
+ ApiId: api.ID,
+ MigrationId: migrationID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New(""),
+ },
+ },
+ }
+
+ t.Run("invalid bearer token", func(t *testing.T) {
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {"Bearer invalid_key_12345"},
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req)
+ require.Equal(t, 401, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("nonexistent key", func(t *testing.T) {
+ nonexistentKey := uid.New(uid.KeyPrefix)
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", nonexistentKey)},
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req)
+ require.Equal(t, 401, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("bearer with extra spaces", func(t *testing.T) {
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {"Bearer invalid_key_with_spaces "},
+ }
+
+ res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req)
+ require.Equal(t, 401, res.Status)
+ require.NotNil(t, res.Body)
+ })
+}
diff --git a/go/apps/api/routes/v2_keys_migrate_keys/403_test.go b/go/apps/api/routes/v2_keys_migrate_keys/403_test.go
new file mode 100644
index 00000000000..c4de8923d08
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_migrate_keys/403_test.go
@@ -0,0 +1,133 @@
+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_migrate_keys"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+ "github.com/unkeyed/unkey/go/pkg/uid"
+)
+
+func TestMigrateKeysForbidden(t *testing.T) {
+ h := testutil.NewHarness(t)
+
+ route := &handler.Handler{
+ DB: h.DB,
+ Keys: h.Keys,
+ Logger: h.Logger,
+ Auditlogs: h.Auditlogs,
+ ApiCache: h.Caches.LiveApiByID,
+ }
+
+ h.Register(route)
+
+ api := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ EncryptedKeys: true,
+ })
+
+ otherAPI := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ EncryptedKeys: true,
+ })
+
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New(""),
+ },
+ },
+ MigrationId: uid.New(""),
+ }
+
+ 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)
+ require.Equal(t, 403, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("wrong permission - has read but not create", func(t *testing.T) {
+ // Create root key with read permission instead of create
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.read_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)
+ require.Equal(t, 403, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("permission for different API", func(t *testing.T) {
+ // Create root key with create permission for other API
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("api.%s.create_key", otherAPI.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)
+ require.Equal(t, 403, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("permission for specific API but requesting different API", func(t *testing.T) {
+ // Create root key with create permission for specific API
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("api.%s.create_key", otherAPI.ID))
+
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
+ }
+
+ // Try to create key for different API
+ res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req)
+ require.Equal(t, 403, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("unrelated permission", func(t *testing.T) {
+ // Create root key with completely unrelated permission
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "workspace.read")
+
+ 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, 403, res.Status)
+ require.NotNil(t, res.Body)
+ })
+
+ t.Run("partial permission match", func(t *testing.T) {
+ // Create root key with permission that partially matches but isn't sufficient
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.create")
+
+ 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, 403, res.Status)
+ require.NotNil(t, res.Body)
+ })
+}
diff --git a/go/apps/api/routes/v2_keys_migrate_keys/404_test.go b/go/apps/api/routes/v2_keys_migrate_keys/404_test.go
new file mode 100644
index 00000000000..14c9f43d2ae
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_migrate_keys/404_test.go
@@ -0,0 +1,182 @@
+package handler_test
+
+import (
+ "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_migrate_keys"
+ "github.com/unkeyed/unkey/go/pkg/db"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+ "github.com/unkeyed/unkey/go/pkg/uid"
+)
+
+func TestMigrateKeysNotFound(t *testing.T) {
+ h := testutil.NewHarness(t)
+ ctx := t.Context()
+
+ route := &handler.Handler{
+ DB: h.DB,
+ Logger: h.Logger,
+ Keys: h.Keys,
+ Auditlogs: h.Auditlogs,
+ ApiCache: h.Caches.LiveApiByID,
+ }
+
+ h.Register(route)
+
+ rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.create_key")
+
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
+ }
+
+ api := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ IpWhitelist: "",
+ EncryptedKeys: false,
+ Name: nil,
+ CreatedAt: nil,
+ DefaultPrefix: nil,
+ DefaultBytes: nil,
+ })
+
+ t.Run("nonexistent api", func(t *testing.T) {
+ // Use a valid API ID format but one that doesn't exist
+ nonexistentApiID := uid.New(uid.APIPrefix)
+ req := handler.Request{
+ ApiId: nonexistentApiID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New(""),
+ },
+ },
+ MigrationId: uid.New(""),
+ }
+
+ 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, "The requested API does not exist or has been deleted.")
+ })
+
+ t.Run("api with valid format but invalid id", func(t *testing.T) {
+ // Create a syntactically valid but non-existent API ID
+ fakeApiID := "api_1234567890abcdef"
+ req := handler.Request{
+ ApiId: fakeApiID,
+ MigrationId: "unkeyed",
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New(""),
+ },
+ },
+ }
+
+ 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, "The requested API does not exist or has been deleted.")
+ })
+
+ t.Run("api from different workspace", func(t *testing.T) {
+ // Create a different workspace to test cross-workspace isolation
+ otherWorkspace := h.CreateWorkspace()
+
+ // Create API in the other workspace
+ otherApi := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: otherWorkspace.ID,
+ IpWhitelist: "",
+ EncryptedKeys: false,
+ Name: nil,
+ CreatedAt: nil,
+ DefaultPrefix: nil,
+ DefaultBytes: nil,
+ })
+
+ req := handler.Request{
+ ApiId: otherApi.ID,
+ MigrationId: "unkeyed",
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New(""),
+ },
+ },
+ }
+
+ // Use credentials from the original workspace to try to access API from otherWorkspace
+ // This tests cross-workspace isolation
+ 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, "The requested API does not exist or has been deleted.")
+ })
+
+ t.Run("api with minimum valid length but nonexistent", func(t *testing.T) {
+ // Test with minimum valid API ID length (3 chars as per validation)
+ minimalApiID := "api"
+ req := handler.Request{
+ ApiId: minimalApiID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: uid.New(""),
+ },
+ },
+ MigrationId: uid.New(""),
+ }
+
+ 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, "The requested API does not exist or has been deleted.")
+ })
+
+ t.Run("deleted api", func(t *testing.T) {
+ deletedApi := h.CreateApi(seed.CreateApiRequest{
+ WorkspaceID: h.Resources().UserWorkspace.ID,
+ IpWhitelist: "",
+ EncryptedKeys: false,
+ Name: nil,
+ CreatedAt: nil,
+ DefaultPrefix: nil,
+ DefaultBytes: nil,
+ })
+
+ db.Query.SoftDeleteApi(ctx, h.DB.RW(), db.SoftDeleteApiParams{
+ Now: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ ApiID: deletedApi.ID,
+ })
+
+ req := handler.Request{
+ ApiId: deletedApi.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{{
+ Hash: uid.New(""),
+ }},
+ MigrationId: uid.New(""),
+ }
+
+ 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, "The requested API does not exist or has been deleted.")
+ })
+
+ t.Run("migration doesn't exist", func(t *testing.T) {
+ req := handler.Request{
+ ApiId: api.ID,
+ Keys: []openapi.V2KeysMigrateKeyData{{Hash: uid.New("")}},
+ MigrationId: uid.New("some_migration_id"),
+ }
+
+ 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, "The requested Migration does not exist or has been deleted.")
+ })
+}
diff --git a/go/apps/api/routes/v2_keys_migrate_keys/handler.go b/go/apps/api/routes/v2_keys_migrate_keys/handler.go
new file mode 100644
index 00000000000..974d462a8b2
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_migrate_keys/handler.go
@@ -0,0 +1,659 @@
+package handler
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/unkeyed/unkey/go/apps/api/openapi"
+ "github.com/unkeyed/unkey/go/internal/services/auditlogs"
+ "github.com/unkeyed/unkey/go/internal/services/caches"
+ "github.com/unkeyed/unkey/go/internal/services/keys"
+
+ "github.com/unkeyed/unkey/go/pkg/auditlog"
+ "github.com/unkeyed/unkey/go/pkg/cache"
+ "github.com/unkeyed/unkey/go/pkg/codes"
+ "github.com/unkeyed/unkey/go/pkg/db"
+ dbtype "github.com/unkeyed/unkey/go/pkg/db/types"
+ "github.com/unkeyed/unkey/go/pkg/fault"
+ "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/uid"
+ "github.com/unkeyed/unkey/go/pkg/zen"
+)
+
+type (
+ Request = openapi.V2KeysMigrateKeysRequestBody
+ Response = openapi.V2KeysMigrateKeysResponseBody
+)
+
+const (
+ ChunkSize = 1_000
+)
+
+type Handler struct {
+ Logger logging.Logger
+ DB db.Database
+ Keys keys.KeyService
+ Auditlogs auditlogs.AuditLogService
+ ApiCache cache.Cache[cache.ScopedKey, db.FindLiveApiByIDRow]
+}
+
+// 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.migrateKeys"
+}
+
+// Handle processes the HTTP request
+func (h *Handler) Handle(ctx context.Context, s *zen.Session) error {
+ h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.migrateKeys")
+
+ auth, emit, err := h.Keys.GetRootKey(ctx, s)
+ defer emit()
+ if err != nil {
+ return err
+ }
+
+ req, err := zen.BindBody[Request](s)
+ if err != nil {
+ return err
+ }
+
+ err = auth.VerifyRootKey(ctx, keys.WithPermissions(rbac.Or(
+ rbac.T(rbac.Tuple{
+ ResourceType: rbac.Api,
+ ResourceID: req.ApiId,
+ Action: rbac.CreateKey,
+ }),
+ rbac.T(rbac.Tuple{
+ ResourceType: rbac.Api,
+ ResourceID: "*",
+ Action: rbac.CreateKey,
+ }),
+ )))
+ if err != nil {
+ return err
+ }
+
+ api, hit, err := h.ApiCache.SWR(ctx, cache.ScopedKey{WorkspaceID: auth.AuthorizedWorkspaceID, Key: req.ApiId}, func(ctx context.Context) (db.FindLiveApiByIDRow, error) {
+ return db.Query.FindLiveApiByID(ctx, h.DB.RO(), req.ApiId)
+ }, caches.DefaultFindFirstOp)
+ if err != nil {
+ if db.IsNotFound(err) {
+ return fault.Wrap(
+ err,
+ fault.Code(codes.Data.Api.NotFound.URN()),
+ fault.Internal("api does not exist"),
+ fault.Public("The requested API does not exist or has been deleted."),
+ )
+ }
+
+ return fault.Wrap(err,
+ fault.Code(codes.App.Internal.ServiceUnavailable.URN()),
+ fault.Internal("database error"),
+ fault.Public("Failed to retrieve API information."),
+ )
+ }
+
+ if hit == cache.Null {
+ return fault.New("api not found",
+ fault.Code(codes.Data.Api.NotFound.URN()),
+ fault.Internal("api not found"),
+ fault.Public("The requested API does not exist or has been deleted."),
+ )
+ }
+
+ // Check if API belongs to the authorized workspace
+ if api.WorkspaceID != auth.AuthorizedWorkspaceID {
+ return fault.New("wrong workspace",
+ fault.Code(codes.Data.Api.NotFound.URN()),
+ fault.Internal("wrong workspace, masking as 404"),
+ fault.Public("The requested API does not exist or has been deleted."),
+ )
+ }
+
+ migration, err := db.Query.FindKeyMigrationByID(ctx, h.DB.RO(), db.FindKeyMigrationByIDParams{ID: req.MigrationId, WorkspaceID: auth.AuthorizedWorkspaceID})
+ if err != nil {
+ if db.IsNotFound(err) {
+ return fault.Wrap(
+ err,
+ fault.Code(codes.Data.Migration.NotFound.URN()),
+ fault.Internal("migration does not exist"),
+ fault.Public("The requested Migration does not exist or has been deleted."),
+ )
+ }
+
+ return fault.Wrap(err,
+ fault.Code(codes.App.Internal.ServiceUnavailable.URN()),
+ fault.Internal("database error"),
+ fault.Public("Failed to retrieve migration information."),
+ )
+ }
+
+ now := time.Now().UnixMilli()
+
+ var hashes []string
+ var identitiesToFind []string
+ var permissionsToFind []string
+ var rolesToFind []string
+
+ var keysArray []db.InsertKeyParams
+ var ratelimitsToInsert []db.InsertKeyRatelimitParams
+ var identitiesToInsert []db.InsertIdentityParams
+ var keyRolesToInsert []db.InsertKeyRoleParams
+ var keyPermissionsToInsert []db.InsertKeyPermissionParams
+ var rolesToInsert []db.InsertRoleParams
+ var permissionsToInsert []db.InsertPermissionParams
+
+ var keysToInsert = make(map[string]db.InsertKeyParams)
+ var externalIdToIdentityId = make(map[string]*string)
+ var permissionSlugToPermissionId = make(map[string]*string)
+ var roleNameToRoleId = make(map[string]*string)
+
+ var auditLogs []auditlog.AuditLog
+ var failedHashes = make([]string, 0)
+
+ for _, key := range req.Keys {
+ hashes = append(hashes, key.Hash)
+ name := ptr.SafeDeref(key.Name)
+
+ newKey := db.InsertKeyParams{
+ ID: uid.New(uid.KeyPrefix),
+ Hash: key.Hash,
+ KeySpaceID: api.KeyAuth.ID,
+ Start: "", // Unknown at this point
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Name: sql.NullString{Valid: name != "", String: name},
+ Meta: sql.NullString{Valid: false, String: ""},
+ PendingMigrationID: sql.NullString{Valid: true, String: migration.ID},
+ ForWorkspaceID: sql.NullString{Valid: false, String: ""},
+ IdentityID: sql.NullString{Valid: false, String: ""},
+ Expires: sql.NullTime{Valid: false, Time: time.Time{}},
+ CreatedAtM: now,
+ Enabled: ptr.SafeDeref(key.Enabled, true),
+ RemainingRequests: sql.NullInt32{Valid: false, Int32: 0},
+ RefillDay: sql.NullInt16{Valid: false, Int16: 0},
+ RefillAmount: sql.NullInt32{Valid: false, Int32: 0},
+ } // nolint:exhaustruct
+
+ if key.Meta != nil {
+ metaBytes, marshalErr := json.Marshal(*key.Meta)
+ if marshalErr != nil {
+ return fault.Wrap(marshalErr,
+ fault.Code(codes.App.Validation.InvalidInput.URN()),
+ fault.Internal("failed to marshal meta"), fault.Public("Invalid metadata format."),
+ )
+ }
+
+ newKey.Meta = sql.NullString{String: string(metaBytes), Valid: true}
+ }
+
+ if key.Expires != nil {
+ newKey.Expires = sql.NullTime{Time: time.UnixMilli(*key.Expires), Valid: true}
+ }
+
+ if key.Credits != nil {
+ if key.Credits.Remaining.IsSpecified() {
+ newKey.RemainingRequests = sql.NullInt32{
+ Int32: int32(key.Credits.Remaining.MustGet()), // nolint:gosec
+ Valid: true,
+ }
+ }
+
+ if key.Credits.Refill != nil {
+ newKey.RefillAmount = sql.NullInt32{
+ Int32: int32(key.Credits.Refill.Amount), // nolint:gosec
+ Valid: true,
+ }
+
+ if key.Credits.Refill.Interval == openapi.KeyCreditsRefillIntervalMonthly {
+ if key.Credits.Refill.RefillDay == 0 {
+ return fault.New("missing refillDay",
+ fault.Code(codes.App.Validation.InvalidInput.URN()),
+ fault.Internal("refillDay required for monthly interval"),
+ fault.Public("`refillDay` must be provided when the refill interval is `monthly`."),
+ )
+ }
+
+ newKey.RefillDay = sql.NullInt16{
+ Int16: int16(key.Credits.Refill.RefillDay), // nolint:gosec
+ Valid: true,
+ }
+ }
+ }
+ }
+
+ if key.ExternalId != nil {
+ identitiesToFind = append(identitiesToFind, *key.ExternalId)
+
+ externalIdToIdentityId[*key.ExternalId] = nil
+ }
+
+ if key.Permissions != nil {
+ permissionsToFind = append(permissionsToFind, *key.Permissions...)
+
+ for _, permission := range *key.Permissions {
+ permissionSlugToPermissionId[permission] = nil
+ }
+ }
+
+ if key.Roles != nil {
+ rolesToFind = append(rolesToFind, *key.Roles...)
+
+ for _, role := range *key.Roles {
+ roleNameToRoleId[role] = nil
+ }
+ }
+
+ // Any other data of the key will be set later down the line.
+ keysToInsert[key.Hash] = newKey
+ }
+
+ hashes = deduplicate(hashes)
+ identitiesToFind = deduplicate(identitiesToFind)
+ permissionsToFind = deduplicate(permissionsToFind)
+ rolesToFind = deduplicate(rolesToFind)
+
+ err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error {
+ usedHashes, err := db.Query.FindKeysByHash(ctx, tx, hashes)
+ if err != nil && !db.IsNotFound(err) {
+ return fault.Wrap(err,
+ fault.Code(codes.App.Internal.ServiceUnavailable.URN()),
+ fault.Internal("database error"),
+ fault.Public("Failed to check for duplicate keys."),
+ )
+ }
+
+ // Respond with that in the response, so the customer knows which keys were already in use.
+ // and can contact us maybe we can get rid of them.
+ for _, hash := range usedHashes {
+ delete(keysToInsert, hash.Hash)
+ failedHashes = append(failedHashes, hash.Hash)
+ }
+
+ if len(identitiesToFind) > 0 {
+ identities, err := db.Query.FindIdentitiesByExternalId(ctx, tx, db.FindIdentitiesByExternalIdParams{
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ ExternalIds: identitiesToFind,
+ Deleted: false,
+ })
+ if err != nil && !db.IsNotFound(err) {
+ return fault.Wrap(err,
+ fault.Code(codes.App.Internal.ServiceUnavailable.URN()),
+ fault.Internal("database error"),
+ fault.Public("Failed to check for duplicate identities."),
+ )
+ }
+
+ for _, identity := range identities {
+ externalIdToIdentityId[identity.ExternalID] = &identity.ID
+ }
+ }
+
+ if len(permissionsToFind) > 0 {
+ permissions, err := db.Query.FindPermissionsBySlugs(ctx, tx, db.FindPermissionsBySlugsParams{
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Slugs: permissionsToFind,
+ })
+ if err != nil && !db.IsNotFound(err) {
+ return fault.Wrap(err,
+ fault.Code(codes.App.Internal.ServiceUnavailable.URN()),
+ fault.Internal("database error"),
+ fault.Public("Failed to check for duplicate permissions."),
+ )
+ }
+
+ for _, permission := range permissions {
+ permissionSlugToPermissionId[permission.Slug] = &permission.ID
+ }
+ }
+
+ if len(rolesToFind) > 0 {
+ roles, err := db.Query.FindRolesByNames(ctx, tx, db.FindRolesByNamesParams{
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Names: rolesToFind,
+ })
+ if err != nil && !db.IsNotFound(err) {
+ return fault.Wrap(err,
+ fault.Code(codes.App.Internal.ServiceUnavailable.URN()),
+ fault.Internal("database error"),
+ fault.Public("Failed to check for duplicate roles."),
+ )
+ }
+
+ for _, role := range roles {
+ roleNameToRoleId[role.Name] = &role.ID
+ }
+ }
+
+ // We found the stuff that we could find, now we can just upsert everything that doesn't exist.
+ for externalId, identityId := range externalIdToIdentityId {
+ if identityId != nil {
+ continue
+ }
+
+ id := uid.New(uid.IdentityPrefix)
+ identitiesToInsert = append(identitiesToInsert, db.InsertIdentityParams{
+ ID: id,
+ ExternalID: externalId,
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Environment: "default",
+ CreatedAt: now,
+ Meta: []byte("{}"),
+ })
+
+ externalIdToIdentityId[externalId] = &id
+ }
+
+ for slug, permissionId := range permissionSlugToPermissionId {
+ if permissionId != nil {
+ continue
+ }
+
+ id := uid.New(uid.PermissionPrefix)
+ permissionsToInsert = append(permissionsToInsert, db.InsertPermissionParams{
+ PermissionID: id,
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Name: slug,
+ Slug: slug,
+ Description: dbtype.NullString{Valid: false, String: ""},
+ CreatedAtM: now,
+ })
+
+ permissionSlugToPermissionId[slug] = &id
+ }
+
+ for name, roleId := range roleNameToRoleId {
+ if roleId != nil {
+ continue
+ }
+
+ id := uid.New(uid.RolePrefix)
+ rolesToInsert = append(rolesToInsert, db.InsertRoleParams{
+ RoleID: id,
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Name: name,
+ Description: sql.NullString{Valid: false, String: ""},
+ CreatedAt: now,
+ })
+
+ roleNameToRoleId[name] = &id
+ }
+
+ // Now the fun begins.
+ for _, key := range req.Keys {
+ keyParams, ok := keysToInsert[key.Hash]
+ if !ok {
+ continue
+ }
+
+ if key.Ratelimits != nil {
+ for _, ratelimit := range *key.Ratelimits {
+ ratelimitsToInsert = append(ratelimitsToInsert, db.InsertKeyRatelimitParams{
+ ID: uid.New(uid.RatelimitPrefix),
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ KeyID: sql.NullString{String: keyParams.ID, Valid: true},
+ Name: ratelimit.Name,
+ Limit: int32(ratelimit.Limit), // nolint:gosec
+ Duration: ratelimit.Duration,
+ CreatedAt: now,
+ AutoApply: ratelimit.AutoApply,
+ })
+ }
+ }
+
+ if key.ExternalId != nil {
+ identityID, ok := externalIdToIdentityId[*key.ExternalId]
+ if ok {
+ keyParams.IdentityID = sql.NullString{Valid: true, String: *identityID}
+ }
+ }
+
+ if key.Permissions != nil {
+ for _, permission := range *key.Permissions {
+ permissionID, ok := permissionSlugToPermissionId[permission]
+ if !ok {
+ continue
+ }
+
+ keyPermissionsToInsert = append(keyPermissionsToInsert, db.InsertKeyPermissionParams{
+ KeyID: keyParams.ID,
+ PermissionID: *permissionID,
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ CreatedAt: now,
+ })
+
+ auditLogs = append(auditLogs, auditlog.AuditLog{
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Event: auditlog.AuthConnectPermissionKeyEvent,
+ ActorType: auditlog.RootKeyActor,
+ ActorID: auth.Key.ID,
+ ActorName: "root key",
+ ActorMeta: map[string]any{},
+ Display: fmt.Sprintf("Added permission %s to key %s", permission, keyParams.ID),
+ RemoteIP: s.Location(),
+ UserAgent: s.UserAgent(),
+ Resources: []auditlog.AuditLogResource{
+ {
+ Type: auditlog.KeyResourceType,
+ ID: keyParams.ID,
+ Name: keyParams.Name.String,
+ DisplayName: keyParams.Name.String,
+ Meta: map[string]any{},
+ },
+ {
+ Type: auditlog.PermissionResourceType,
+ ID: *permissionID,
+ Name: permission,
+ DisplayName: permission,
+ Meta: map[string]any{},
+ },
+ },
+ })
+ }
+ }
+
+ if key.Roles != nil {
+ for _, role := range *key.Roles {
+ roleID, ok := roleNameToRoleId[role]
+ if !ok {
+ continue
+ }
+
+ keyRolesToInsert = append(keyRolesToInsert, db.InsertKeyRoleParams{
+ KeyID: keyParams.ID,
+ RoleID: *roleID,
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ CreatedAtM: now,
+ })
+
+ auditLogs = append(auditLogs, auditlog.AuditLog{
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Event: auditlog.AuthConnectRoleKeyEvent,
+ ActorType: auditlog.RootKeyActor,
+ ActorID: auth.Key.ID,
+ ActorName: "root key",
+ ActorMeta: map[string]any{},
+ Display: fmt.Sprintf("Connected role %s to key %s", role, keyParams.ID),
+ RemoteIP: s.Location(),
+ UserAgent: s.UserAgent(),
+ Resources: []auditlog.AuditLogResource{
+ {
+ Type: auditlog.KeyResourceType,
+ ID: keyParams.ID,
+ Name: keyParams.Name.String,
+ DisplayName: keyParams.Name.String,
+ Meta: map[string]any{},
+ },
+ {
+ Type: auditlog.RoleResourceType,
+ ID: *roleID,
+ DisplayName: role,
+ Name: role,
+ Meta: map[string]any{},
+ },
+ },
+ })
+ }
+ }
+
+ auditLogs = append(auditLogs, auditlog.AuditLog{
+ WorkspaceID: auth.AuthorizedWorkspaceID,
+ Event: auditlog.KeyCreateEvent,
+ ActorType: auditlog.RootKeyActor,
+ ActorID: auth.Key.ID,
+ ActorName: "root key",
+ ActorMeta: map[string]any{},
+ Display: fmt.Sprintf("Created key %s in migration %s", keyParams.ID, migration.ID),
+ RemoteIP: s.Location(),
+ UserAgent: s.UserAgent(),
+ Resources: []auditlog.AuditLogResource{
+ {
+ Type: auditlog.KeyResourceType,
+ ID: keyParams.ID,
+ DisplayName: keyParams.Name.String,
+ Name: keyParams.Name.String,
+ Meta: map[string]any{},
+ },
+ {
+ Type: auditlog.APIResourceType,
+ ID: req.ApiId,
+ DisplayName: api.Name,
+ Name: api.Name,
+ Meta: map[string]any{},
+ },
+ },
+ })
+
+ keysArray = append(keysArray, keyParams)
+ }
+
+ if len(permissionsToInsert) > 0 {
+ chunks := chunk(permissionsToInsert, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertPermissions(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ if len(identitiesToInsert) > 0 {
+ chunks := chunk(identitiesToInsert, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertIdentities(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ if len(rolesToInsert) > 0 {
+ chunks := chunk(rolesToInsert, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertRoles(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ if len(keysArray) > 0 {
+ chunks := chunk(keysArray, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertKeys(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ if len(keyRolesToInsert) > 0 {
+ chunks := chunk(keyRolesToInsert, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertKeyRoles(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ if len(keyPermissionsToInsert) > 0 {
+ chunks := chunk(keyPermissionsToInsert, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertKeyPermissions(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ if len(ratelimitsToInsert) > 0 {
+ chunks := chunk(ratelimitsToInsert, ChunkSize)
+ for _, chunk := range chunks {
+ if err := db.BulkQuery.InsertKeyRatelimits(ctx, tx, chunk); err != nil {
+ return err
+ }
+ }
+ }
+
+ err = h.Auditlogs.Insert(ctx, tx, auditLogs)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ // Build the response with migrated keys and failed hashes
+ migratedKeys := []openapi.V2KeysMigrateKeysMigration{}
+ for _, key := range keysArray {
+ migratedKeys = append(migratedKeys, openapi.V2KeysMigrateKeysMigration{
+ Hash: key.Hash,
+ KeyId: key.ID,
+ })
+ }
+
+ return s.JSON(http.StatusOK, Response{
+ Meta: openapi.Meta{
+ RequestId: s.RequestID(),
+ },
+ Data: openapi.V2KeysMigrateKeysResponseData{
+ Migrated: migratedKeys,
+ Failed: failedHashes,
+ },
+ })
+}
+
+func deduplicate[T comparable](items []T) []T {
+ seen := make(map[T]bool)
+ result := []T{}
+
+ for _, item := range items {
+ if !seen[item] {
+ seen[item] = true
+ result = append(result, item)
+ }
+ }
+
+ return result
+}
+
+func chunk[T any](items []T, size int) [][]T {
+ var chunks [][]T
+ for i := 0; i < len(items); i += size {
+ end := i + size
+ if end > len(items) {
+ end = len(items)
+ }
+ chunks = append(chunks, items[i:end])
+ }
+
+ return chunks
+}
diff --git a/go/apps/api/routes/v2_keys_update_credits/200_test.go b/go/apps/api/routes/v2_keys_update_credits/200_test.go
index 7acfd62f933..e296efb9b5c 100644
--- a/go/apps/api/routes/v2_keys_update_credits/200_test.go
+++ b/go/apps/api/routes/v2_keys_update_credits/200_test.go
@@ -13,6 +13,7 @@ import (
handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_update_credits"
"github.com/unkeyed/unkey/go/internal/services/keys"
"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/testutil/seed"
"github.com/unkeyed/unkey/go/pkg/zen"
@@ -178,7 +179,7 @@ func TestKeyUpdateCreditsSuccess(t *testing.T) {
Remaining: &initialCredits,
})
- authBefore, _, err := h.Keys.Get(ctx, &zen.Session{}, cacheTestKey.Key)
+ authBefore, _, err := h.Keys.Get(ctx, &zen.Session{}, hash.Sha256(cacheTestKey.Key))
require.NoError(t, err)
err = authBefore.Verify(ctx, keys.WithCredits(1))
@@ -205,7 +206,7 @@ func TestKeyUpdateCreditsSuccess(t *testing.T) {
require.Equal(t, newCredits, updatedRemaining)
// Verify the key again to check if cache was properly invalidated
- authAfter, _, err := h.Keys.Get(ctx, &zen.Session{}, cacheTestKey.Key)
+ authAfter, _, err := h.Keys.Get(ctx, &zen.Session{}, hash.Sha256(cacheTestKey.Key))
require.NoError(t, err)
err = authAfter.Verify(ctx, keys.WithCredits(1))
diff --git a/go/apps/api/routes/v2_keys_update_key/200_test.go b/go/apps/api/routes/v2_keys_update_key/200_test.go
index 6c00af99fee..0471071923b 100644
--- a/go/apps/api/routes/v2_keys_update_key/200_test.go
+++ b/go/apps/api/routes/v2_keys_update_key/200_test.go
@@ -14,6 +14,7 @@ import (
handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_update_key"
"github.com/unkeyed/unkey/go/internal/services/keys"
"github.com/unkeyed/unkey/go/pkg/db"
+ "github.com/unkeyed/unkey/go/pkg/hash"
"github.com/unkeyed/unkey/go/pkg/ptr"
"github.com/unkeyed/unkey/go/pkg/testutil"
"github.com/unkeyed/unkey/go/pkg/testutil/seed"
@@ -244,7 +245,7 @@ func TestKeyUpdateCreditsInvalidatesCache(t *testing.T) {
"Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
}
- authBefore, _, err := h.Keys.Get(ctx, &zen.Session{}, key.Key)
+ authBefore, _, err := h.Keys.Get(ctx, &zen.Session{}, hash.Sha256(key.Key))
require.NoError(t, err)
err = authBefore.Verify(ctx, keys.WithCredits(1))
@@ -268,7 +269,7 @@ func TestKeyUpdateCreditsInvalidatesCache(t *testing.T) {
require.Equal(t, 200, res.Status)
// Verify the key again to check if cache was properly invalidated
- authAfter, _, err := h.Keys.Get(ctx, &zen.Session{}, key.Key)
+ authAfter, _, err := h.Keys.Get(ctx, &zen.Session{}, hash.Sha256(key.Key))
require.NoError(t, err)
err = authAfter.Verify(ctx, keys.WithCredits(1))
diff --git a/go/apps/api/routes/v2_keys_verify_key/handler.go b/go/apps/api/routes/v2_keys_verify_key/handler.go
index d4b8092e789..9628fa0f643 100644
--- a/go/apps/api/routes/v2_keys_verify_key/handler.go
+++ b/go/apps/api/routes/v2_keys_verify_key/handler.go
@@ -14,6 +14,7 @@ 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"
@@ -62,11 +63,18 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error {
return err
}
- key, emit, err := h.Keys.Get(ctx, s, req.Key)
+ key, emit, err := h.Keys.Get(ctx, s, hash.Sha256(req.Key))
if err != nil {
return err
}
+ if key.Status == keys.StatusNotFound && req.MigrationId != nil {
+ key, emit, err = h.Keys.GetMigrated(ctx, s, req.Key, ptr.SafeDeref(req.MigrationId))
+ if err != nil {
+ return err
+ }
+ }
+
// Validate key belongs to authorized workspace
if key.Key.WorkspaceID != auth.AuthorizedWorkspaceID {
return s.JSON(http.StatusOK, Response{
diff --git a/go/apps/api/routes/v2_keys_verify_key/migration_test.go b/go/apps/api/routes/v2_keys_verify_key/migration_test.go
new file mode 100644
index 00000000000..4c61e94cbb3
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_verify_key/migration_test.go
@@ -0,0 +1,125 @@
+package handler_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/unkeyed/unkey/go/apps/api/openapi"
+ migrateHandler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_migrate_keys"
+ handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_verify_key"
+ "github.com/unkeyed/unkey/go/pkg/db"
+ "github.com/unkeyed/unkey/go/pkg/hash"
+ "github.com/unkeyed/unkey/go/pkg/prefixedapikey"
+ "github.com/unkeyed/unkey/go/pkg/ptr"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+ "github.com/unkeyed/unkey/go/pkg/uid"
+)
+
+func TestKeyVerificationWithMigration(t *testing.T) {
+ ctx := context.Background()
+ h := testutil.NewHarness(t)
+
+ migrateRoute := &migrateHandler.Handler{
+ Logger: h.Logger,
+ DB: h.DB,
+ Keys: h.Keys,
+ Auditlogs: h.Auditlogs,
+ ApiCache: h.Caches.LiveApiByID,
+ }
+
+ verifyRoute := &handler.Handler{
+ DB: h.DB,
+ Keys: h.Keys,
+ Logger: h.Logger,
+ Auditlogs: h.Auditlogs,
+ ClickHouse: h.ClickHouse,
+ }
+
+ h.Register(verifyRoute)
+ h.Register(migrateRoute)
+
+ // Create a workspace
+ workspace := h.Resources().UserWorkspace
+
+ // Create a root key with appropriate permissions
+ rootKey := h.CreateRootKey(workspace.ID, "api.*.verify_key", "api.*.create_key")
+
+ api := h.CreateApi(seed.CreateApiRequest{WorkspaceID: workspace.ID})
+
+ // Set up request headers
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
+ }
+
+ t.Run("verifies key with migration ID", func(t *testing.T) {
+ // Create a migration
+ migrationID := uid.New("migration")
+
+ // Insert migration directly to database
+ err := db.Query.InsertKeyMigration(ctx, h.DB.RW(), db.InsertKeyMigrationParams{
+ ID: migrationID,
+ WorkspaceID: workspace.ID,
+ Algorithm: db.KeyMigrationsAlgorithmGithubcomSeamapiPrefixedApiKey,
+ })
+ require.NoError(t, err, "Failed to insert migration")
+
+ resendKey, err := prefixedapikey.GenerateAPIKey(&prefixedapikey.GenerateAPIKeyOptions{
+ KeyPrefix: "re",
+ })
+ require.NoError(t, err)
+
+ migrateReq := migrateHandler.Request{
+ ApiId: api.ID,
+ MigrationId: migrationID,
+ Keys: []openapi.V2KeysMigrateKeyData{
+ {
+ Hash: resendKey.LongTokenHash,
+ Enabled: ptr.P(true),
+ },
+ },
+ }
+
+ migrateRes := testutil.CallRoute[migrateHandler.Request, migrateHandler.Response](h, migrateRoute, headers, migrateReq)
+ require.Equal(t, 200, migrateRes.Status, "expected 200, received: %#v", migrateRes)
+ require.Len(t, migrateRes.Body.Data.Failed, 0, "No keys should fail migration")
+ require.Len(t, migrateRes.Body.Data.Migrated, 1, "One key should be migrated")
+ keyID := migrateRes.Body.Data.Migrated[0].KeyId
+
+ req := handler.Request{
+ Key: resendKey.Token,
+ MigrationId: ptr.P(migrationID),
+ }
+
+ res1 := testutil.CallRoute[handler.Request, handler.Response](h, verifyRoute, headers, req)
+
+ require.Equal(t, 200, res1.Status, "expected 200, received: %#v", res1)
+ require.NotNil(t, res1.Body)
+ require.Equal(t, openapi.VALID, res1.Body.Data.Code, "Key should be valid but got %s", res1.Body.Data.Code)
+ require.True(t, res1.Body.Data.Valid, "Key should be valid but got %t", res1.Body.Data.Valid)
+
+ // Now we should be able to verify the key without the migration ID
+ req = handler.Request{
+ Key: resendKey.Token,
+ }
+
+ res2 := testutil.CallRoute[handler.Request, handler.Response](h, verifyRoute, headers, req)
+ require.Equal(t, 200, res2.Status, "expected 200, received: %#v", res2)
+ require.NotNil(t, res2.Body)
+ require.Equal(t, openapi.VALID, res2.Body.Data.Code, "Key should be valid but got %s", res2.Body.Data.Code)
+ require.True(t, res2.Body.Data.Valid, "Key should be valid but got %t", res2.Body.Data.Valid)
+
+ // The migration ID should be removed from the key and the hash updated
+ key, err := db.Query.FindKeyByID(ctx, h.DB.RW(), keyID)
+ require.NoError(t, err)
+ require.False(t, key.PendingMigrationID.Valid)
+ require.Empty(t, key.PendingMigrationID.String)
+ require.NotEqual(t, resendKey.LongTokenHash, key.Hash, "Hash should be different after migration")
+ require.Equal(t, hash.Sha256(resendKey.Token), key.Hash)
+ require.Equal(t, resendKey.Token[:7], key.Start, "start should match first 6 chars of raw key after migration")
+ })
+}
diff --git a/go/apps/api/routes/v2_keys_verify_key/resend_demo_test.go b/go/apps/api/routes/v2_keys_verify_key/resend_demo_test.go
new file mode 100644
index 00000000000..d720b63d40b
--- /dev/null
+++ b/go/apps/api/routes/v2_keys_verify_key/resend_demo_test.go
@@ -0,0 +1,137 @@
+package handler_test
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_verify_key"
+ "github.com/unkeyed/unkey/go/pkg/db"
+ "github.com/unkeyed/unkey/go/pkg/prefixedapikey"
+ "github.com/unkeyed/unkey/go/pkg/ptr"
+ "github.com/unkeyed/unkey/go/pkg/testutil"
+ "github.com/unkeyed/unkey/go/pkg/testutil/seed"
+ "github.com/unkeyed/unkey/go/pkg/uid"
+)
+
+func TestResendDemo(t *testing.T) {
+ ctx := context.Background()
+ h := testutil.NewHarness(t)
+
+ route := &handler.Handler{
+ DB: h.DB,
+ Keys: h.Keys,
+ Logger: h.Logger,
+ Auditlogs: h.Auditlogs,
+ ClickHouse: h.ClickHouse,
+ }
+
+ h.Register(route)
+
+ // Create a workspace
+ workspace := h.Resources().UserWorkspace
+
+ // Create a root key with appropriate permissions
+ rootKey := h.CreateRootKey(workspace.ID, "api.*.verify_key")
+
+ api := h.CreateApi(seed.CreateApiRequest{WorkspaceID: workspace.ID})
+
+ // Set up request headers
+ headers := http.Header{
+ "Content-Type": {"application/json"},
+ "Authorization": {fmt.Sprintf("Bearer %s", rootKey)},
+ }
+
+ t.Run("verifies key with migration ID", func(t *testing.T) {
+
+ // 1. Create a migration
+ // This will be done by us, no need to think about it.
+
+ // Insert migration directly to database
+ err := db.Query.InsertKeyMigration(ctx, h.DB.RW(), db.InsertKeyMigrationParams{
+ ID: "resend",
+ WorkspaceID: workspace.ID,
+ Algorithm: db.KeyMigrationsAlgorithmGithubcomSeamapiPrefixedApiKey,
+ })
+ require.NoError(t, err, "Failed to insert migration")
+
+ // 2. Get an existing key.
+ //
+ // In the future you'd use unkey to issue new keys, but for your existing ones,
+ // we'll create one using your library.
+ //
+ //
+ // ```js
+ // import { generateAPIKey } from "prefixed-api-key"
+ //
+ // const key = await generateAPIKey({ keyPrefix: 'resend' })
+ //
+ // console.log(key)
+ // /*
+ // {
+ // shortToken: "2aGwhSYz",
+ // longToken: "GEbTboUygK1ixefLDTUM5wf7",
+ // longTokenHash: "c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64",
+ // token: "resend_2aGwhSYz_GEbTboUygK1ixefLDTUM5wf7",
+ // }
+ // */
+ // ```
+
+ // When migrating keys to unkey, you just need to give us the longTokenHash
+ // and optional user id etc to link them together so you can later query all
+ // keys for a specific user.
+ // longTokenHash := "f8d7af831e76a886cb225e56d0750a54efab6f89c036e01b2ca1f52203425c72"
+
+ // Unkey doesn't store this token, we just use it below to run a demo
+ // verification.
+ // token := "re_QgLu9m3D_FMbosT9oDBP3D8RkTu6p24wT"
+ resendKey, err := prefixedapikey.GenerateAPIKey(&prefixedapikey.GenerateAPIKeyOptions{
+ KeyPrefix: "re",
+ })
+ require.NoError(t, err)
+
+ // 3. Migrate existing keys to unkey
+ //
+ // We'll give you an api endpoint to send your existing hashes to.
+
+ err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{
+ ID: uid.New(uid.KeyPrefix),
+ KeySpaceID: api.KeyAuthID.String,
+ WorkspaceID: workspace.ID,
+ CreatedAtM: time.Now().UnixMilli(),
+ Hash: resendKey.LongTokenHash,
+ Enabled: true,
+ PendingMigrationID: sql.NullString{Valid: true, String: "resend"},
+ })
+ require.NoError(t, err)
+
+ // 4. Now we're ready to verify keys.
+ // You'll grab the key from the request against your api and then make a call to unkey
+ //
+ // You need to send the key and the preshared constant migration ID,
+
+ res1 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, handler.Request{
+ Key: resendKey.Token,
+ MigrationId: ptr.P("resend"),
+ })
+
+ require.Equal(t, 200, res1.Status)
+ require.True(t, res1.Body.Data.Valid)
+
+ // During the first verification, we look up the key using the algorithm from
+ // your library and then rehash it to use unkey's default algorithm.
+ // Now this key is fully migrated and just like any other unkey key.
+ // Sending the migration ID along for this key is no longer necessary, but doesn't hurt either.
+ // Since you don't know before hand if the key is migrated or not, you can always send the migration ID along with the key and we will handle it accordingly.
+ res2 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, handler.Request{
+ Key: resendKey.Token,
+ })
+
+ require.Equal(t, 200, res2.Status)
+ require.True(t, res2.Body.Data.Valid)
+ })
+}
diff --git a/go/apps/gw/services/auth/auth.go b/go/apps/gw/services/auth/auth.go
index e62eb3a2224..2ba2487aa7d 100644
--- a/go/apps/gw/services/auth/auth.go
+++ b/go/apps/gw/services/auth/auth.go
@@ -10,6 +10,7 @@ import (
"github.com/unkeyed/unkey/go/pkg/assert"
"github.com/unkeyed/unkey/go/pkg/codes"
"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/zen"
)
@@ -92,7 +93,7 @@ func (a *authenticator) verifyAPIKey(ctx context.Context, sess *server.Session,
WorkspaceID: sess.WorkspaceID,
}
- key, emit, err := a.keys.Get(ctx, &z, apiKey)
+ key, emit, err := a.keys.Get(ctx, &z, hash.Sha256(apiKey))
defer emit()
if err != nil {
return fault.Wrap(err,
diff --git a/go/internal/services/keys/get.go b/go/internal/services/keys/get.go
index 0b68525bd6b..4d081533ad1 100644
--- a/go/internal/services/keys/get.go
+++ b/go/internal/services/keys/get.go
@@ -32,7 +32,7 @@ func (s *service) GetRootKey(ctx context.Context, sess *zen.Session) (*KeyVerifi
)
}
- key, log, err := s.Get(ctx, sess, rootKey)
+ key, log, err := s.Get(ctx, sess, hash.Sha256(rootKey))
if err != nil {
return nil, log, err
}
@@ -58,21 +58,20 @@ var emptyLog = func() {}
// Get retrieves a key from the database and performs basic validation checks.
// It returns a KeyVerifier that can be used for further validation with specific options.
// For normal keys, validation failures are indicated by KeyVerifier.Valid=false.
-func (s *service) Get(ctx context.Context, sess *zen.Session, rawKey string) (*KeyVerifier, func(), error) {
+func (s *service) Get(ctx context.Context, sess *zen.Session, sha256Hash string) (*KeyVerifier, func(), error) {
ctx, span := tracing.Start(ctx, "keys.Get")
defer span.End()
- err := assert.NotEmpty(rawKey)
+ err := assert.NotEmpty(sha256Hash)
if err != nil {
- return nil, emptyLog, fault.Wrap(err, fault.Internal("rawKey is empty"))
+ return nil, emptyLog, fault.Wrap(err, fault.Internal("sha256Hash is empty"))
}
- h := hash.Sha256(rawKey)
- key, hit, err := s.keyCache.SWR(ctx, h, func(ctx context.Context) (db.CachedKeyData, error) {
+ key, hit, err := s.keyCache.SWR(ctx, sha256Hash, func(ctx context.Context) (db.CachedKeyData, error) {
// Use database retry with exponential backoff, skipping non-transient errors
var row db.FindKeyForVerificationRow
row, err = db.WithRetryContext(ctx, func() (db.FindKeyForVerificationRow, error) {
- return db.Query.FindKeyForVerification(ctx, s.db.RO(), h)
+ return db.Query.FindKeyForVerification(ctx, s.db.RO(), sha256Hash)
})
if err != nil {
return db.CachedKeyData{}, err
diff --git a/go/internal/services/keys/get_migrated.go b/go/internal/services/keys/get_migrated.go
new file mode 100644
index 00000000000..1bd07b070f3
--- /dev/null
+++ b/go/internal/services/keys/get_migrated.go
@@ -0,0 +1,136 @@
+package keys
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/unkeyed/unkey/go/pkg/assert"
+ "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/tracing"
+ "github.com/unkeyed/unkey/go/pkg/zen"
+)
+
+// GetMigrated uses a special hashing algorithm to retrieve a key from the database and
+// migrates the key to our own hashing algorithm.
+func (s *service) GetMigrated(ctx context.Context, sess *zen.Session, rawKey string, migrationID string) (*KeyVerifier, func(), error) {
+ ctx, span := tracing.Start(ctx, "keys.GetMigrated")
+ defer span.End()
+
+ err := assert.NotEmpty(rawKey)
+ if err != nil {
+ return nil, emptyLog, fault.Wrap(err, fault.Internal("rawKey is empty"))
+ }
+
+ migration, err := db.Query.FindKeyMigrationByID(ctx, s.db.RO(), db.FindKeyMigrationByIDParams{
+ ID: migrationID,
+ WorkspaceID: sess.AuthorizedWorkspaceID(),
+ })
+ if err != nil {
+ if db.IsNotFound(err) {
+ // nolint:exhaustruct
+ return &KeyVerifier{
+ Status: StatusNotFound,
+ message: "migration does not exist",
+ }, emptyLog, nil
+ }
+
+ return nil, emptyLog, fault.Wrap(
+ err,
+ fault.Internal("unable to load migration"),
+ fault.Public("We could not load the requested migration."),
+ )
+ }
+
+ // h is the hash result for the algorithm-specific lookup
+ // start is the prefix + first few chars for display/indexing
+ // Each algorithm is responsible for computing both values based on its key format
+ var h string
+ var start string
+
+ switch migration.Algorithm {
+ case db.KeyMigrationsAlgorithmGithubcomSeamapiPrefixedApiKey:
+ {
+ // Parse from the right since shortToken and longToken never contain underscores,
+ // but prefix can (e.g., "my_company_shortToken_longToken")
+ parts := strings.Split(rawKey, "_")
+ if len(parts) < 3 {
+ return nil, emptyLog, fault.Wrap(
+ fmt.Errorf("expected at least 3 segments, got %d", len(parts)),
+ fault.Code(codes.URN(codes.Auth.Authentication.Malformed.URN())),
+ fault.Public("Invalid key format"),
+ )
+ }
+
+ // Take last 2 parts as shortToken and longToken
+ longToken := parts[len(parts)-1]
+ shortToken := parts[len(parts)-2]
+ // Everything before is the prefix (may contain underscores)
+ prefix := strings.Join(parts[:len(parts)-2], "_")
+
+ // Hash the long token for lookup
+ b := sha256.Sum256([]byte(longToken))
+ h = hex.EncodeToString(b[:])
+
+ // Extract start using the already-parsed parts
+ // Format: prefix_shortToken_longToken -> prefix_shor (first 4 chars of shortToken_longToken)
+ actualKey := shortToken + "_" + longToken
+
+ if len(actualKey) >= 4 {
+ start = fmt.Sprintf("%s_%s", prefix, actualKey[:4])
+ } else {
+ start = fmt.Sprintf("%s_%s", prefix, actualKey)
+ }
+ }
+ case db.KeyMigrationsAlgorithmSha256:
+ // If we have a sha256 already migrated key and we didn't find it in the first place
+ // then it doesn't exist, and there is nothing to migrate here.
+ return &KeyVerifier{
+ Status: StatusNotFound,
+ message: "key does not exist",
+ }, emptyLog, nil
+ default:
+ return nil, emptyLog, fault.New(
+ fmt.Sprintf("unsupported migration algorithm %s", migration.Algorithm),
+ fault.Public(fmt.Sprintf("We could not load the requested migration for algorithm %s.", migration.Algorithm)),
+ )
+ }
+
+ key, log, err := s.Get(ctx, sess, h)
+ if err != nil {
+ return nil, log, err
+ }
+
+ if key.Key.PendingMigrationID.Valid && key.Key.PendingMigrationID.String == migrationID {
+ newHash := hash.Sha256(rawKey)
+ err = db.Query.UpdateKeyHashAndMigration(ctx, s.db.RW(), db.UpdateKeyHashAndMigrationParams{
+ ID: key.Key.ID,
+ Hash: newHash,
+ Start: start,
+ PendingMigrationID: sql.NullString{Valid: false, String: ""},
+ UpdatedAtM: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
+ })
+ if err != nil {
+ return nil, log, fault.Wrap(
+ err,
+ fault.Code(codes.App.Internal.UnexpectedError.URN()),
+ fault.Public("We could not update the key hash and migration id"),
+ )
+ }
+
+ s.keyCache.Remove(
+ ctx,
+ h,
+ newHash,
+ )
+ }
+
+ return key, log, nil
+}
diff --git a/go/internal/services/keys/get_test.go b/go/internal/services/keys/get_test.go
index 366dbf43a85..561554f63fe 100644
--- a/go/internal/services/keys/get_test.go
+++ b/go/internal/services/keys/get_test.go
@@ -47,7 +47,7 @@ func TestGetRootKey_WithEmptyRawKey_ReturnsError(t *testing.T) {
require.Error(t, err)
require.Nil(t, key)
require.NotNil(t, log)
- require.Contains(t, err.Error(), "rawKey is empty")
+ require.Contains(t, err.Error(), "sha256Hash is empty")
}
func TestGet_WithEmptyRawKey_ReturnsError(t *testing.T) {
@@ -62,7 +62,7 @@ func TestGet_WithEmptyRawKey_ReturnsError(t *testing.T) {
require.Error(t, err)
require.Nil(t, key)
require.NotNil(t, log)
- require.Contains(t, err.Error(), "rawKey is empty")
+ require.Contains(t, err.Error(), "sha256Hash is empty")
}
func TestGet_EmptyString_Variants(t *testing.T) {
@@ -83,6 +83,6 @@ func TestGet_EmptyString_Variants(t *testing.T) {
require.Error(t, err)
require.Nil(t, key)
require.NotNil(t, log)
- require.Contains(t, err.Error(), "rawKey is empty")
+ require.Contains(t, err.Error(), "sha256Hash is empty")
}
}
diff --git a/go/internal/services/keys/interface.go b/go/internal/services/keys/interface.go
index 40ea5783006..a3b292e0d5e 100644
--- a/go/internal/services/keys/interface.go
+++ b/go/internal/services/keys/interface.go
@@ -9,12 +9,16 @@ import (
// KeyService defines the interface for key management operations.
// It provides methods for key creation, retrieval, and validation.
type KeyService interface {
- // Get retrieves a key and returns a KeyVerifier for validation
+ // Get retrieves a sha256 hashed key and returns a KeyVerifier for validation
Get(ctx context.Context, sess *zen.Session, hash string) (*KeyVerifier, func(), error)
// GetRootKey retrieves and validates a root key from the session
GetRootKey(ctx context.Context, sess *zen.Session) (*KeyVerifier, func(), error)
+ // GetMigrated retrieves a key using rawKey and migrationID
+ // If migration is pending, it performs on-demand migration and returns a KeyVerifier for further validation.
+ GetMigrated(ctx context.Context, sess *zen.Session, rawKey string, migrationID string) (*KeyVerifier, func(), error)
+
// CreateKey generates a new secure API key
CreateKey(ctx context.Context, req CreateKeyRequest) (CreateKeyResponse, error)
}
diff --git a/go/pkg/codes/constants_gen.go b/go/pkg/codes/constants_gen.go
index 564fa3bb47c..c5a3e4231aa 100644
--- a/go/pkg/codes/constants_gen.go
+++ b/go/pkg/codes/constants_gen.go
@@ -87,6 +87,11 @@ const (
// NotFound indicates the requested API was not found.
UnkeyDataErrorsApiNotFound URN = "err:unkey:data:api_not_found"
+ // Migration
+
+ // NotFound indicates the requested migration was not found.
+ UnkeyDataErrorsMigrationNotFound URN = "err:unkey:data:migration_not_found"
+
// KeySpace
// NotFound indicates the requested key space was not found.
diff --git a/go/pkg/codes/unkey_data.go b/go/pkg/codes/unkey_data.go
index 557f164a303..2acb01f1a97 100644
--- a/go/pkg/codes/unkey_data.go
+++ b/go/pkg/codes/unkey_data.go
@@ -20,6 +20,12 @@ type dataApi struct {
NotFound Code
}
+// dataMigration defines errors related to migration operations.
+type dataMigration struct {
+ // NotFound indicates the requested migration was not found.
+ NotFound Code
+}
+
// dataKeySpace defines errors related to key space operations.
type dataKeySpace struct {
// NotFound indicates the requested key space was not found.
@@ -95,6 +101,7 @@ type UnkeyDataErrors struct {
Key dataKey
Workspace dataWorkspace
Api dataApi
+ Migration dataMigration
KeySpace dataKeySpace
Permission dataPermission
Role dataRole
@@ -122,6 +129,10 @@ var Data = UnkeyDataErrors{
NotFound: Code{SystemUnkey, CategoryUnkeyData, "api_not_found"},
},
+ Migration: dataMigration{
+ NotFound: Code{SystemUnkey, CategoryUnkeyData, "migration_not_found"},
+ },
+
KeySpace: dataKeySpace{
NotFound: Code{SystemUnkey, CategoryUnkeyData, "key_space_not_found"},
},
diff --git a/go/pkg/db/bulk_key_insert.sql_generated.go b/go/pkg/db/bulk_key_insert.sql_generated.go
index 28211f4118c..20634b73681 100644
--- a/go/pkg/db/bulk_key_insert.sql_generated.go
+++ b/go/pkg/db/bulk_key_insert.sql_generated.go
@@ -9,7 +9,7 @@ import (
)
// bulkInsertKey is the base query for bulk insert
-const bulkInsertKey = `INSERT INTO ` + "`" + `keys` + "`" + ` ( id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, enabled, remaining_requests, refill_day, refill_amount ) VALUES %s`
+const bulkInsertKey = `INSERT INTO ` + "`" + `keys` + "`" + ` ( id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, enabled, remaining_requests, refill_day, refill_amount, pending_migration_id ) VALUES %s`
// InsertKeys performs bulk insert in a single query
func (q *BulkQueries) InsertKeys(ctx context.Context, db DBTX, args []InsertKeyParams) error {
@@ -21,7 +21,7 @@ func (q *BulkQueries) InsertKeys(ctx context.Context, db DBTX, args []InsertKeyP
// Build the bulk insert query
valueClauses := make([]string, len(args))
for i := range args {
- valueClauses[i] = "( ?, ?, ?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, ? )"
+ valueClauses[i] = "( ?, ?, ?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, ?, ? )"
}
bulkQuery := fmt.Sprintf(bulkInsertKey, strings.Join(valueClauses, ", "))
@@ -44,6 +44,7 @@ func (q *BulkQueries) InsertKeys(ctx context.Context, db DBTX, args []InsertKeyP
allArgs = append(allArgs, arg.RemainingRequests)
allArgs = append(allArgs, arg.RefillDay)
allArgs = append(allArgs, arg.RefillAmount)
+ allArgs = append(allArgs, arg.PendingMigrationID)
}
// Execute the bulk insert
diff --git a/go/pkg/db/bulk_key_migration_insert.sql_generated.go b/go/pkg/db/bulk_key_migration_insert.sql_generated.go
new file mode 100644
index 00000000000..4784f699fa9
--- /dev/null
+++ b/go/pkg/db/bulk_key_migration_insert.sql_generated.go
@@ -0,0 +1,40 @@
+// Code generated by sqlc bulk insert plugin. DO NOT EDIT.
+
+package db
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// bulkInsertKeyMigration is the base query for bulk insert
+const bulkInsertKeyMigration = `INSERT INTO key_migrations ( id, workspace_id, algorithm ) VALUES %s`
+
+// InsertKeyMigrations performs bulk insert in a single query
+func (q *BulkQueries) InsertKeyMigrations(ctx context.Context, db DBTX, args []InsertKeyMigrationParams) error {
+
+ if len(args) == 0 {
+ return nil
+ }
+
+ // Build the bulk insert query
+ valueClauses := make([]string, len(args))
+ for i := range args {
+ valueClauses[i] = "( ?, ?, ? )"
+ }
+
+ bulkQuery := fmt.Sprintf(bulkInsertKeyMigration, strings.Join(valueClauses, ", "))
+
+ // Collect all arguments
+ var allArgs []any
+ for _, arg := range args {
+ allArgs = append(allArgs, arg.ID)
+ allArgs = append(allArgs, arg.WorkspaceID)
+ allArgs = append(allArgs, arg.Algorithm)
+ }
+
+ // Execute the bulk insert
+ _, err := db.ExecContext(ctx, bulkQuery, allArgs...)
+ return err
+}
diff --git a/go/pkg/db/identity_find_many_by_external_id.sql_generated.go b/go/pkg/db/identity_find_many_by_external_id.sql_generated.go
new file mode 100644
index 00000000000..cf7e1a2ea70
--- /dev/null
+++ b/go/pkg/db/identity_find_many_by_external_id.sql_generated.go
@@ -0,0 +1,72 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: identity_find_many_by_external_id.sql
+
+package db
+
+import (
+ "context"
+ "strings"
+)
+
+const findIdentitiesByExternalId = `-- name: FindIdentitiesByExternalId :many
+SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at
+FROM identities
+WHERE workspace_id = ? AND external_id IN (/*SLICE:externalIds*/?) AND deleted = ?
+`
+
+type FindIdentitiesByExternalIdParams struct {
+ WorkspaceID string `db:"workspace_id"`
+ ExternalIds []string `db:"externalIds"`
+ Deleted bool `db:"deleted"`
+}
+
+// FindIdentitiesByExternalId
+//
+// SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at
+// FROM identities
+// WHERE workspace_id = ? AND external_id IN (/*SLICE:externalIds*/?) AND deleted = ?
+func (q *Queries) FindIdentitiesByExternalId(ctx context.Context, db DBTX, arg FindIdentitiesByExternalIdParams) ([]Identity, error) {
+ query := findIdentitiesByExternalId
+ var queryParams []interface{}
+ queryParams = append(queryParams, arg.WorkspaceID)
+ if len(arg.ExternalIds) > 0 {
+ for _, v := range arg.ExternalIds {
+ queryParams = append(queryParams, v)
+ }
+ query = strings.Replace(query, "/*SLICE:externalIds*/?", strings.Repeat(",?", len(arg.ExternalIds))[1:], 1)
+ } else {
+ query = strings.Replace(query, "/*SLICE:externalIds*/?", "NULL", 1)
+ }
+ queryParams = append(queryParams, arg.Deleted)
+ rows, err := db.QueryContext(ctx, query, queryParams...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Identity
+ for rows.Next() {
+ var i Identity
+ if err := rows.Scan(
+ &i.ID,
+ &i.ExternalID,
+ &i.WorkspaceID,
+ &i.Environment,
+ &i.Meta,
+ &i.Deleted,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/go/pkg/db/key_find_by_id.sql_generated.go b/go/pkg/db/key_find_by_id.sql_generated.go
index adc96c0a42c..f53851fee2d 100644
--- a/go/pkg/db/key_find_by_id.sql_generated.go
+++ b/go/pkg/db/key_find_by_id.sql_generated.go
@@ -10,13 +10,13 @@ import (
)
const findKeyByID = `-- name: FindKeyByID :one
-SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment FROM ` + "`" + `keys` + "`" + ` k
+SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment, pending_migration_id FROM ` + "`" + `keys` + "`" + ` k
WHERE k.id = ?
`
// FindKeyByID
//
-// SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment FROM `keys` k
+// SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment, pending_migration_id FROM `keys` k
// WHERE k.id = ?
func (q *Queries) FindKeyByID(ctx context.Context, db DBTX, id string) (Key, error) {
row := db.QueryRowContext(ctx, findKeyByID, id)
@@ -45,6 +45,7 @@ func (q *Queries) FindKeyByID(ctx context.Context, db DBTX, id string) (Key, err
&i.RatelimitLimit,
&i.RatelimitDuration,
&i.Environment,
+ &i.PendingMigrationID,
)
return i, err
}
diff --git a/go/pkg/db/key_find_for_verification.sql_generated.go b/go/pkg/db/key_find_for_verification.sql_generated.go
index 1093c89159d..1615c548fab 100644
--- a/go/pkg/db/key_find_for_verification.sql_generated.go
+++ b/go/pkg/db/key_find_for_verification.sql_generated.go
@@ -24,6 +24,7 @@ select k.id,
k.last_refill_at,
k.enabled,
k.remaining_requests,
+ k.pending_migration_id,
a.ip_whitelist,
a.workspace_id as api_workspace_id,
a.id as api_id,
@@ -103,6 +104,7 @@ type FindKeyForVerificationRow struct {
LastRefillAt sql.NullTime `db:"last_refill_at"`
Enabled bool `db:"enabled"`
RemainingRequests sql.NullInt32 `db:"remaining_requests"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
IpWhitelist sql.NullString `db:"ip_whitelist"`
ApiWorkspaceID string `db:"api_workspace_id"`
ApiID string `db:"api_id"`
@@ -133,6 +135,7 @@ type FindKeyForVerificationRow struct {
// k.last_refill_at,
// k.enabled,
// k.remaining_requests,
+// k.pending_migration_id,
// a.ip_whitelist,
// a.workspace_id as api_workspace_id,
// a.id as api_id,
@@ -213,6 +216,7 @@ func (q *Queries) FindKeyForVerification(ctx context.Context, db DBTX, hash stri
&i.LastRefillAt,
&i.Enabled,
&i.RemainingRequests,
+ &i.PendingMigrationID,
&i.IpWhitelist,
&i.ApiWorkspaceID,
&i.ApiID,
diff --git a/go/pkg/db/key_find_live_by_hash.sql_generated.go b/go/pkg/db/key_find_live_by_hash.sql_generated.go
index 1788c941358..06e1b616d04 100644
--- a/go/pkg/db/key_find_live_by_hash.sql_generated.go
+++ b/go/pkg/db/key_find_live_by_hash.sql_generated.go
@@ -12,7 +12,7 @@ import (
const findLiveKeyByHash = `-- name: FindLiveKeyByHash :one
SELECT
- k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection,
ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at,
ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m,
@@ -125,6 +125,7 @@ type FindLiveKeyByHashRow struct {
RatelimitLimit sql.NullInt32 `db:"ratelimit_limit"`
RatelimitDuration sql.NullInt64 `db:"ratelimit_duration"`
Environment sql.NullString `db:"environment"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
Api Api `db:"api"`
KeyAuth KeyAuth `db:"key_auth"`
Workspace Workspace `db:"workspace"`
@@ -142,7 +143,7 @@ type FindLiveKeyByHashRow struct {
// FindLiveKeyByHash
//
// SELECT
-// k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+// k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection,
// ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at,
// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m,
@@ -256,6 +257,7 @@ func (q *Queries) FindLiveKeyByHash(ctx context.Context, db DBTX, hash string) (
&i.RatelimitLimit,
&i.RatelimitDuration,
&i.Environment,
+ &i.PendingMigrationID,
&i.Api.ID,
&i.Api.Name,
&i.Api.WorkspaceID,
diff --git a/go/pkg/db/key_find_live_by_id.sql_generated.go b/go/pkg/db/key_find_live_by_id.sql_generated.go
index db6a9d0b7e7..b410859ee0d 100644
--- a/go/pkg/db/key_find_live_by_id.sql_generated.go
+++ b/go/pkg/db/key_find_live_by_id.sql_generated.go
@@ -12,7 +12,7 @@ import (
const findLiveKeyByID = `-- name: FindLiveKeyByID :one
SELECT
- k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection,
ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at,
ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m,
@@ -126,6 +126,7 @@ type FindLiveKeyByIDRow struct {
RatelimitLimit sql.NullInt32 `db:"ratelimit_limit"`
RatelimitDuration sql.NullInt64 `db:"ratelimit_duration"`
Environment sql.NullString `db:"environment"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
Api Api `db:"api"`
KeyAuth KeyAuth `db:"key_auth"`
Workspace Workspace `db:"workspace"`
@@ -143,7 +144,7 @@ type FindLiveKeyByIDRow struct {
// FindLiveKeyByID
//
// SELECT
-// k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+// k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection,
// ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at,
// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m,
@@ -258,6 +259,7 @@ func (q *Queries) FindLiveKeyByID(ctx context.Context, db DBTX, id string) (Find
&i.RatelimitLimit,
&i.RatelimitDuration,
&i.Environment,
+ &i.PendingMigrationID,
&i.Api.ID,
&i.Api.Name,
&i.Api.WorkspaceID,
diff --git a/go/pkg/db/key_find_many_by_hash.sql_generated.go b/go/pkg/db/key_find_many_by_hash.sql_generated.go
new file mode 100644
index 00000000000..f9fb954483b
--- /dev/null
+++ b/go/pkg/db/key_find_many_by_hash.sql_generated.go
@@ -0,0 +1,56 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: key_find_many_by_hash.sql
+
+package db
+
+import (
+ "context"
+ "strings"
+)
+
+const findKeysByHash = `-- name: FindKeysByHash :many
+SELECT id, hash FROM ` + "`" + `keys` + "`" + ` WHERE hash IN (/*SLICE:hashes*/?)
+`
+
+type FindKeysByHashRow struct {
+ ID string `db:"id"`
+ Hash string `db:"hash"`
+}
+
+// FindKeysByHash
+//
+// SELECT id, hash FROM `keys` WHERE hash IN (/*SLICE:hashes*/?)
+func (q *Queries) FindKeysByHash(ctx context.Context, db DBTX, hashes []string) ([]FindKeysByHashRow, error) {
+ query := findKeysByHash
+ var queryParams []interface{}
+ if len(hashes) > 0 {
+ for _, v := range hashes {
+ queryParams = append(queryParams, v)
+ }
+ query = strings.Replace(query, "/*SLICE:hashes*/?", strings.Repeat(",?", len(hashes))[1:], 1)
+ } else {
+ query = strings.Replace(query, "/*SLICE:hashes*/?", "NULL", 1)
+ }
+ rows, err := db.QueryContext(ctx, query, queryParams...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []FindKeysByHashRow
+ for rows.Next() {
+ var i FindKeysByHashRow
+ if err := rows.Scan(&i.ID, &i.Hash); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/go/pkg/db/key_insert.sql_generated.go b/go/pkg/db/key_insert.sql_generated.go
index 72be3d987f0..fef8dfef839 100644
--- a/go/pkg/db/key_insert.sql_generated.go
+++ b/go/pkg/db/key_insert.sql_generated.go
@@ -27,7 +27,8 @@ INSERT INTO ` + "`" + `keys` + "`" + ` (
enabled,
remaining_requests,
refill_day,
- refill_amount
+ refill_amount,
+ pending_migration_id
) VALUES (
?,
?,
@@ -44,26 +45,28 @@ INSERT INTO ` + "`" + `keys` + "`" + ` (
?,
?,
?,
+ ?,
?
)
`
type InsertKeyParams struct {
- ID string `db:"id"`
- KeySpaceID string `db:"key_space_id"`
- Hash string `db:"hash"`
- Start string `db:"start"`
- WorkspaceID string `db:"workspace_id"`
- ForWorkspaceID sql.NullString `db:"for_workspace_id"`
- Name sql.NullString `db:"name"`
- IdentityID sql.NullString `db:"identity_id"`
- Meta sql.NullString `db:"meta"`
- Expires sql.NullTime `db:"expires"`
- CreatedAtM int64 `db:"created_at_m"`
- Enabled bool `db:"enabled"`
- RemainingRequests sql.NullInt32 `db:"remaining_requests"`
- RefillDay sql.NullInt16 `db:"refill_day"`
- RefillAmount sql.NullInt32 `db:"refill_amount"`
+ ID string `db:"id"`
+ KeySpaceID string `db:"key_space_id"`
+ Hash string `db:"hash"`
+ Start string `db:"start"`
+ WorkspaceID string `db:"workspace_id"`
+ ForWorkspaceID sql.NullString `db:"for_workspace_id"`
+ Name sql.NullString `db:"name"`
+ IdentityID sql.NullString `db:"identity_id"`
+ Meta sql.NullString `db:"meta"`
+ Expires sql.NullTime `db:"expires"`
+ CreatedAtM int64 `db:"created_at_m"`
+ Enabled bool `db:"enabled"`
+ RemainingRequests sql.NullInt32 `db:"remaining_requests"`
+ RefillDay sql.NullInt16 `db:"refill_day"`
+ RefillAmount sql.NullInt32 `db:"refill_amount"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
}
// InsertKey
@@ -84,7 +87,8 @@ type InsertKeyParams struct {
// enabled,
// remaining_requests,
// refill_day,
-// refill_amount
+// refill_amount,
+// pending_migration_id
// ) VALUES (
// ?,
// ?,
@@ -101,6 +105,7 @@ type InsertKeyParams struct {
// ?,
// ?,
// ?,
+// ?,
// ?
// )
func (q *Queries) InsertKey(ctx context.Context, db DBTX, arg InsertKeyParams) error {
@@ -120,6 +125,7 @@ func (q *Queries) InsertKey(ctx context.Context, db DBTX, arg InsertKeyParams) e
arg.RemainingRequests,
arg.RefillDay,
arg.RefillAmount,
+ arg.PendingMigrationID,
)
return err
}
diff --git a/go/pkg/db/key_list_by_key_space_id.sql_generated.go b/go/pkg/db/key_list_by_key_space_id.sql_generated.go
index bf6bf858c07..0c7b772a610 100644
--- a/go/pkg/db/key_list_by_key_space_id.sql_generated.go
+++ b/go/pkg/db/key_list_by_key_space_id.sql_generated.go
@@ -12,7 +12,7 @@ import (
const listKeysByKeySpaceID = `-- name: ListKeysByKeySpaceID :many
SELECT
- k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
i.id as identity_id,
i.external_id as external_id,
i.meta as identity_meta,
@@ -49,7 +49,7 @@ type ListKeysByKeySpaceIDRow struct {
// ListKeysByKeySpaceID
//
// SELECT
-// k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+// k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// i.id as identity_id,
// i.external_id as external_id,
// i.meta as identity_meta,
@@ -104,6 +104,7 @@ func (q *Queries) ListKeysByKeySpaceID(ctx context.Context, db DBTX, arg ListKey
&i.Key.RatelimitLimit,
&i.Key.RatelimitDuration,
&i.Key.Environment,
+ &i.Key.PendingMigrationID,
&i.IdentityID,
&i.ExternalID,
&i.IdentityMeta,
diff --git a/go/pkg/db/key_list_live_by_key_space_id.sql_generated.go b/go/pkg/db/key_list_live_by_key_space_id.sql_generated.go
index 4b4522f825a..0d6709e7101 100644
--- a/go/pkg/db/key_list_live_by_key_space_id.sql_generated.go
+++ b/go/pkg/db/key_list_live_by_key_space_id.sql_generated.go
@@ -11,7 +11,7 @@ import (
)
const listLiveKeysByKeySpaceID = `-- name: ListLiveKeysByKeySpaceID :many
-SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
i.id as identity_table_id,
i.external_id as identity_external_id,
i.meta as identity_meta,
@@ -133,6 +133,7 @@ type ListLiveKeysByKeySpaceIDRow struct {
RatelimitLimit sql.NullInt32 `db:"ratelimit_limit"`
RatelimitDuration sql.NullInt64 `db:"ratelimit_duration"`
Environment sql.NullString `db:"environment"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
IdentityTableID sql.NullString `db:"identity_table_id"`
IdentityExternalID sql.NullString `db:"identity_external_id"`
IdentityMeta []byte `db:"identity_meta"`
@@ -146,7 +147,7 @@ type ListLiveKeysByKeySpaceIDRow struct {
// ListLiveKeysByKeySpaceID
//
-// SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+// SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// i.id as identity_table_id,
// i.external_id as identity_external_id,
// i.meta as identity_meta,
@@ -274,6 +275,7 @@ func (q *Queries) ListLiveKeysByKeySpaceID(ctx context.Context, db DBTX, arg Lis
&i.RatelimitLimit,
&i.RatelimitDuration,
&i.Environment,
+ &i.PendingMigrationID,
&i.IdentityTableID,
&i.IdentityExternalID,
&i.IdentityMeta,
diff --git a/go/pkg/db/key_migration_find_by_id.sql_generated.go b/go/pkg/db/key_migration_find_by_id.sql_generated.go
new file mode 100644
index 00000000000..4bfb5d40aaa
--- /dev/null
+++ b/go/pkg/db/key_migration_find_by_id.sql_generated.go
@@ -0,0 +1,41 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: key_migration_find_by_id.sql
+
+package db
+
+import (
+ "context"
+)
+
+const findKeyMigrationByID = `-- name: FindKeyMigrationByID :one
+SELECT
+ id,
+ workspace_id,
+ algorithm
+FROM key_migrations
+WHERE id = ?
+and workspace_id = ?
+`
+
+type FindKeyMigrationByIDParams struct {
+ ID string `db:"id"`
+ WorkspaceID string `db:"workspace_id"`
+}
+
+// FindKeyMigrationByID
+//
+// SELECT
+// id,
+// workspace_id,
+// algorithm
+// FROM key_migrations
+// WHERE id = ?
+// and workspace_id = ?
+func (q *Queries) FindKeyMigrationByID(ctx context.Context, db DBTX, arg FindKeyMigrationByIDParams) (KeyMigration, error) {
+ row := db.QueryRowContext(ctx, findKeyMigrationByID, arg.ID, arg.WorkspaceID)
+ var i KeyMigration
+ err := row.Scan(&i.ID, &i.WorkspaceID, &i.Algorithm)
+ return i, err
+}
diff --git a/go/pkg/db/key_migration_insert.sql_generated.go b/go/pkg/db/key_migration_insert.sql_generated.go
new file mode 100644
index 00000000000..1c0e6fb6d6f
--- /dev/null
+++ b/go/pkg/db/key_migration_insert.sql_generated.go
@@ -0,0 +1,44 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: key_migration_insert.sql
+
+package db
+
+import (
+ "context"
+)
+
+const insertKeyMigration = `-- name: InsertKeyMigration :exec
+INSERT INTO key_migrations (
+ id,
+ workspace_id,
+ algorithm
+) VALUES (
+ ?,
+ ?,
+ ?
+)
+`
+
+type InsertKeyMigrationParams struct {
+ ID string `db:"id"`
+ WorkspaceID string `db:"workspace_id"`
+ Algorithm KeyMigrationsAlgorithm `db:"algorithm"`
+}
+
+// InsertKeyMigration
+//
+// INSERT INTO key_migrations (
+// id,
+// workspace_id,
+// algorithm
+// ) VALUES (
+// ?,
+// ?,
+// ?
+// )
+func (q *Queries) InsertKeyMigration(ctx context.Context, db DBTX, arg InsertKeyMigrationParams) error {
+ _, err := db.ExecContext(ctx, insertKeyMigration, arg.ID, arg.WorkspaceID, arg.Algorithm)
+ return err
+}
diff --git a/go/pkg/db/key_update_hash_and_migration.sql_generated.go b/go/pkg/db/key_update_hash_and_migration.sql_generated.go
new file mode 100644
index 00000000000..e8248339319
--- /dev/null
+++ b/go/pkg/db/key_update_hash_and_migration.sql_generated.go
@@ -0,0 +1,49 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: key_update_hash_and_migration.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const updateKeyHashAndMigration = `-- name: UpdateKeyHashAndMigration :exec
+UPDATE ` + "`" + `keys` + "`" + `
+SET
+ hash = ?,
+ pending_migration_id = ?,
+ start = ?,
+ updated_at_m = ?
+WHERE id = ?
+`
+
+type UpdateKeyHashAndMigrationParams struct {
+ Hash string `db:"hash"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
+ Start string `db:"start"`
+ UpdatedAtM sql.NullInt64 `db:"updated_at_m"`
+ ID string `db:"id"`
+}
+
+// UpdateKeyHashAndMigration
+//
+// UPDATE `keys`
+// SET
+// hash = ?,
+// pending_migration_id = ?,
+// start = ?,
+// updated_at_m = ?
+// WHERE id = ?
+func (q *Queries) UpdateKeyHashAndMigration(ctx context.Context, db DBTX, arg UpdateKeyHashAndMigrationParams) error {
+ _, err := db.ExecContext(ctx, updateKeyHashAndMigration,
+ arg.Hash,
+ arg.PendingMigrationID,
+ arg.Start,
+ arg.UpdatedAtM,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go
index a37822ffdd8..0d93f1cfa20 100644
--- a/go/pkg/db/models_generated.go
+++ b/go/pkg/db/models_generated.go
@@ -321,6 +321,48 @@ func (ns NullDomainsType) Value() (driver.Value, error) {
return string(ns.DomainsType), nil
}
+type KeyMigrationsAlgorithm string
+
+const (
+ KeyMigrationsAlgorithmSha256 KeyMigrationsAlgorithm = "sha256"
+ KeyMigrationsAlgorithmGithubcomSeamapiPrefixedApiKey KeyMigrationsAlgorithm = "github.com/seamapi/prefixed-api-key"
+)
+
+func (e *KeyMigrationsAlgorithm) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = KeyMigrationsAlgorithm(s)
+ case string:
+ *e = KeyMigrationsAlgorithm(s)
+ default:
+ return fmt.Errorf("unsupported scan type for KeyMigrationsAlgorithm: %T", src)
+ }
+ return nil
+}
+
+type NullKeyMigrationsAlgorithm struct {
+ KeyMigrationsAlgorithm KeyMigrationsAlgorithm
+ Valid bool // Valid is true if KeyMigrationsAlgorithm is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullKeyMigrationsAlgorithm) Scan(value interface{}) error {
+ if value == nil {
+ ns.KeyMigrationsAlgorithm, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.KeyMigrationsAlgorithm.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullKeyMigrationsAlgorithm) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.KeyMigrationsAlgorithm), nil
+}
+
type RatelimitOverridesSharding string
const (
@@ -652,29 +694,30 @@ type Identity struct {
}
type Key struct {
- ID string `db:"id"`
- KeyAuthID string `db:"key_auth_id"`
- Hash string `db:"hash"`
- Start string `db:"start"`
- WorkspaceID string `db:"workspace_id"`
- ForWorkspaceID sql.NullString `db:"for_workspace_id"`
- Name sql.NullString `db:"name"`
- OwnerID sql.NullString `db:"owner_id"`
- IdentityID sql.NullString `db:"identity_id"`
- Meta sql.NullString `db:"meta"`
- Expires sql.NullTime `db:"expires"`
- CreatedAtM int64 `db:"created_at_m"`
- UpdatedAtM sql.NullInt64 `db:"updated_at_m"`
- DeletedAtM sql.NullInt64 `db:"deleted_at_m"`
- RefillDay sql.NullInt16 `db:"refill_day"`
- RefillAmount sql.NullInt32 `db:"refill_amount"`
- LastRefillAt sql.NullTime `db:"last_refill_at"`
- Enabled bool `db:"enabled"`
- RemainingRequests sql.NullInt32 `db:"remaining_requests"`
- RatelimitAsync sql.NullBool `db:"ratelimit_async"`
- RatelimitLimit sql.NullInt32 `db:"ratelimit_limit"`
- RatelimitDuration sql.NullInt64 `db:"ratelimit_duration"`
- Environment sql.NullString `db:"environment"`
+ ID string `db:"id"`
+ KeyAuthID string `db:"key_auth_id"`
+ Hash string `db:"hash"`
+ Start string `db:"start"`
+ WorkspaceID string `db:"workspace_id"`
+ ForWorkspaceID sql.NullString `db:"for_workspace_id"`
+ Name sql.NullString `db:"name"`
+ OwnerID sql.NullString `db:"owner_id"`
+ IdentityID sql.NullString `db:"identity_id"`
+ Meta sql.NullString `db:"meta"`
+ Expires sql.NullTime `db:"expires"`
+ CreatedAtM int64 `db:"created_at_m"`
+ UpdatedAtM sql.NullInt64 `db:"updated_at_m"`
+ DeletedAtM sql.NullInt64 `db:"deleted_at_m"`
+ RefillDay sql.NullInt16 `db:"refill_day"`
+ RefillAmount sql.NullInt32 `db:"refill_amount"`
+ LastRefillAt sql.NullTime `db:"last_refill_at"`
+ Enabled bool `db:"enabled"`
+ RemainingRequests sql.NullInt32 `db:"remaining_requests"`
+ RatelimitAsync sql.NullBool `db:"ratelimit_async"`
+ RatelimitLimit sql.NullInt32 `db:"ratelimit_limit"`
+ RatelimitDuration sql.NullInt64 `db:"ratelimit_duration"`
+ Environment sql.NullString `db:"environment"`
+ PendingMigrationID sql.NullString `db:"pending_migration_id"`
}
type KeyAuth struct {
@@ -690,6 +733,12 @@ type KeyAuth struct {
SizeLastUpdatedAt int64 `db:"size_last_updated_at"`
}
+type KeyMigration struct {
+ ID string `db:"id"`
+ WorkspaceID string `db:"workspace_id"`
+ Algorithm KeyMigrationsAlgorithm `db:"algorithm"`
+}
+
type KeyMigrationError struct {
ID string `db:"id"`
MigrationID string `db:"migration_id"`
diff --git a/go/pkg/db/querier_bulk_generated.go b/go/pkg/db/querier_bulk_generated.go
index 6e1be6a4f5e..411ebe0e584 100644
--- a/go/pkg/db/querier_bulk_generated.go
+++ b/go/pkg/db/querier_bulk_generated.go
@@ -21,6 +21,7 @@ type BulkQuerier interface {
InsertKeyEncryptions(ctx context.Context, db DBTX, args []InsertKeyEncryptionParams) error
InsertKeys(ctx context.Context, db DBTX, args []InsertKeyParams) error
InsertKeyRatelimits(ctx context.Context, db DBTX, args []InsertKeyRatelimitParams) error
+ InsertKeyMigrations(ctx context.Context, db DBTX, args []InsertKeyMigrationParams) error
InsertKeyPermissions(ctx context.Context, db DBTX, args []InsertKeyPermissionParams) error
InsertKeyRoles(ctx context.Context, db DBTX, args []InsertKeyRoleParams) error
InsertKeySpaces(ctx context.Context, db DBTX, args []InsertKeySpaceParams) error
diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go
index 923661b40f1..b3c2fe8220b 100644
--- a/go/pkg/db/querier_generated.go
+++ b/go/pkg/db/querier_generated.go
@@ -278,6 +278,12 @@ type Querier interface {
// AND deleted = ?
// AND (external_id IN(/*SLICE:identities*/?) OR id IN (/*SLICE:identities*/?))
FindIdentities(ctx context.Context, db DBTX, arg FindIdentitiesParams) ([]Identity, error)
+ //FindIdentitiesByExternalId
+ //
+ // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at
+ // FROM identities
+ // WHERE workspace_id = ? AND external_id IN (/*SLICE:externalIds*/?) AND deleted = ?
+ FindIdentitiesByExternalId(ctx context.Context, db DBTX, arg FindIdentitiesByExternalIdParams) ([]Identity, error)
//FindIdentity
//
// SELECT
@@ -349,7 +355,7 @@ type Querier interface {
FindKeyAuthsByKeyAuthIds(ctx context.Context, db DBTX, arg FindKeyAuthsByKeyAuthIdsParams) ([]FindKeyAuthsByKeyAuthIdsRow, error)
//FindKeyByID
//
- // SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment FROM `keys` k
+ // SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment, pending_migration_id FROM `keys` k
// WHERE k.id = ?
FindKeyByID(ctx context.Context, db DBTX, id string) (Key, error)
//FindKeyCredits
@@ -375,6 +381,7 @@ type Querier interface {
// k.last_refill_at,
// k.enabled,
// k.remaining_requests,
+ // k.pending_migration_id,
// a.ip_whitelist,
// a.workspace_id as api_workspace_id,
// a.id as api_id,
@@ -439,6 +446,16 @@ type Querier interface {
// where k.hash = ?
// and k.deleted_at_m is null
FindKeyForVerification(ctx context.Context, db DBTX, hash string) (FindKeyForVerificationRow, error)
+ //FindKeyMigrationByID
+ //
+ // SELECT
+ // id,
+ // workspace_id,
+ // algorithm
+ // FROM key_migrations
+ // WHERE id = ?
+ // and workspace_id = ?
+ FindKeyMigrationByID(ctx context.Context, db DBTX, arg FindKeyMigrationByIDParams) (KeyMigration, error)
//FindKeyRoleByKeyAndRoleID
//
// SELECT key_id, role_id, workspace_id, created_at_m, updated_at_m
@@ -450,6 +467,10 @@ type Querier interface {
//
// SELECT id, workspace_id, created_at_m, updated_at_m, deleted_at_m, store_encrypted_keys, default_prefix, default_bytes, size_approx, size_last_updated_at FROM `key_auth` WHERE id = ?
FindKeySpaceByID(ctx context.Context, db DBTX, id string) (KeyAuth, error)
+ //FindKeysByHash
+ //
+ // SELECT id, hash FROM `keys` WHERE hash IN (/*SLICE:hashes*/?)
+ FindKeysByHash(ctx context.Context, db DBTX, hashes []string) ([]FindKeysByHashRow, error)
//FindLiveApiByID
//
// SELECT apis.id, apis.name, apis.workspace_id, apis.ip_whitelist, apis.auth_type, apis.key_auth_id, apis.created_at_m, apis.updated_at_m, apis.deleted_at_m, apis.delete_protection, ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at
@@ -463,7 +484,7 @@ type Querier interface {
//FindLiveKeyByHash
//
// SELECT
- // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection,
// ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at,
// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m,
@@ -554,7 +575,7 @@ type Querier interface {
//FindLiveKeyByID
//
// SELECT
- // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection,
// ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at,
// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m,
@@ -1181,7 +1202,8 @@ type Querier interface {
// enabled,
// remaining_requests,
// refill_day,
- // refill_amount
+ // refill_amount,
+ // pending_migration_id
// ) VALUES (
// ?,
// ?,
@@ -1198,6 +1220,7 @@ type Querier interface {
// ?,
// ?,
// ?,
+ // ?,
// ?
// )
InsertKey(ctx context.Context, db DBTX, arg InsertKeyParams) error
@@ -1225,6 +1248,18 @@ type Querier interface {
// (workspace_id, key_id, encrypted, encryption_key_id, created_at)
// VALUES (?, ?, ?, ?, ?)
InsertKeyEncryption(ctx context.Context, db DBTX, arg InsertKeyEncryptionParams) error
+ //InsertKeyMigration
+ //
+ // INSERT INTO key_migrations (
+ // id,
+ // workspace_id,
+ // algorithm
+ // ) VALUES (
+ // ?,
+ // ?,
+ // ?
+ // )
+ InsertKeyMigration(ctx context.Context, db DBTX, arg InsertKeyMigrationParams) error
//InsertKeyPermission
//
// INSERT INTO `keys_permissions` (
@@ -1489,7 +1524,7 @@ type Querier interface {
//ListKeysByKeySpaceID
//
// SELECT
- // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// i.id as identity_id,
// i.external_id as external_id,
// i.meta as identity_meta,
@@ -1508,7 +1543,7 @@ type Querier interface {
ListKeysByKeySpaceID(ctx context.Context, db DBTX, arg ListKeysByKeySpaceIDParams) ([]ListKeysByKeySpaceIDRow, error)
//ListLiveKeysByKeySpaceID
//
- // SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment,
+ // SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id,
// i.id as identity_table_id,
// i.external_id as identity_external_id,
// i.meta as identity_meta,
@@ -1913,6 +1948,16 @@ type Querier interface {
// SET remaining_requests = ?
// WHERE id = ?
UpdateKeyCreditsSet(ctx context.Context, db DBTX, arg UpdateKeyCreditsSetParams) error
+ //UpdateKeyHashAndMigration
+ //
+ // UPDATE `keys`
+ // SET
+ // hash = ?,
+ // pending_migration_id = ?,
+ // start = ?,
+ // updated_at_m = ?
+ // WHERE id = ?
+ UpdateKeyHashAndMigration(ctx context.Context, db DBTX, arg UpdateKeyHashAndMigrationParams) error
//UpdateKeySpaceKeyEncryption
//
// UPDATE `key_auth` SET store_encrypted_keys = ? WHERE id = ?
diff --git a/go/pkg/db/queries/identity_find_many_by_external_id.sql b/go/pkg/db/queries/identity_find_many_by_external_id.sql
new file mode 100644
index 00000000000..e4c1f8689d5
--- /dev/null
+++ b/go/pkg/db/queries/identity_find_many_by_external_id.sql
@@ -0,0 +1,4 @@
+-- name: FindIdentitiesByExternalId :many
+SELECT *
+FROM identities
+WHERE workspace_id = ? AND external_id IN (sqlc.slice('externalIds')) AND deleted = ?;
diff --git a/go/pkg/db/queries/key_find_for_verification.sql b/go/pkg/db/queries/key_find_for_verification.sql
index d649de3567a..5b0e21bbd3e 100644
--- a/go/pkg/db/queries/key_find_for_verification.sql
+++ b/go/pkg/db/queries/key_find_for_verification.sql
@@ -12,6 +12,7 @@ select k.id,
k.last_refill_at,
k.enabled,
k.remaining_requests,
+ k.pending_migration_id,
a.ip_whitelist,
a.workspace_id as api_workspace_id,
a.id as api_id,
diff --git a/go/pkg/db/queries/key_find_many_by_hash.sql b/go/pkg/db/queries/key_find_many_by_hash.sql
new file mode 100644
index 00000000000..631aa50528d
--- /dev/null
+++ b/go/pkg/db/queries/key_find_many_by_hash.sql
@@ -0,0 +1,2 @@
+-- name: FindKeysByHash :many
+SELECT id, hash FROM `keys` WHERE hash IN (sqlc.slice(hashes));
diff --git a/go/pkg/db/queries/key_insert.sql b/go/pkg/db/queries/key_insert.sql
index 82dc0d09a26..d3640a6fa10 100644
--- a/go/pkg/db/queries/key_insert.sql
+++ b/go/pkg/db/queries/key_insert.sql
@@ -15,7 +15,8 @@ INSERT INTO `keys` (
enabled,
remaining_requests,
refill_day,
- refill_amount
+ refill_amount,
+ pending_migration_id
) VALUES (
sqlc.arg(id),
sqlc.arg(key_space_id),
@@ -32,5 +33,6 @@ INSERT INTO `keys` (
sqlc.arg(enabled),
sqlc.arg(remaining_requests),
sqlc.arg(refill_day),
- sqlc.arg(refill_amount)
+ sqlc.arg(refill_amount),
+ sqlc.arg(pending_migration_id)
);
diff --git a/go/pkg/db/queries/key_migration_find_by_id.sql b/go/pkg/db/queries/key_migration_find_by_id.sql
new file mode 100644
index 00000000000..5273873895c
--- /dev/null
+++ b/go/pkg/db/queries/key_migration_find_by_id.sql
@@ -0,0 +1,8 @@
+-- name: FindKeyMigrationByID :one
+SELECT
+ id,
+ workspace_id,
+ algorithm
+FROM key_migrations
+WHERE id = sqlc.arg(id)
+and workspace_id = sqlc.arg(workspace_id);
diff --git a/go/pkg/db/queries/key_migration_insert.sql b/go/pkg/db/queries/key_migration_insert.sql
new file mode 100644
index 00000000000..2429fe7491c
--- /dev/null
+++ b/go/pkg/db/queries/key_migration_insert.sql
@@ -0,0 +1,10 @@
+-- name: InsertKeyMigration :exec
+INSERT INTO key_migrations (
+ id,
+ workspace_id,
+ algorithm
+) VALUES (
+ sqlc.arg(id),
+ sqlc.arg(workspace_id),
+ sqlc.arg(algorithm)
+);
diff --git a/go/pkg/db/queries/key_update_hash_and_migration.sql b/go/pkg/db/queries/key_update_hash_and_migration.sql
new file mode 100644
index 00000000000..69e80809680
--- /dev/null
+++ b/go/pkg/db/queries/key_update_hash_and_migration.sql
@@ -0,0 +1,8 @@
+-- name: UpdateKeyHashAndMigration :exec
+UPDATE `keys`
+SET
+ hash = sqlc.arg(hash),
+ pending_migration_id = sqlc.arg(pending_migration_id),
+ start = sqlc.arg(start),
+ updated_at_m = sqlc.arg(updated_at_m)
+WHERE id = sqlc.arg(id);
diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql
index 0b6162b57c9..f5471cffe1a 100644
--- a/go/pkg/db/schema.sql
+++ b/go/pkg/db/schema.sql
@@ -95,6 +95,13 @@ CREATE TABLE `encrypted_keys` (
CONSTRAINT `key_id_idx` UNIQUE(`key_id`)
);
+CREATE TABLE `key_migrations` (
+ `id` varchar(255) NOT NULL,
+ `workspace_id` varchar(256) NOT NULL,
+ `algorithm` enum('sha256','github.com/seamapi/prefixed-api-key') NOT NULL,
+ CONSTRAINT `key_migrations_id_workspace_id_pk` PRIMARY KEY(`id`,`workspace_id`)
+);
+
CREATE TABLE `keys` (
`id` varchar(256) NOT NULL,
`key_auth_id` varchar(256) NOT NULL,
@@ -119,6 +126,7 @@ CREATE TABLE `keys` (
`ratelimit_limit` int,
`ratelimit_duration` bigint,
`environment` varchar(256),
+ `pending_migration_id` varchar(256),
CONSTRAINT `keys_id` PRIMARY KEY(`id`),
CONSTRAINT `hash_idx` UNIQUE(`hash`)
);
@@ -411,6 +419,7 @@ CREATE INDEX `workspace_id_idx` ON `apis` (`workspace_id`);
CREATE INDEX `workspace_id_idx` ON `roles` (`workspace_id`);
CREATE INDEX `key_auth_id_deleted_at_idx` ON `keys` (`key_auth_id`,`deleted_at_m`);
CREATE INDEX `idx_keys_on_for_workspace_id` ON `keys` (`for_workspace_id`);
+CREATE INDEX `pending_migration_id_idx` ON `keys` (`pending_migration_id`);
CREATE INDEX `idx_keys_on_workspace_id` ON `keys` (`workspace_id`);
CREATE INDEX `owner_id_idx` ON `keys` (`owner_id`);
CREATE INDEX `identity_id_idx` ON `keys` (`identity_id`);
diff --git a/go/pkg/prefixedapikey/LICENSE b/go/pkg/prefixedapikey/LICENSE
new file mode 100644
index 00000000000..95aa932ddc5
--- /dev/null
+++ b/go/pkg/prefixedapikey/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Seam
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/go/pkg/prefixedapikey/cmd/main.go b/go/pkg/prefixedapikey/cmd/main.go
new file mode 100644
index 00000000000..483207bf66a
--- /dev/null
+++ b/go/pkg/prefixedapikey/cmd/main.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/unkeyed/unkey/go/pkg/cli"
+ "github.com/unkeyed/unkey/go/pkg/prefixedapikey"
+)
+
+func main() {
+ app := &cli.Command{
+ Name: "prefixed-api-key",
+ Usage: "Generate prefixed API keys",
+ Description: "A CLI tool for generating prefixed API keys compatible with github.com/seamapi/prefixed-api-key",
+ Version: "1.0.0",
+ Action: generateAction,
+ Flags: []cli.Flag{
+ cli.String("prefix", "Key prefix (e.g., 'myapp', 'prod', 'dev')", cli.Required()),
+ cli.String("short-prefix", "Optional prefix for the short token (e.g., 'test')"),
+ cli.Int("short-length", "Length of the short token in bytes", cli.Default(8)),
+ cli.Int("long-length", "Length of the long token in bytes", cli.Default(24)),
+ cli.Int("count", "Number of keys to generate", cli.Default(1)),
+ cli.Bool("json", "Output in JSON format"),
+ },
+ }
+
+ if err := app.Run(context.Background(), os.Args); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func generateAction(ctx context.Context, cmd *cli.Command) error {
+ opts := &prefixedapikey.GenerateAPIKeyOptions{
+ KeyPrefix: cmd.RequireString("prefix"),
+ ShortTokenPrefix: cmd.String("short-prefix"),
+ ShortTokenLength: cmd.Int("short-length"),
+ LongTokenLength: cmd.Int("long-length"),
+ }
+
+ count := cmd.Int("count")
+ useJSON := cmd.Bool("json")
+
+ // Validate count
+ if count < 1 {
+ return fmt.Errorf("count must be at least 1")
+ }
+
+ // Generate keys
+ var keys []*prefixedapikey.APIKey
+ for i := 0; i < count; i++ {
+ key, err := prefixedapikey.GenerateAPIKey(opts)
+ if err != nil {
+ return fmt.Errorf("failed to generate key: %w", err)
+ }
+ keys = append(keys, key)
+ }
+
+ // Output results
+ if useJSON {
+ return outputJSON(keys)
+ }
+
+ return outputTable(keys)
+}
+
+func outputJSON(keys []*prefixedapikey.APIKey) error {
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(keys)
+}
+
+func outputTable(keys []*prefixedapikey.APIKey) error {
+ if len(keys) == 1 {
+ // Single key output - detailed format
+ key := keys[0]
+ fmt.Println("Generated API Key:")
+ fmt.Println("==================")
+ fmt.Printf("Full Token: %s\n", key.Token)
+ fmt.Printf("Short Token: %s\n", key.ShortToken)
+ fmt.Printf("Long Token: %s\n", key.LongToken)
+ fmt.Printf("Long Token Hash: %s\n", key.LongTokenHash)
+ fmt.Println()
+ fmt.Println("Store the 'Long Token Hash' in your database.")
+ fmt.Println("Give the 'Full Token' to your user (only shown once).")
+ } else {
+ // Multiple keys - compact table format
+ fmt.Printf("Generated %d API Keys:\n", len(keys))
+ fmt.Println("==================================================")
+ fmt.Printf("%-10s %-40s %s\n", "Index", "Full Token", "Hash (store this)")
+ fmt.Println("--------------------------------------------------")
+ for i, key := range keys {
+ // Truncate token for display if too long
+ token := key.Token
+ if len(token) > 40 {
+ token = token[:37] + "..."
+ }
+ fmt.Printf("%-10d %-40s %s...\n", i+1, token, key.LongTokenHash[:16])
+ }
+ fmt.Println("==================================================")
+ fmt.Println()
+ fmt.Println("Use --json flag for full output including all tokens and hashes.")
+ }
+
+ return nil
+}
diff --git a/go/pkg/prefixedapikey/prefixedapikey.go b/go/pkg/prefixedapikey/prefixedapikey.go
new file mode 100644
index 00000000000..1fadd379675
--- /dev/null
+++ b/go/pkg/prefixedapikey/prefixedapikey.go
@@ -0,0 +1,181 @@
+package prefixedapikey
+
+// This Go package is a port of the https://github.com/seamapi/prefixed-api-key, licensed under MIT.
+// See License for copyright and license information.
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/unkeyed/unkey/go/pkg/base58"
+)
+
+// ErrMissingKeyPrefix is returned when GenerateAPIKey is called without a KeyPrefix
+var ErrMissingKeyPrefix = errors.New("missing KeyPrefix in options")
+
+// GenerateAPIKeyOptions holds the options for generating an API key
+type GenerateAPIKeyOptions struct {
+ KeyPrefix string
+ ShortTokenPrefix string
+ ShortTokenLength int
+ LongTokenLength int
+}
+
+// APIKey represents the generated API key components
+type APIKey struct {
+ ShortToken string
+ LongToken string
+ LongTokenHash string
+ Token string
+}
+
+// hashLongTokenToBytes hashes a long token using SHA256 and returns the bytes
+func hashLongTokenToBytes(longToken string) []byte {
+ hash := sha256.Sum256([]byte(longToken))
+ return hash[:]
+}
+
+// HashLongToken hashes a long token using SHA256 and returns hex string
+func HashLongToken(longToken string) string {
+ return hex.EncodeToString(hashLongTokenToBytes(longToken))
+}
+
+// padStart pads a string with a character to reach the specified length
+func padStart(str string, length int, padChar string) string {
+ if len(str) >= length {
+ return str
+ }
+ padding := strings.Repeat(padChar, length-len(str))
+ return padding + str
+}
+
+// GenerateAPIKey generates a new API key with the given options
+func GenerateAPIKey(opts *GenerateAPIKeyOptions) (*APIKey, error) {
+ // Set default values if not provided
+ if opts == nil {
+ opts = &GenerateAPIKeyOptions{}
+ }
+ if opts.KeyPrefix == "" {
+ return nil, ErrMissingKeyPrefix
+ }
+
+ if opts.ShortTokenPrefix == "" {
+ opts.ShortTokenPrefix = ""
+ }
+
+ if opts.ShortTokenLength == 0 {
+ opts.ShortTokenLength = 8
+ }
+
+ if opts.LongTokenLength == 0 {
+ opts.LongTokenLength = 24
+ }
+
+ // Generate random bytes for tokens
+ shortTokenBytes := make([]byte, opts.ShortTokenLength)
+ longTokenBytes := make([]byte, opts.LongTokenLength)
+
+ if _, err := rand.Read(shortTokenBytes); err != nil {
+ return nil, fmt.Errorf("failed to generate short token: %w", err)
+ }
+ if _, err := rand.Read(longTokenBytes); err != nil {
+ return nil, fmt.Errorf("failed to generate long token: %w", err)
+ }
+
+ // Encode tokens using base58
+ shortToken := padStart(
+ base58.Encode(shortTokenBytes),
+ opts.ShortTokenLength,
+ "0",
+ )
+ if len(shortToken) > opts.ShortTokenLength {
+ shortToken = shortToken[:opts.ShortTokenLength]
+ }
+
+ longToken := padStart(
+ base58.Encode(longTokenBytes),
+ opts.LongTokenLength,
+ "0",
+ )
+ if len(longToken) > opts.LongTokenLength {
+ longToken = longToken[:opts.LongTokenLength]
+ }
+
+ // Hash the long token
+ longTokenHash := HashLongToken(longToken)
+
+ // Add prefix to short token and trim if necessary
+ shortToken = (opts.ShortTokenPrefix + shortToken)
+ if len(shortToken) > opts.ShortTokenLength {
+ shortToken = shortToken[:opts.ShortTokenLength]
+ }
+
+ // Construct the full token
+ token := fmt.Sprintf("%s_%s_%s", opts.KeyPrefix, shortToken, longToken)
+
+ return &APIKey{
+ ShortToken: shortToken,
+ LongToken: longToken,
+ LongTokenHash: longTokenHash,
+ Token: token,
+ }, nil
+}
+
+// ExtractLongToken extracts the long token from a full API key
+func ExtractLongToken(token string) string {
+ parts := strings.Split(token, "_")
+ if len(parts) > 0 {
+ return parts[len(parts)-1]
+ }
+ return ""
+}
+
+// ExtractShortToken extracts the short token from a full API key
+func ExtractShortToken(token string) string {
+ parts := strings.Split(token, "_")
+ if len(parts) > 1 {
+ return parts[1]
+ }
+ return ""
+}
+
+// ExtractLongTokenHash extracts and hashes the long token from a full API key
+func ExtractLongTokenHash(token string) string {
+ return HashLongToken(ExtractLongToken(token))
+}
+
+// TokenComponents represents the components of an API key
+type TokenComponents struct {
+ LongToken string
+ ShortToken string
+ LongTokenHash string
+ Token string
+}
+
+// GetTokenComponents extracts all components from a full API key
+func GetTokenComponents(token string) *TokenComponents {
+ return &TokenComponents{
+ LongToken: ExtractLongToken(token),
+ ShortToken: ExtractShortToken(token),
+ LongTokenHash: HashLongToken(ExtractLongToken(token)),
+ Token: token,
+ }
+}
+
+// CheckAPIKey verifies if a token matches the expected long token hash using constant-time comparison
+func CheckAPIKey(token string, expectedLongTokenHash string) bool {
+ expectedLongTokenHashBytes, err := hex.DecodeString(expectedLongTokenHash)
+ if err != nil {
+ return false
+ }
+
+ inputLongTokenHashBytes := hashLongTokenToBytes(ExtractLongToken(token))
+
+ // Use constant-time comparison to prevent timing attacks
+ return subtle.ConstantTimeCompare(expectedLongTokenHashBytes, inputLongTokenHashBytes) == 1
+}
diff --git a/go/pkg/prefixedapikey/prefixedapikey_test.go b/go/pkg/prefixedapikey/prefixedapikey_test.go
new file mode 100644
index 00000000000..3db7f6fbd48
--- /dev/null
+++ b/go/pkg/prefixedapikey/prefixedapikey_test.go
@@ -0,0 +1,362 @@
+package prefixedapikey
+
+import (
+ "testing"
+)
+
+// exampleKey represents a known test key for consistent testing
+var exampleKey = &APIKey{
+ ShortToken: "12345678",
+ LongToken: "abcdefghijklmnopqrstuvwx",
+ LongTokenHash: HashLongToken("abcdefghijklmnopqrstuvwx"),
+ Token: "test_12345678_abcdefghijklmnopqrstuvwx",
+}
+
+func TestHashLongToken(t *testing.T) {
+ result := HashLongToken(exampleKey.LongToken)
+ expected := exampleKey.LongTokenHash
+
+ if result != expected {
+ t.Errorf("HashLongToken() = %v, want %v", result, expected)
+ }
+}
+
+func TestExtractLongToken(t *testing.T) {
+ result := ExtractLongToken(exampleKey.Token)
+ expected := exampleKey.LongToken
+
+ if result != expected {
+ t.Errorf("ExtractLongToken() = %v, want %v", result, expected)
+ }
+
+ // Additional test cases
+ tests := []struct {
+ name string
+ token string
+ expected string
+ }{
+ {
+ name: "standard token format",
+ token: "test_12345678_abcdefghijklmnopqrstuvwx",
+ expected: "abcdefghijklmnopqrstuvwx",
+ },
+ {
+ name: "token with multiple underscores",
+ token: "prefix_with_underscores_short_long",
+ expected: "long",
+ },
+ {
+ name: "single underscore",
+ token: "prefix_longtoken",
+ expected: "longtoken",
+ },
+ {
+ name: "no underscores",
+ token: "notokenstructure",
+ expected: "notokenstructure",
+ },
+ {
+ name: "empty token",
+ token: "",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ExtractLongToken(tt.token)
+ if result != tt.expected {
+ t.Errorf("ExtractLongToken(%v) = %v, want %v", tt.token, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestExtractShortToken(t *testing.T) {
+ result := ExtractShortToken(exampleKey.Token)
+ expected := exampleKey.ShortToken
+
+ if result != expected {
+ t.Errorf("ExtractShortToken() = %v, want %v", result, expected)
+ }
+
+ // Additional test cases
+ tests := []struct {
+ name string
+ token string
+ expected string
+ }{
+ {
+ name: "standard token format",
+ token: "test_12345678_abcdefghijklmnopqrstuvwx",
+ expected: "12345678",
+ },
+ {
+ name: "token with multiple underscores",
+ token: "prefix_with_underscores_short_long",
+ expected: "with",
+ },
+ {
+ name: "single underscore",
+ token: "prefix_shorttoken",
+ expected: "shorttoken",
+ },
+ {
+ name: "no underscores",
+ token: "notokenstructure",
+ expected: "",
+ },
+ {
+ name: "empty token",
+ token: "",
+ expected: "",
+ },
+ {
+ name: "only prefix",
+ token: "prefix_",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ExtractShortToken(tt.token)
+ if result != tt.expected {
+ t.Errorf("ExtractShortToken(%v) = %v, want %v", tt.token, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGetTokenComponents(t *testing.T) {
+ result := GetTokenComponents(exampleKey.Token)
+
+ expected := &TokenComponents{
+ LongToken: exampleKey.LongToken,
+ ShortToken: exampleKey.ShortToken,
+ LongTokenHash: exampleKey.LongTokenHash,
+ Token: exampleKey.Token,
+ }
+
+ if result.LongToken != expected.LongToken {
+ t.Errorf("GetTokenComponents().LongToken = %v, want %v", result.LongToken, expected.LongToken)
+ }
+
+ if result.ShortToken != expected.ShortToken {
+ t.Errorf("GetTokenComponents().ShortToken = %v, want %v", result.ShortToken, expected.ShortToken)
+ }
+
+ if result.LongTokenHash != expected.LongTokenHash {
+ t.Errorf("GetTokenComponents().LongTokenHash = %v, want %v", result.LongTokenHash, expected.LongTokenHash)
+ }
+
+ if result.Token != expected.Token {
+ t.Errorf("GetTokenComponents().Token = %v, want %v", result.Token, expected.Token)
+ }
+}
+
+func TestCheckAPIKey(t *testing.T) {
+ result := CheckAPIKey(exampleKey.Token, exampleKey.LongTokenHash)
+ expected := true
+
+ if result != expected {
+ t.Errorf("CheckAPIKey() = %v, want %v", result, expected)
+ }
+
+ // Additional test cases
+ invalidHash := "invalid_hash"
+ tests := []struct {
+ name string
+ token string
+ expectedHash string
+ expected bool
+ }{
+ {
+ name: "valid token and hash",
+ token: exampleKey.Token,
+ expectedHash: exampleKey.LongTokenHash,
+ expected: true,
+ },
+ {
+ name: "invalid hash",
+ token: exampleKey.Token,
+ expectedHash: invalidHash,
+ expected: false,
+ },
+ {
+ name: "empty token",
+ token: "",
+ expectedHash: exampleKey.LongTokenHash,
+ expected: false,
+ },
+ {
+ name: "empty hash",
+ token: exampleKey.Token,
+ expectedHash: "",
+ expected: false,
+ },
+ {
+ name: "malformed token",
+ token: "malformed_token",
+ expectedHash: exampleKey.LongTokenHash,
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := CheckAPIKey(tt.token, tt.expectedHash)
+ if result != tt.expected {
+ t.Errorf("CheckAPIKey(%v, %v) = %v, want %v", tt.token, tt.expectedHash, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGenerateAPIKey(t *testing.T) {
+ tests := []struct {
+ name string
+ opts *GenerateAPIKeyOptions
+ hasError bool
+ }{
+ {
+ name: "standard generation",
+ opts: &GenerateAPIKeyOptions{
+ KeyPrefix: "test",
+ ShortTokenPrefix: "",
+ ShortTokenLength: 8,
+ LongTokenLength: 24,
+ },
+ hasError: false,
+ },
+ {
+ name: "with short token prefix",
+ opts: &GenerateAPIKeyOptions{
+ KeyPrefix: "api",
+ ShortTokenPrefix: "dev",
+ ShortTokenLength: 10,
+ LongTokenLength: 32,
+ },
+ hasError: false,
+ },
+ {
+ name: "minimal options",
+ opts: &GenerateAPIKeyOptions{
+ KeyPrefix: "min",
+ },
+ hasError: false,
+ },
+ {
+ name: "nil options returns error",
+ opts: nil,
+ hasError: true,
+ },
+ {
+ name: "empty key prefix returns error",
+ opts: &GenerateAPIKeyOptions{
+ KeyPrefix: "",
+ },
+ hasError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := GenerateAPIKey(tt.opts)
+
+ if tt.hasError {
+ if err == nil {
+ t.Errorf("GenerateAPIKey() expected error, got nil")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("GenerateAPIKey() unexpected error: %v", err)
+ return
+ }
+
+ // Validate the generated key structure
+ if result.Token == "" {
+ t.Errorf("GenerateAPIKey() generated empty token")
+ }
+
+ if result.ShortToken == "" {
+ t.Errorf("GenerateAPIKey() generated empty short token")
+ }
+
+ if result.LongToken == "" {
+ t.Errorf("GenerateAPIKey() generated empty long token")
+ }
+
+ if result.LongTokenHash == "" {
+ t.Errorf("GenerateAPIKey() generated empty long token hash")
+ }
+
+ // Verify the hash matches
+ expectedHash := HashLongToken(result.LongToken)
+ if result.LongTokenHash != expectedHash {
+ t.Errorf("GenerateAPIKey() hash mismatch: got %v, want %v", result.LongTokenHash, expectedHash)
+ }
+
+ // Verify token structure
+ components := GetTokenComponents(result.Token)
+ if components.LongToken != result.LongToken {
+ t.Errorf("GenerateAPIKey() token structure invalid: long token mismatch")
+ }
+
+ if components.ShortToken != result.ShortToken {
+ t.Errorf("GenerateAPIKey() token structure invalid: short token mismatch")
+ }
+
+ // Verify token can be validated
+ if !CheckAPIKey(result.Token, result.LongTokenHash) {
+ t.Errorf("GenerateAPIKey() generated token fails validation")
+ }
+ })
+ }
+}
+
+func TestGenerateAPIKeyConsistency(t *testing.T) {
+ opts := &GenerateAPIKeyOptions{
+ KeyPrefix: "test",
+ ShortTokenLength: 8,
+ LongTokenLength: 24,
+ }
+
+ // Generate multiple keys to ensure they're different
+ keys := make([]*APIKey, 10)
+ for i := 0; i < 10; i++ {
+ key, err := GenerateAPIKey(opts)
+ if err != nil {
+ t.Fatalf("GenerateAPIKey() unexpected error: %v", err)
+ }
+ keys[i] = key
+ }
+
+ // Ensure all generated keys are unique
+ for i := 0; i < len(keys); i++ {
+ for j := i + 1; j < len(keys); j++ {
+ if keys[i].Token == keys[j].Token {
+ t.Errorf("GenerateAPIKey() generated duplicate tokens: %v", keys[i].Token)
+ }
+ if keys[i].LongToken == keys[j].LongToken {
+ t.Errorf("GenerateAPIKey() generated duplicate long tokens")
+ }
+ if keys[i].ShortToken == keys[j].ShortToken {
+ t.Errorf("GenerateAPIKey() generated duplicate short tokens")
+ }
+ }
+ }
+}
+
+func TestExtractLongTokenHash(t *testing.T) {
+ token := "test_12345678_abcdefghijklmnopqrstuvwx"
+
+ result := ExtractLongTokenHash(token)
+ expected := HashLongToken("abcdefghijklmnopqrstuvwx")
+
+ if result != expected {
+ t.Errorf("ExtractLongTokenHash(%v) = %v, want %v", token, result, expected)
+ }
+}
diff --git a/go/pkg/zen/middleware_errors.go b/go/pkg/zen/middleware_errors.go
index db848361e5c..dd48a2fbb17 100644
--- a/go/pkg/zen/middleware_errors.go
+++ b/go/pkg/zen/middleware_errors.go
@@ -37,6 +37,7 @@ func WithErrorHandling(logger logging.Logger) Middleware {
case codes.UnkeyDataErrorsKeyNotFound,
codes.UnkeyDataErrorsWorkspaceNotFound,
codes.UnkeyDataErrorsApiNotFound,
+ codes.UnkeyDataErrorsMigrationNotFound,
codes.UnkeyDataErrorsKeySpaceNotFound,
codes.UnkeyDataErrorsPermissionNotFound,
codes.UnkeyDataErrorsRoleNotFound,
diff --git a/internal/db/src/schema/keys.ts b/internal/db/src/schema/keys.ts
index 220d677f3ae..5ddc93f9639 100644
--- a/internal/db/src/schema/keys.ts
+++ b/internal/db/src/schema/keys.ts
@@ -5,7 +5,9 @@ import {
datetime,
index,
int,
+ mysqlEnum,
mysqlTable,
+ primaryKey,
text,
tinyint,
uniqueIndex,
@@ -81,6 +83,8 @@ export const keys = mysqlTable(
* common settings can be configured by the user.
*/
environment: varchar("environment", { length: 256 }),
+
+ pendingMigrationId: varchar("pending_migration_id", { length: 256 }),
},
(table) => ({
hashIndex: uniqueIndex("hash_idx").on(table.hash),
@@ -89,6 +93,7 @@ export const keys = mysqlTable(
table.deletedAtM,
),
forWorkspaceIdIndex: index("idx_keys_on_for_workspace_id").on(table.forWorkspaceId),
+ pendingMigrationIdIndex: index("pending_migration_id_idx").on(table.pendingMigrationId),
workspaceIdIndex: index("idx_keys_on_workspace_id").on(table.workspaceId),
ownerIdIndex: index("owner_id_idx").on(table.ownerId),
identityIdIndex: index("identity_id_idx").on(table.identityId),
@@ -152,3 +157,15 @@ export const encryptedKeysRelations = relations(encryptedKeys, ({ one }) => ({
references: [workspaces.id],
}),
}));
+
+export const keyMigrations = mysqlTable(
+ "key_migrations",
+ {
+ id: varchar("id", { length: 255 }),
+ workspaceId: varchar("workspace_id", { length: 256 }).notNull(),
+ algorithm: mysqlEnum("algorithm", ["sha256", "github.com/seamapi/prefixed-api-key"]).notNull(),
+ },
+ (table) => ({
+ idWorkspacePk: primaryKey({ columns: [table.id, table.workspaceId] }),
+ }),
+);