diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 2edf28d30f..2e2076ab6a 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -166,26 +166,13 @@ type ForbiddenErrorResponse struct { } // IdentitiesCreateIdentityResponseData defines model for IdentitiesCreateIdentityResponseData. -type IdentitiesCreateIdentityResponseData struct { - // IdentityId The unique identifier for this identity in Unkey's system (begins with `id_`). - // - // This ID is generated automatically and used internally by Unkey to reference this identity. While you typically don't need to store this value (your `externalId` is sufficient), it can be useful to record it for: - // - Debugging purposes - // - Advanced API operations - // - Integration with Unkey's analytics - // - // Unlike `externalId` which comes from your system, this ID is guaranteed unique across all Unkey `workspaces`. - IdentityId string `json:"identityId"` -} +type IdentitiesCreateIdentityResponseData = map[string]interface{} // IdentitiesGetIdentityResponseData defines model for IdentitiesGetIdentityResponseData. type IdentitiesGetIdentityResponseData struct { // ExternalId The external identifier for this identity in your system. This is the ID you provided during identity creation. ExternalId string `json:"externalId"` - // Id The unique identifier for this identity in Unkey's system (begins with 'id_'). - Id string `json:"id"` - // Meta Custom metadata associated with this identity. This can include any JSON-serializable data you stored with the identity during creation or updates. Meta *map[string]interface{} `json:"meta,omitempty"` @@ -201,9 +188,6 @@ type IdentitiesUpdateIdentityResponseData struct { // ExternalId The external identifier for this identity in your system. ExternalId string `json:"externalId"` - // Id The unique identifier for this identity in Unkey's system (begins with 'id_'). - Id string `json:"id"` - // Meta Custom metadata associated with this identity after the update. Meta *map[string]interface{} `json:"meta,omitempty"` @@ -216,9 +200,6 @@ type Identity struct { // ExternalId External identity ID ExternalId string `json:"externalId"` - // Id Identity ID - Id string `json:"id"` - // Meta Identity metadata Meta *map[string]interface{} `json:"meta,omitempty"` @@ -310,13 +291,10 @@ type KeysCreateKeyResponseData struct { KeyId string `json:"keyId"` } -// KeysDeleteKeyResponseData Confirms successful key deletion with no additional data returned. -// Deletion immediately invalidates the key in the primary database but cache propagation across regions may take up to 30 seconds. -// During this propagation window, some verification attempts might still succeed in certain regions due to eventual consistency. -// Monitor your application logs during the propagation period to ensure no unexpected authentication successes occur. +// KeysDeleteKeyResponseData Empty response object by design. A successful response indicates the key was deleted successfully. type KeysDeleteKeyResponseData = map[string]interface{} -// KeysUpdateKeyResponseData Empty response object by design. A successful response indicates the key was updated successfully. The endpoint doesn't return the updated key to reduce response size and avoid exposing sensitive information. Changes may take up to 30 seconds to propagate to all regions due to cache invalidation delays. If you need the updated key state, use a subsequent call to `keys.getKey`. +// KeysUpdateKeyResponseData Empty response object by design. A successful response indicates the key was updated successfully. type KeysUpdateKeyResponseData = map[string]interface{} // KeysVerifyKeyCredits Controls credit consumption for usage-based billing and quota enforcement. @@ -861,27 +839,11 @@ type V2IdentitiesCreateIdentityResponseBody struct { // V2IdentitiesDeleteIdentityRequestBody defines model for V2IdentitiesDeleteIdentityRequestBody. type V2IdentitiesDeleteIdentityRequestBody struct { // ExternalId The id of this identity in your system. - // - // This should match the externalId value you used when creating the identity. You can use this field when you know the specific externalId but don't have the Unkey identityId. Only one of externalId or identityId is required. - // + // This should match the externalId value you used when creating the identity. // This identifier typically comes from your authentication system and could be a userId, organizationId, or any other stable unique identifier in your application. - ExternalId *string `json:"externalId,omitempty"` - - // IdentityId The Unkey Identity ID (begins with 'id_'). - // - // This is the internal unique identifier generated by Unkey when the identity was created. Use this when you have the specific Unkey ID and want to ensure you're targeting the exact identity. This is especially useful in automation scripts or when you need to guarantee you're operating on a specific identity regardless of externalId changes. - // - // Only one of externalId or identityId is required. - IdentityId *string `json:"identityId,omitempty"` - union json.RawMessage + ExternalId string `json:"externalId"` } -// V2IdentitiesDeleteIdentityRequestBody0 Identify by external ID from your system -type V2IdentitiesDeleteIdentityRequestBody0 = interface{} - -// V2IdentitiesDeleteIdentityRequestBody1 Identify by Unkey's internal identity ID -type V2IdentitiesDeleteIdentityRequestBody1 = interface{} - // V2IdentitiesDeleteIdentityResponseBody Empty response object. A successful response indicates the identity was deleted successfully. The operation is immediate and permanent - the identity and all its associated data are removed from the system. Any API keys previously associated with this identity remain valid but are no longer linked to this identity. type V2IdentitiesDeleteIdentityResponseBody struct { // 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. @@ -890,20 +852,10 @@ type V2IdentitiesDeleteIdentityResponseBody struct { // V2IdentitiesGetIdentityRequestBody defines model for V2IdentitiesGetIdentityRequestBody. type V2IdentitiesGetIdentityRequestBody struct { - // ExternalId The external ID of the identity to retrieve. This is the ID from your own system that was used during identity creation. Use either identityId or externalId to specify which identity to fetch. If both are provided, identityId takes precedence. - ExternalId *string `json:"externalId,omitempty"` - - // IdentityId The Unkey identity ID to retrieve (begins with 'id_'). Use either identityId or externalId to specify which identity to fetch. If both are provided, identityId takes precedence. - IdentityId *string `json:"identityId,omitempty"` - union json.RawMessage + // ExternalId The external ID of the identity to retrieve. This is the ID from your own system that was used during identity creation. + ExternalId string `json:"externalId"` } -// V2IdentitiesGetIdentityRequestBody0 defines model for . -type V2IdentitiesGetIdentityRequestBody0 = interface{} - -// V2IdentitiesGetIdentityRequestBody1 defines model for . -type V2IdentitiesGetIdentityRequestBody1 = interface{} - // V2IdentitiesGetIdentityResponseBody defines model for V2IdentitiesGetIdentityResponseBody. type V2IdentitiesGetIdentityResponseBody struct { Data IdentitiesGetIdentityResponseData `json:"data"` @@ -934,17 +886,9 @@ type V2IdentitiesListIdentitiesResponseBody struct { // V2IdentitiesUpdateIdentityRequestBody defines model for V2IdentitiesUpdateIdentityRequestBody. type V2IdentitiesUpdateIdentityRequestBody struct { // ExternalId Specifies which identity to update using your system's identifier from identity creation. - // Ignored when identityId is also provided in the same request. // Use this when you track identities by your own user IDs, organization IDs, or tenant identifiers. - // More convenient than identityId when integrating with existing user management systems. // Accepts letters, numbers, underscores, dots, and hyphens for flexible identifier formats. - ExternalId *string `json:"externalId,omitempty"` - - // IdentityId Specifies which identity to update using the Unkey database identifier. - // Takes precedence over externalId when both are provided in the same request. - // Use this when you have stored the Unkey identity ID from previous API calls. - // Essential for direct identity management when you track Unkey's internal identifiers. - IdentityId *string `json:"identityId,omitempty"` + ExternalId string `json:"externalId"` // Meta Replaces all existing metadata with this new metadata object. // Omitting this field preserves existing metadata, while providing an empty object clears all metadata. @@ -957,15 +901,8 @@ type V2IdentitiesUpdateIdentityRequestBody struct { // These limits are shared across all keys belonging to this identity, preventing abuse through multiple keys. // Rate limit changes take effect immediately but may take up to 30 seconds to propagate across all regions. Ratelimits *[]RatelimitRequest `json:"ratelimits,omitempty"` - union json.RawMessage } -// V2IdentitiesUpdateIdentityRequestBody0 defines model for . -type V2IdentitiesUpdateIdentityRequestBody0 = interface{} - -// V2IdentitiesUpdateIdentityRequestBody1 defines model for . -type V2IdentitiesUpdateIdentityRequestBody1 = interface{} - // V2IdentitiesUpdateIdentityResponseBody defines model for V2IdentitiesUpdateIdentityResponseBody. type V2IdentitiesUpdateIdentityResponseBody struct { Data IdentitiesUpdateIdentityResponseData `json:"data"` @@ -1234,10 +1171,7 @@ type V2KeysDeleteKeyRequestBody struct { // V2KeysDeleteKeyResponseBody defines model for V2KeysDeleteKeyResponseBody. type V2KeysDeleteKeyResponseBody struct { - // Data Confirms successful key deletion with no additional data returned. - // Deletion immediately invalidates the key in the primary database but cache propagation across regions may take up to 30 seconds. - // During this propagation window, some verification attempts might still succeed in certain regions due to eventual consistency. - // Monitor your application logs during the propagation period to ensure no unexpected authentication successes occur. + // Data Empty response object by design. A successful response indicates the key was deleted successfully. Data *KeysDeleteKeyResponseData `json:"data,omitempty"` // 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. @@ -1679,7 +1613,7 @@ type V2KeysUpdateKeyRequestBody struct { // V2KeysUpdateKeyResponseBody defines model for V2KeysUpdateKeyResponseBody. type V2KeysUpdateKeyResponseBody struct { - // Data Empty response object by design. A successful response indicates the key was updated successfully. The endpoint doesn't return the updated key to reduce response size and avoid exposing sensitive information. Changes may take up to 30 seconds to propagate to all regions due to cache invalidation delays. If you need the updated key state, use a subsequent call to `keys.getKey`. + // Data Empty response object by design. A successful response indicates the key was updated successfully. Data *KeysUpdateKeyResponseData `json:"data,omitempty"` // 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. @@ -2342,364 +2276,6 @@ type RatelimitListOverridesJSONRequestBody = V2RatelimitListOverridesRequestBody // RatelimitSetOverrideJSONRequestBody defines body for RatelimitSetOverride for application/json ContentType. type RatelimitSetOverrideJSONRequestBody = V2RatelimitSetOverrideRequestBody -// AsV2IdentitiesDeleteIdentityRequestBody0 returns the union data inside the V2IdentitiesDeleteIdentityRequestBody as a V2IdentitiesDeleteIdentityRequestBody0 -func (t V2IdentitiesDeleteIdentityRequestBody) AsV2IdentitiesDeleteIdentityRequestBody0() (V2IdentitiesDeleteIdentityRequestBody0, error) { - var body V2IdentitiesDeleteIdentityRequestBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2IdentitiesDeleteIdentityRequestBody0 overwrites any union data inside the V2IdentitiesDeleteIdentityRequestBody as the provided V2IdentitiesDeleteIdentityRequestBody0 -func (t *V2IdentitiesDeleteIdentityRequestBody) FromV2IdentitiesDeleteIdentityRequestBody0(v V2IdentitiesDeleteIdentityRequestBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2IdentitiesDeleteIdentityRequestBody0 performs a merge with any union data inside the V2IdentitiesDeleteIdentityRequestBody, using the provided V2IdentitiesDeleteIdentityRequestBody0 -func (t *V2IdentitiesDeleteIdentityRequestBody) MergeV2IdentitiesDeleteIdentityRequestBody0(v V2IdentitiesDeleteIdentityRequestBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV2IdentitiesDeleteIdentityRequestBody1 returns the union data inside the V2IdentitiesDeleteIdentityRequestBody as a V2IdentitiesDeleteIdentityRequestBody1 -func (t V2IdentitiesDeleteIdentityRequestBody) AsV2IdentitiesDeleteIdentityRequestBody1() (V2IdentitiesDeleteIdentityRequestBody1, error) { - var body V2IdentitiesDeleteIdentityRequestBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2IdentitiesDeleteIdentityRequestBody1 overwrites any union data inside the V2IdentitiesDeleteIdentityRequestBody as the provided V2IdentitiesDeleteIdentityRequestBody1 -func (t *V2IdentitiesDeleteIdentityRequestBody) FromV2IdentitiesDeleteIdentityRequestBody1(v V2IdentitiesDeleteIdentityRequestBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2IdentitiesDeleteIdentityRequestBody1 performs a merge with any union data inside the V2IdentitiesDeleteIdentityRequestBody, using the provided V2IdentitiesDeleteIdentityRequestBody1 -func (t *V2IdentitiesDeleteIdentityRequestBody) MergeV2IdentitiesDeleteIdentityRequestBody1(v V2IdentitiesDeleteIdentityRequestBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V2IdentitiesDeleteIdentityRequestBody) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - if err != nil { - return nil, err - } - object := make(map[string]json.RawMessage) - if t.union != nil { - err = json.Unmarshal(b, &object) - if err != nil { - return nil, err - } - } - - if t.ExternalId != nil { - object["externalId"], err = json.Marshal(t.ExternalId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'externalId': %w", err) - } - } - - if t.IdentityId != nil { - object["identityId"], err = json.Marshal(t.IdentityId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'identityId': %w", err) - } - } - b, err = json.Marshal(object) - return b, err -} - -func (t *V2IdentitiesDeleteIdentityRequestBody) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - if err != nil { - return err - } - object := make(map[string]json.RawMessage) - err = json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["externalId"]; found { - err = json.Unmarshal(raw, &t.ExternalId) - if err != nil { - return fmt.Errorf("error reading 'externalId': %w", err) - } - } - - if raw, found := object["identityId"]; found { - err = json.Unmarshal(raw, &t.IdentityId) - if err != nil { - return fmt.Errorf("error reading 'identityId': %w", err) - } - } - - return err -} - -// AsV2IdentitiesGetIdentityRequestBody0 returns the union data inside the V2IdentitiesGetIdentityRequestBody as a V2IdentitiesGetIdentityRequestBody0 -func (t V2IdentitiesGetIdentityRequestBody) AsV2IdentitiesGetIdentityRequestBody0() (V2IdentitiesGetIdentityRequestBody0, error) { - var body V2IdentitiesGetIdentityRequestBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2IdentitiesGetIdentityRequestBody0 overwrites any union data inside the V2IdentitiesGetIdentityRequestBody as the provided V2IdentitiesGetIdentityRequestBody0 -func (t *V2IdentitiesGetIdentityRequestBody) FromV2IdentitiesGetIdentityRequestBody0(v V2IdentitiesGetIdentityRequestBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2IdentitiesGetIdentityRequestBody0 performs a merge with any union data inside the V2IdentitiesGetIdentityRequestBody, using the provided V2IdentitiesGetIdentityRequestBody0 -func (t *V2IdentitiesGetIdentityRequestBody) MergeV2IdentitiesGetIdentityRequestBody0(v V2IdentitiesGetIdentityRequestBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV2IdentitiesGetIdentityRequestBody1 returns the union data inside the V2IdentitiesGetIdentityRequestBody as a V2IdentitiesGetIdentityRequestBody1 -func (t V2IdentitiesGetIdentityRequestBody) AsV2IdentitiesGetIdentityRequestBody1() (V2IdentitiesGetIdentityRequestBody1, error) { - var body V2IdentitiesGetIdentityRequestBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2IdentitiesGetIdentityRequestBody1 overwrites any union data inside the V2IdentitiesGetIdentityRequestBody as the provided V2IdentitiesGetIdentityRequestBody1 -func (t *V2IdentitiesGetIdentityRequestBody) FromV2IdentitiesGetIdentityRequestBody1(v V2IdentitiesGetIdentityRequestBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2IdentitiesGetIdentityRequestBody1 performs a merge with any union data inside the V2IdentitiesGetIdentityRequestBody, using the provided V2IdentitiesGetIdentityRequestBody1 -func (t *V2IdentitiesGetIdentityRequestBody) MergeV2IdentitiesGetIdentityRequestBody1(v V2IdentitiesGetIdentityRequestBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V2IdentitiesGetIdentityRequestBody) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - if err != nil { - return nil, err - } - object := make(map[string]json.RawMessage) - if t.union != nil { - err = json.Unmarshal(b, &object) - if err != nil { - return nil, err - } - } - - if t.ExternalId != nil { - object["externalId"], err = json.Marshal(t.ExternalId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'externalId': %w", err) - } - } - - if t.IdentityId != nil { - object["identityId"], err = json.Marshal(t.IdentityId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'identityId': %w", err) - } - } - b, err = json.Marshal(object) - return b, err -} - -func (t *V2IdentitiesGetIdentityRequestBody) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - if err != nil { - return err - } - object := make(map[string]json.RawMessage) - err = json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["externalId"]; found { - err = json.Unmarshal(raw, &t.ExternalId) - if err != nil { - return fmt.Errorf("error reading 'externalId': %w", err) - } - } - - if raw, found := object["identityId"]; found { - err = json.Unmarshal(raw, &t.IdentityId) - if err != nil { - return fmt.Errorf("error reading 'identityId': %w", err) - } - } - - return err -} - -// AsV2IdentitiesUpdateIdentityRequestBody0 returns the union data inside the V2IdentitiesUpdateIdentityRequestBody as a V2IdentitiesUpdateIdentityRequestBody0 -func (t V2IdentitiesUpdateIdentityRequestBody) AsV2IdentitiesUpdateIdentityRequestBody0() (V2IdentitiesUpdateIdentityRequestBody0, error) { - var body V2IdentitiesUpdateIdentityRequestBody0 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2IdentitiesUpdateIdentityRequestBody0 overwrites any union data inside the V2IdentitiesUpdateIdentityRequestBody as the provided V2IdentitiesUpdateIdentityRequestBody0 -func (t *V2IdentitiesUpdateIdentityRequestBody) FromV2IdentitiesUpdateIdentityRequestBody0(v V2IdentitiesUpdateIdentityRequestBody0) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2IdentitiesUpdateIdentityRequestBody0 performs a merge with any union data inside the V2IdentitiesUpdateIdentityRequestBody, using the provided V2IdentitiesUpdateIdentityRequestBody0 -func (t *V2IdentitiesUpdateIdentityRequestBody) MergeV2IdentitiesUpdateIdentityRequestBody0(v V2IdentitiesUpdateIdentityRequestBody0) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -// AsV2IdentitiesUpdateIdentityRequestBody1 returns the union data inside the V2IdentitiesUpdateIdentityRequestBody as a V2IdentitiesUpdateIdentityRequestBody1 -func (t V2IdentitiesUpdateIdentityRequestBody) AsV2IdentitiesUpdateIdentityRequestBody1() (V2IdentitiesUpdateIdentityRequestBody1, error) { - var body V2IdentitiesUpdateIdentityRequestBody1 - err := json.Unmarshal(t.union, &body) - return body, err -} - -// FromV2IdentitiesUpdateIdentityRequestBody1 overwrites any union data inside the V2IdentitiesUpdateIdentityRequestBody as the provided V2IdentitiesUpdateIdentityRequestBody1 -func (t *V2IdentitiesUpdateIdentityRequestBody) FromV2IdentitiesUpdateIdentityRequestBody1(v V2IdentitiesUpdateIdentityRequestBody1) error { - b, err := json.Marshal(v) - t.union = b - return err -} - -// MergeV2IdentitiesUpdateIdentityRequestBody1 performs a merge with any union data inside the V2IdentitiesUpdateIdentityRequestBody, using the provided V2IdentitiesUpdateIdentityRequestBody1 -func (t *V2IdentitiesUpdateIdentityRequestBody) MergeV2IdentitiesUpdateIdentityRequestBody1(v V2IdentitiesUpdateIdentityRequestBody1) error { - b, err := json.Marshal(v) - if err != nil { - return err - } - - merged, err := runtime.JSONMerge(t.union, b) - t.union = merged - return err -} - -func (t V2IdentitiesUpdateIdentityRequestBody) MarshalJSON() ([]byte, error) { - b, err := t.union.MarshalJSON() - if err != nil { - return nil, err - } - object := make(map[string]json.RawMessage) - if t.union != nil { - err = json.Unmarshal(b, &object) - if err != nil { - return nil, err - } - } - - if t.ExternalId != nil { - object["externalId"], err = json.Marshal(t.ExternalId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'externalId': %w", err) - } - } - - if t.IdentityId != nil { - object["identityId"], err = json.Marshal(t.IdentityId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'identityId': %w", err) - } - } - - if t.Meta != nil { - object["meta"], err = json.Marshal(t.Meta) - if err != nil { - return nil, fmt.Errorf("error marshaling 'meta': %w", err) - } - } - - if t.Ratelimits != nil { - object["ratelimits"], err = json.Marshal(t.Ratelimits) - if err != nil { - return nil, fmt.Errorf("error marshaling 'ratelimits': %w", err) - } - } - b, err = json.Marshal(object) - return b, err -} - -func (t *V2IdentitiesUpdateIdentityRequestBody) UnmarshalJSON(b []byte) error { - err := t.union.UnmarshalJSON(b) - if err != nil { - return err - } - object := make(map[string]json.RawMessage) - err = json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["externalId"]; found { - err = json.Unmarshal(raw, &t.ExternalId) - if err != nil { - return fmt.Errorf("error reading 'externalId': %w", err) - } - } - - if raw, found := object["identityId"]; found { - err = json.Unmarshal(raw, &t.IdentityId) - if err != nil { - return fmt.Errorf("error reading 'identityId': %w", err) - } - } - - if raw, found := object["meta"]; found { - err = json.Unmarshal(raw, &t.Meta) - if err != nil { - return fmt.Errorf("error reading 'meta': %w", err) - } - } - - if raw, found := object["ratelimits"]; found { - err = json.Unmarshal(raw, &t.Ratelimits) - if err != nil { - return fmt.Errorf("error reading 'ratelimits': %w", err) - } - } - - return err -} - // AsV2KeysGetKeyRequestBody0 returns the union data inside the V2KeysGetKeyRequestBody as a V2KeysGetKeyRequestBody0 func (t V2KeysGetKeyRequestBody) AsV2KeysGetKeyRequestBody0() (V2KeysGetKeyRequestBody0, error) { var body V2KeysGetKeyRequestBody0 diff --git a/go/apps/api/openapi/openapi.yaml b/go/apps/api/openapi/openapi.yaml index 495c74762f..d27780d793 100644 --- a/go/apps/api/openapi/openapi.yaml +++ b/go/apps/api/openapi/openapi.yaml @@ -201,8 +201,8 @@ components: Cannot configure refill settings when credits is null, and refillDay requires monthly interval. Essential for implementing usage-based pricing and subscription quotas. ratelimits: - type: array nullable: true + type: array maxItems: 50 # Reasonable limit for rate limit configurations per key items: "$ref": "#/components/schemas/RatelimitRequest" @@ -223,12 +223,8 @@ components: KeysUpdateKeyResponseData: type: object properties: {} - description: - Empty response object by design. A successful response indicates - the key was updated successfully. The endpoint doesn't return the updated - key to reduce response size and avoid exposing sensitive information. Changes - may take up to 30 seconds to propagate to all regions due to cache invalidation - delays. If you need the updated key state, use a subsequent call to `keys.getKey`. + additionalProperties: false + description: Empty response object by design. A successful response indicates the key was updated successfully. V2KeysUpdateKeyResponseBody: type: object required: @@ -270,11 +266,8 @@ components: KeysDeleteKeyResponseData: type: object properties: {} - description: | - Confirms successful key deletion with no additional data returned. - Deletion immediately invalidates the key in the primary database but cache propagation across regions may take up to 30 seconds. - During this propagation window, some verification attempts might still succeed in certain regions due to eventual consistency. - Monitor your application logs during the propagation period to ensure no unexpected authentication successes occur. + additionalProperties: false + description: Empty response object by design. A successful response indicates the key was deleted successfully. V2KeysDeleteKeyResponseBody: type: object required: @@ -318,7 +311,6 @@ components: Never log, cache, or store API keys in your system as they provide full access to user resources. Include the full key exactly as provided - even minor modifications will cause a not found error. example: prefix_f4cc2d765275c206b7d76ff0e92e583067c4e33603fb4055d7ba3031cd7ce36a - additionalProperties: false oneOf: - required: @@ -1198,7 +1190,6 @@ components: - name: heavy_operations limit: 10 duration: 3600000 - async: false enabled: type: boolean default: true @@ -1558,9 +1549,6 @@ components: Identity: type: object properties: - id: - type: string - description: Identity ID externalId: type: string description: External identity ID @@ -1573,7 +1561,6 @@ components: "$ref": "#/components/schemas/RatelimitResponse" description: Identity ratelimits required: - - id - externalId - ratelimits KeyCreditsData: @@ -2308,20 +2295,6 @@ components: default: false IdentitiesCreateIdentityResponseData: type: object - properties: - identityId: - description: |- - The unique identifier for this identity in Unkey's system (begins with `id_`). - - This ID is generated automatically and used internally by Unkey to reference this identity. While you typically don't need to store this value (your `externalId` is sufficient), it can be useful to record it for: - - Debugging purposes - - Advanced API operations - - Integration with Unkey's analytics - - Unlike `externalId` which comes from your system, this ID is guaranteed unique across all Unkey `workspaces`. - type: string - required: - - identityId V2IdentitiesCreateIdentityResponseBody: type: object required: @@ -2335,41 +2308,21 @@ components: V2IdentitiesGetIdentityRequestBody: type: object properties: - identityId: - type: string - minLength: 3 - description: - The Unkey identity ID to retrieve (begins with 'id_'). Use - either identityId or externalId to specify which identity to fetch. If - both are provided, identityId takes precedence. - example: id_1234567890abcdef externalId: type: string minLength: 1 description: The external ID of the identity to retrieve. This is the ID - from your own system that was used during identity creation. Use either - identityId or externalId to specify which identity to fetch. If both are - provided, identityId takes precedence. + from your own system that was used during identity creation. example: user_abc123 additionalProperties: false - oneOf: - - required: - - identityId - - required: - - externalId + required: + - externalId IdentitiesGetIdentityResponseData: type: object required: - - id - externalId properties: - id: - type: string - description: - The unique identifier for this identity in Unkey's system (begins - with 'id_'). - example: id_1234567890abcdef externalId: type: string description: @@ -2451,28 +2404,11 @@ components: minLength: 3 description: | The id of this identity in your system. - - This should match the externalId value you used when creating the identity. You can use this field when you know the specific externalId but don't have the Unkey identityId. Only one of externalId or identityId is required. - + This should match the externalId value you used when creating the identity. This identifier typically comes from your authentication system and could be a userId, organizationId, or any other stable unique identifier in your application. example: user_123 - identityId: - type: string - minLength: 3 - description: |- - The Unkey Identity ID (begins with 'id_'). - - This is the internal unique identifier generated by Unkey when the identity was created. Use this when you have the specific Unkey ID and want to ensure you're targeting the exact identity. This is especially useful in automation scripts or when you need to guarantee you're operating on a specific identity regardless of externalId changes. - - Only one of externalId or identityId is required. - example: id_123 - oneOf: - - required: - - externalId - description: Identify by external ID from your system - - required: - - identityId - description: Identify by Unkey's internal identity ID + required: + - externalId V2IdentitiesDeleteIdentityResponseBody: type: object description: @@ -3644,17 +3580,6 @@ components: V2IdentitiesUpdateIdentityRequestBody: type: object properties: - identityId: - type: string - minLength: 3 - maxLength: 255 # Reasonable upper bound for database identifiers - pattern: "^[a-zA-Z0-9_]+$" - description: | - Specifies which identity to update using the Unkey database identifier. - Takes precedence over externalId when both are provided in the same request. - Use this when you have stored the Unkey identity ID from previous API calls. - Essential for direct identity management when you track Unkey's internal identifiers. - example: id_01H9TQPP77V5E48E9SH0BG0ZQX externalId: type: string minLength: 3 @@ -3662,9 +3587,7 @@ components: pattern: "^[a-zA-Z0-9_.-]+$" description: | Specifies which identity to update using your system's identifier from identity creation. - Ignored when identityId is also provided in the same request. Use this when you track identities by your own user IDs, organization IDs, or tenant identifiers. - More convenient than identityId when integrating with existing user management systems. Accepts letters, numbers, underscores, dots, and hyphens for flexible identifier formats. example: user_abc123 meta: @@ -3695,23 +3618,13 @@ components: limit: 1000 duration: 3600000 additionalProperties: false - anyOf: - - required: - - identityId - - required: - - externalId + required: + - externalId IdentitiesUpdateIdentityResponseData: type: object required: - - id - externalId properties: - id: - type: string - description: - The unique identifier for this identity in Unkey's system (begins - with 'id_'). - example: id_01H9TQPP77V5E48E9SH0BG0ZQX externalId: type: string description: The external identifier for this identity in your system. @@ -4804,7 +4717,7 @@ paths: "404": description: Not found - The specified identity doesn't exist. This error - occurs when no identity matches the provided externalId or identityId. + occurs when no identity matches the provided externalId. This might happen if the identity was already deleted, never existed, or if there's a typo in the identifier. Verify the identity exists before attempting deletion. @@ -4910,8 +4823,7 @@ paths: description: Bad request - Invalid or missing parameters. This error occurs when the request body is malformed, missing required fields, or contains - invalid values. Verify that you've provided either a valid externalId - or identityId, and that the format of each field meets the requirements. + invalid values. Verify that you've provided a valid externalId, and that the format of each field meets the requirements. content: application/json: schema: @@ -6088,8 +6000,6 @@ paths: value: meta: requestId: req_01H9TQPP77V5E48E9SH0BG0ZQX - data: - identityId: id_01H9TQP8NP8JN3X8HWSKPW43JE description: OK "400": description: Bad request @@ -6133,20 +6043,18 @@ paths: - identities summary: Retrieve identity information description: |- - Retrieves detailed information about a specific identity by its Unkey ID or external ID. + Retrieves detailed information about a specific identity by its external ID. Identities in Unkey represent entities in your system (users, organizations, etc.) that can be associated with multiple API keys. This endpoint provides access to: - Identity metadata that was stored during creation - Rate limiting configurations that apply across all keys for this identity - - Mapping between your system's external IDs and Unkey's internal IDs This endpoint is useful for: - Checking if an identity exists - Retrieving metadata associated with an identity - Viewing rate limit configurations - - Finding the Unkey ID for an identity when you only have the external ID - You must provide either the identityId or externalId parameter to identify which identity to retrieve. + You must provide the externalId parameter to identify which identity to retrieve. operationId: identities.getIdentity x-speakeasy-name-override: getIdentity security: @@ -6157,10 +6065,6 @@ paths: schema: "$ref": "#/components/schemas/V2IdentitiesGetIdentityRequestBody" examples: - byIdentityId: - summary: Retrieve by Unkey identity ID - value: - identityId: id_1234567890abcdef byExternalId: summary: Retrieve by external ID value: @@ -6195,8 +6099,7 @@ paths: limit: 100 duration: 3600000 "400": - description: - Bad request - Missing both identityId and externalId, or other + description: Bad request - Missing the externalId, or other validation issues content: application/json: @@ -6369,11 +6272,6 @@ paths: description: Deleting using your system's identifier value: externalId: user_abc123 - byIdentityId: - summary: Delete by Unkey identity ID - description: Deleting using Unkey's internal identifier - value: - identityId: id_01H9TQP8NP8JN3X8HWSKPW43JE required: true responses: "200": @@ -7440,7 +7338,7 @@ paths: updateMetadata: summary: Update identity metadata only value: - identityId: id_01H9TQPP77V5E48E9SH0BG0ZQX + externalId: user_123 meta: name: Alice Smith email: alice.updated@example.com diff --git a/go/apps/api/routes/v2_apis_create_api/handler.go b/go/apps/api/routes/v2_apis_create_api/handler.go index 780911cf5a..9b411a3c74 100644 --- a/go/apps/api/routes/v2_apis_create_api/handler.go +++ b/go/apps/api/routes/v2_apis_create_api/handler.go @@ -44,12 +44,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - var req Request - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( diff --git a/go/apps/api/routes/v2_apis_delete_api/handler.go b/go/apps/api/routes/v2_apis_delete_api/handler.go index a12fdc042f..8075053e69 100644 --- a/go/apps/api/routes/v2_apis_delete_api/handler.go +++ b/go/apps/api/routes/v2_apis_delete_api/handler.go @@ -50,14 +50,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - var req Request - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Api, diff --git a/go/apps/api/routes/v2_apis_get_api/handler.go b/go/apps/api/routes/v2_apis_get_api/handler.go index bdee59bfe4..910b9a9131 100644 --- a/go/apps/api/routes/v2_apis_get_api/handler.go +++ b/go/apps/api/routes/v2_apis_get_api/handler.go @@ -43,12 +43,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - var req Request - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( diff --git a/go/apps/api/routes/v2_apis_list_keys/200_test.go b/go/apps/api/routes/v2_apis_list_keys/200_test.go index 617cfa707e..a1d4fe9626 100644 --- a/go/apps/api/routes/v2_apis_list_keys/200_test.go +++ b/go/apps/api/routes/v2_apis_list_keys/200_test.go @@ -319,7 +319,6 @@ func TestSuccess(t *testing.T) { for _, key := range res.Body.Data { require.NotNil(t, key.Identity) require.Equal(t, identity1ExternalID, key.Identity.ExternalId) - require.Equal(t, identity1ID, key.Identity.Id) } }) @@ -394,7 +393,6 @@ func TestSuccess(t *testing.T) { key := res.Body.Data[0] require.NotNil(t, key.Identity) - require.Equal(t, identity1ID, key.Identity.Id) require.Equal(t, identity1ExternalID, key.Identity.ExternalId) require.NotNil(t, key.Identity.Meta) diff --git a/go/apps/api/routes/v2_apis_list_keys/handler.go b/go/apps/api/routes/v2_apis_list_keys/handler.go index 0ae8760050..b5379abd92 100644 --- a/go/apps/api/routes/v2_apis_list_keys/handler.go +++ b/go/apps/api/routes/v2_apis_list_keys/handler.go @@ -50,14 +50,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - var req Request - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.And( rbac.Or( @@ -399,7 +395,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if key.IdentityID.Valid { k.Identity = &openapi.Identity{ ExternalId: key.ExternalID.String, - Id: key.IdentityID.String, Meta: nil, Ratelimits: nil, } diff --git a/go/apps/api/routes/v2_identities_create_identity/200_test.go b/go/apps/api/routes/v2_identities_create_identity/200_test.go index c91a986860..11c01b61a8 100644 --- a/go/apps/api/routes/v2_identities_create_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_create_identity/200_test.go @@ -105,11 +105,11 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) @@ -127,11 +127,11 @@ func TestCreateIdentitySuccessfully(t *testing.T) { res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) @@ -168,16 +168,16 @@ func TestCreateIdentitySuccessfully(t *testing.T) { res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) - rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: res.Body.Data.IdentityId, Valid: true}) + rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identity.ID, Valid: true}) require.NoError(t, err) require.Len(t, rateLimits, len(identityRateLimits)) @@ -227,12 +227,11 @@ func TestCreateIdentitySuccessfully(t *testing.T) { res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) - // Verify identity in database - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) @@ -244,7 +243,7 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, *meta, dbMeta) // Verify rate limits - rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: res.Body.Data.IdentityId, Valid: true}) + rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identity.ID, Valid: true}) require.NoError(t, err) require.Len(t, rateLimits, len(identityRateLimits)) @@ -304,12 +303,11 @@ func TestCreateIdentitySuccessfully(t *testing.T) { res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) - // Verify identity in database - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) @@ -335,25 +333,24 @@ func TestCreateIdentitySuccessfully(t *testing.T) { uid.New("test_external_id_2"), uid.New("test_external_id_3"), } - identityIDs := make([]string, len(externalIDs)) + identityIDs := make([]string, 0) - for i, externalID := range externalIDs { + for _, externalID := range externalIDs { req := handler.Request{ExternalId: externalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200 for externalId %s", externalID) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) - - identityIDs[i] = res.Body.Data.IdentityId } // Verify each identity was created with the correct externalId - for i, identityID := range identityIDs { - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: identityID, - Deleted: false, + for i, externalID := range externalIDs { + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Deleted: false, }) + identityIDs = append(identityIDs, identity.ID) require.NoError(t, err) require.Equal(t, externalIDs[i], identity.ExternalID) } @@ -363,6 +360,7 @@ func TestCreateIdentitySuccessfully(t *testing.T) { for _, id := range identityIDs { idMap[id] = true } + require.Len(t, idMap, len(identityIDs), "Identity IDs should be unique") }) @@ -375,12 +373,12 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) // Verify in database - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) require.Equal(t, externalTestID, identity.ExternalID) @@ -415,12 +413,12 @@ func TestCreateIdentitySuccessfully(t *testing.T) { res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Data.IdentityId) // Verify in database - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: res.Body.Data.IdentityId, - Deleted: false, + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalTestID, + Deleted: false, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_identities_create_identity/409_test.go b/go/apps/api/routes/v2_identities_create_identity/409_test.go index 3bb01a4f71..d4694f8651 100644 --- a/go/apps/api/routes/v2_identities_create_identity/409_test.go +++ b/go/apps/api/routes/v2_identities_create_identity/409_test.go @@ -36,7 +36,6 @@ func TestCreateIdentityDuplicate(t *testing.T) { successRes := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, successRes.Status, "expected 200, received: %#v", successRes.Body) require.NotNil(t, successRes.Body) - require.NotEmpty(t, successRes.Body.Data.IdentityId, successRes.Body) errorRes := testutil.CallRoute[handler.Request, openapi.ConflictErrorResponse](h, route, headers, req) require.Equal(t, 409, errorRes.Status, "expected 409, received: %s", errorRes.RawBody) diff --git a/go/apps/api/routes/v2_identities_create_identity/handler.go b/go/apps/api/routes/v2_identities_create_identity/handler.go index ecfa09533e..92c6bf19ed 100644 --- a/go/apps/api/routes/v2_identities_create_identity/handler.go +++ b/go/apps/api/routes/v2_identities_create_identity/handler.go @@ -56,13 +56,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // nolint:exhaustruct - req := Request{} - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( @@ -97,7 +93,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { meta = rawMeta } - identityID, err := db.TxWithResult(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) (string, error) { + err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { identityID := uid.New(uid.IdentityPrefix) args := db.InsertIdentityParams{ ID: identityID, @@ -107,17 +103,17 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { CreatedAt: time.Now().UnixMilli(), Meta: meta, } - err = db.Query.InsertIdentity(ctx, tx, args) + err = db.Query.InsertIdentity(ctx, tx, args) if err != nil { if db.IsDuplicateKeyError(err) { - return "", fault.Wrap(err, + return fault.Wrap(err, fault.Code(codes.Data.Identity.Duplicate.URN()), fault.Internal("identity already exists"), fault.Public(fmt.Sprintf("Identity with externalId \"%s\" already exists in this workspace.", req.ExternalId)), ) } - return "", fault.Wrap(err, + return fault.Wrap(err, fault.Internal("unable to create identity"), fault.Public("We're unable to create the identity and its ratelimits."), ) } @@ -196,7 +192,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { rateLimitsToInsert, ) if err != nil { - return "", fault.Wrap(err, + return fault.Wrap(err, fault.Internal("unable to create ratelimit"), fault.Public("We're unable to create a ratelimit for the identity."), ) } @@ -204,10 +200,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = h.Auditlogs.Insert(ctx, tx, auditLogs) if err != nil { - return "", err + return err } - return identityID, nil + return nil }) if err != nil { return err @@ -217,8 +213,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Meta: openapi.Meta{ RequestId: s.RequestID(), }, - Data: openapi.IdentitiesCreateIdentityResponseData{ - IdentityId: identityID, - }, + Data: openapi.IdentitiesCreateIdentityResponseData{}, }) } diff --git a/go/apps/api/routes/v2_identities_delete_identity/200_test.go b/go/apps/api/routes/v2_identities_delete_identity/200_test.go index 4887f8ac75..2dcc01ca09 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/200_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -79,41 +78,6 @@ func TestDeleteIdentitySuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("delete identity by ID", func(t *testing.T) { - testIdentity := createTestIdentity(t, h, 0) - - // Verify identity exists before deletion - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: testIdentity.ID, - Deleted: false, - }) - require.NoError(t, err) - require.Equal(t, testIdentity.ID, identity.ID) - - // Delete the identity via API - req := handler.Request{IdentityId: ptr.P(testIdentity.ID)} - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - - require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) - require.NotNil(t, res.Body) - - // Verify identity is soft deleted (no longer found with deleted=false) - _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: testIdentity.ID, - Deleted: false, - }) - require.Equal(t, sql.ErrNoRows, err, "identity should not be found with deleted=false") - - // Verify identity still exists but marked as deleted - deletedIdentity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: testIdentity.ID, - Deleted: true, - }) - require.NoError(t, err, "identity should still exist with deleted=true") - require.Equal(t, testIdentity.ID, deletedIdentity.ID) - require.True(t, deletedIdentity.Deleted) - }) - t.Run("delete identity by external ID", func(t *testing.T) { testIdentity := createTestIdentity(t, h, 0) @@ -127,7 +91,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.Equal(t, testIdentity.ExternalID, identity.ExternalID) // Delete the identity via API - req := handler.Request{ExternalId: ptr.P(testIdentity.ExternalID)} + req := handler.Request{ExternalId: testIdentity.ExternalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) @@ -162,7 +126,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.Len(t, rateLimits, numberOfRatelimits) // Delete the identity via API - req := handler.Request{IdentityId: ptr.P(testIdentity.ID)} + req := handler.Request{ExternalId: testIdentity.ExternalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) @@ -191,7 +155,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", wildcardKey)}, } - req := handler.Request{IdentityId: ptr.P(testIdentity.ID)} + req := handler.Request{ExternalId: testIdentity.ExternalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, wildcardHeaders, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) @@ -205,35 +169,11 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.Equal(t, sql.ErrNoRows, err) }) - t.Run("delete identity with specific permission", func(t *testing.T) { - testIdentity := createTestIdentity(t, h, 0) - - // Create root key with specific identity permission - specificKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("identity.%s.delete_identity", testIdentity.ID)) - specificHeaders := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", specificKey)}, - } - - req := handler.Request{IdentityId: ptr.P(testIdentity.ID)} - res := testutil.CallRoute[handler.Request, handler.Response](h, route, specificHeaders, req) - - require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) - require.NotNil(t, res.Body) - - // Verify identity is soft deleted - _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: testIdentity.ID, - Deleted: false, - }) - require.Equal(t, sql.ErrNoRows, err) - }) - t.Run("verify audit logs are created", func(t *testing.T) { testIdentity := createTestIdentity(t, h, 2) // Delete the identity - req := handler.Request{IdentityId: ptr.P(testIdentity.ID)} + req := handler.Request{ExternalId: testIdentity.ExternalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) @@ -259,7 +199,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { testIdentity := createTestIdentity(t, h, 0) // Delete the identity once - req := handler.Request{IdentityId: ptr.P(testIdentity.ID)} + req := handler.Request{ExternalId: testIdentity.ExternalID} res1 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res1.Status, "first deletion should succeed") @@ -276,7 +216,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.NoError(t, err) // Delete the new identity (this should trigger duplicate key error handling) - req2 := handler.Request{IdentityId: ptr.P(newIdentityID)} + req2 := handler.Request{ExternalId: testIdentity.ExternalID} res2 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req2) require.Equal(t, 200, res2.Status, "second deletion should succeed despite duplicate key scenario") diff --git a/go/apps/api/routes/v2_identities_delete_identity/400_test.go b/go/apps/api/routes/v2_identities_delete_identity/400_test.go index 6b74abe15e..d75508bd18 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/400_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/400_test.go @@ -9,9 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" - "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" - "github.com/unkeyed/unkey/go/pkg/uid" ) func TestBadRequests(t *testing.T) { @@ -32,7 +30,7 @@ func TestBadRequests(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("missing both identity ID and external ID", func(t *testing.T) { + t.Run("missing externalID", func(t *testing.T) { req := handler.Request{} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) @@ -47,7 +45,7 @@ func TestBadRequests(t *testing.T) { }) t.Run("empty external ID", func(t *testing.T) { - req := handler.Request{ExternalId: ptr.P("")} + req := handler.Request{ExternalId: ""} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -61,7 +59,7 @@ func TestBadRequests(t *testing.T) { }) t.Run("external ID too short", func(t *testing.T) { - req := handler.Request{ExternalId: ptr.P("id")} + req := handler.Request{ExternalId: "id"} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -74,74 +72,9 @@ func TestBadRequests(t *testing.T) { require.Greater(t, len(res.Body.Error.Errors), 0) }) - t.Run("empty identity ID", func(t *testing.T) { - req := handler.Request{IdentityId: ptr.P("")} - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) - require.Equal(t, "POST request body for '/v2/identities.deleteIdentity' failed to validate schema", res.Body.Error.Detail) - require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) - require.Equal(t, "Bad Request", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - require.Greater(t, len(res.Body.Error.Errors), 0) - }) - - t.Run("invalid identity ID format", func(t *testing.T) { - req := handler.Request{IdentityId: ptr.P("invalid_id_format")} - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, 404, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("both identity ID and external ID provided", func(t *testing.T) { - req := handler.Request{ - IdentityId: ptr.P(uid.New(uid.IdentityPrefix)), - ExternalId: ptr.P(uid.New("test_external")), - } - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) - require.Equal(t, "POST request body for '/v2/identities.deleteIdentity' failed to validate schema", res.Body.Error.Detail) - require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) - require.Equal(t, "Bad Request", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - require.Greater(t, len(res.Body.Error.Errors), 0) - }) - - t.Run("invalid JSON body", func(t *testing.T) { - // Skip this test for now as it's difficult to simulate JSON parsing errors - // with the current test infrastructure without exposing raw HTTP methods - t.Skip("Skipping JSON parsing error test - requires raw HTTP request capability") - }) - t.Run("external ID with special characters", func(t *testing.T) { // Test with external ID containing only special characters (handler treats as not found) - req := handler.Request{ExternalId: ptr.P("@#$%")} - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, 404, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("identity ID too long", func(t *testing.T) { - // Test with extremely long identity ID (handler treats as not found) - longID := uid.New(uid.IdentityPrefix) + string(make([]byte, 1000)) - req := handler.Request{IdentityId: ptr.P(longID)} + req := handler.Request{ExternalId: "@#$%"} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, 404, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -156,7 +89,7 @@ func TestBadRequests(t *testing.T) { t.Run("external ID too long", func(t *testing.T) { // Test with extremely long external ID (handler treats as not found) longExternalID := "test_" + string(make([]byte, 1000)) - req := handler.Request{ExternalId: ptr.P(longExternalID)} + req := handler.Request{ExternalId: longExternalID} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, 404, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) diff --git a/go/apps/api/routes/v2_identities_delete_identity/401_test.go b/go/apps/api/routes/v2_identities_delete_identity/401_test.go index 5d3bb8ddfd..c2f21a3198 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/401_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/401_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" - "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -31,7 +30,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { // No Authorization header } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, http.StatusBadRequest, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -49,7 +48,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {"malformed_header"}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, http.StatusBadRequest, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -67,7 +66,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {"Bearer invalid_token"}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -85,7 +84,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {"Bearer "}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, http.StatusBadRequest, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -103,7 +102,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {"Bearer not-a-valid-key-format"}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -121,7 +120,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {"Bearer random_string_not_a_key"}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -144,7 +143,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", differentWorkspaceKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -162,7 +161,7 @@ func TestDeleteIdentityUnauthorized(t *testing.T) { "Authorization": {"Bearer 123"}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) diff --git a/go/apps/api/routes/v2_identities_delete_identity/403_test.go b/go/apps/api/routes/v2_identities_delete_identity/403_test.go index 9f20fb059a..774ff4136b 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/403_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/403_test.go @@ -4,13 +4,10 @@ import ( "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_identities_delete_identity" - "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -34,7 +31,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -53,7 +50,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -72,42 +69,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} - res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/authorization/insufficient_permissions", res.Body.Error.Type) - require.Contains(t, res.Body.Error.Detail, "permission") - require.Equal(t, http.StatusForbidden, res.Body.Error.Status) - require.Equal(t, "Insufficient Permissions", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("specific identity permission for wrong identity", func(t *testing.T) { - // Create a test identity - identityId := uid.New(uid.IdentityPrefix) - err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ - ID: identityId, - ExternalID: "ext_" + identityId, - WorkspaceID: h.Resources().UserWorkspace.ID, - Environment: "default", - CreatedAt: time.Now().UnixMilli(), - Meta: []byte("{}"), - }) - require.NoError(t, err) - - // Create a different identity ID - differentIdentityId := uid.New(uid.IdentityPrefix) - - // Create root key with permission for the different identity - rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("identity.%s.delete_identity", differentIdentityId)) - headers := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, - } - - req := handler.Request{IdentityId: ptr.P(identityId)} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -126,7 +88,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -145,7 +107,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -167,7 +129,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -186,7 +148,7 @@ func TestDeleteIdentityForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + req := handler.Request{ExternalId: uid.New("test")} res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status, "expected 403, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) diff --git a/go/apps/api/routes/v2_identities_delete_identity/404_test.go b/go/apps/api/routes/v2_identities_delete_identity/404_test.go index c6eaf5cd7a..9b6332b431 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/404_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/404_test.go @@ -10,7 +10,6 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -33,23 +32,9 @@ func TestDeleteIdentityNotFound(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("delete identity with non-existent ID", func(t *testing.T) { - nonExistentID := uid.New(uid.IdentityPrefix) - req := handler.Request{IdentityId: ptr.P(nonExistentID)} - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - t.Run("delete identity with non-existent external ID", func(t *testing.T) { nonExistentExternalID := "non_existent_" + uid.New("test") - req := handler.Request{ExternalId: ptr.P(nonExistentExternalID)} + req := handler.Request{ExternalId: nonExistentExternalID} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -61,49 +46,6 @@ func TestDeleteIdentityNotFound(t *testing.T) { require.NotEmpty(t, res.Body.Meta.RequestId) }) - t.Run("delete identity from different workspace (masked as 404)", func(t *testing.T) { - // Create identity in user workspace - identityId := uid.New(uid.IdentityPrefix) - err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ - ID: identityId, - ExternalID: "ext_" + identityId, - WorkspaceID: h.Resources().UserWorkspace.ID, - Environment: "default", - CreatedAt: time.Now().UnixMilli(), - Meta: []byte("{}"), - }) - require.NoError(t, err) - - // Create a different workspace - differentWorkspace := h.CreateWorkspace() - - // Try to delete it using a key from different workspace - differentWorkspaceKey := h.CreateRootKey(differentWorkspace.ID, "identity.*.delete_identity") - differentHeaders := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", differentWorkspaceKey)}, - } - - req := handler.Request{IdentityId: ptr.P(identityId)} - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, differentHeaders, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - - // Verify the identity still exists in the original workspace - identity, err := db.Query.FindIdentityByID(t.Context(), h.DB.RO(), db.FindIdentityByIDParams{ - ID: identityId, - Deleted: false, - }) - require.NoError(t, err) - require.Equal(t, h.Resources().UserWorkspace.ID, identity.WorkspaceID) - }) - t.Run("delete identity with external ID from different workspace", func(t *testing.T) { // Create identity in user workspace identityId := uid.New(uid.IdentityPrefix) @@ -128,7 +70,7 @@ func TestDeleteIdentityNotFound(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", differentWorkspaceKey)}, } - req := handler.Request{ExternalId: ptr.P(externalId)} + req := handler.Request{ExternalId: externalId} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, differentHeaders, req) require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -143,9 +85,10 @@ func TestDeleteIdentityNotFound(t *testing.T) { t.Run("delete already deleted identity", func(t *testing.T) { // Create and soft delete an identity identityId := uid.New(uid.IdentityPrefix) + externalId := "ext_" + identityId err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ ID: identityId, - ExternalID: "ext_" + identityId, + ExternalID: externalId, WorkspaceID: h.Resources().UserWorkspace.ID, Environment: "default", CreatedAt: time.Now().UnixMilli(), @@ -158,51 +101,7 @@ func TestDeleteIdentityNotFound(t *testing.T) { require.NoError(t, err) // Try to delete it again via API - req := handler.Request{IdentityId: ptr.P(identityId)} - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("delete identity with malformed ID prefix", func(t *testing.T) { - // Use wrong prefix for identity ID - wrongPrefixID := uid.New(uid.KeyPrefix) // Using key prefix instead of identity prefix - req := handler.Request{IdentityId: ptr.P(wrongPrefixID)} - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("delete identity with valid ID format but wrong workspace", func(t *testing.T) { - // Create a different workspace - differentWorkspace := h.CreateWorkspace() - - // Create identity in different workspace - identityId := uid.New(uid.IdentityPrefix) - err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ - ID: identityId, - ExternalID: "ext_" + identityId, - WorkspaceID: differentWorkspace.ID, - Environment: "default", - CreatedAt: time.Now().UnixMilli(), - Meta: []byte("{}"), - }) - require.NoError(t, err) - - // Try to delete with key from user workspace - req := handler.Request{IdentityId: ptr.P(identityId)} + req := handler.Request{ExternalId: externalId} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) @@ -216,7 +115,7 @@ func TestDeleteIdentityNotFound(t *testing.T) { t.Run("delete identity using very long non-existent external ID", func(t *testing.T) { longExternalID := "very_long_external_id_that_does_not_exist_" + uid.New("test") + "_" + uid.New("test2") - req := handler.Request{ExternalId: ptr.P(longExternalID)} + req := handler.Request{ExternalId: longExternalID} res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, http.StatusNotFound, res.Status, "expected 404, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) diff --git a/go/apps/api/routes/v2_identities_delete_identity/handler.go b/go/apps/api/routes/v2_identities_delete_identity/handler.go index d9e01dcbf3..02d2ff23b8 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/handler.go +++ b/go/apps/api/routes/v2_identities_delete_identity/handler.go @@ -1,68 +1,3 @@ -// Package handler implements the API endpoint for deleting an identity in the Unkey system. -// -// OVERVIEW: -// This handler implements the POST /v2/identities.deleteIdentity endpoint which allows -// authorized users to delete identities from the system. The deletion is performed as a -// soft delete to maintain data integrity and audit history. -// -// FLOW DIAGRAM: -// -// +----------------+ +-------------+ +----------------+ +--------------+ -// | Verify Key |---->| Check Perms |---->| Get Identity |---->| Begin Tx | -// +----------------+ +-------------+ +----------------+ +--------------+ -// | -// v -// +----------------+ +-------------+ +----------------+ +--------------+ -// | Return 200 |<----| Commit Tx |<----| Create Audits |<----| Soft Delete | -// +----------------+ +-------------+ +----------------+ +--------------+ -// -// DETAILED PROCESS: -// 1. Authentication: Verifies the root key for API access -// 2. Permission Verification: Checks if the key has permission to delete identities -// - Checks for general identity deletion permission (*) -// - Checks for specific identity deletion permission if ID provided -// -// 3. Identity Retrieval: Gets the identity by either: -// -// +---------------+ +-----------------------+ -// | Identity ID |---->| FindIdentityByID | -// +---------------+ +-----------------------+ -// +---------------+ +-----------------------+ -// | External ID |---->| FindIdentityByExtID | -// +---------------+ +-----------------------+ -// -// 4. Soft Deletion Process: -// -// +----------------+ -// | Soft Delete | -// +--------+-------+ -// | -// v -// +---------------------------+ +------------------------+ -// | Duplicate Key Error? |--Yes->| Delete Old Soft- | -// +-----------------+---------+ | Deleted Identity | -// | +----------+-------------+ -// No | -// | | -// v v -// +--------------------------+ +------------------------+ -// | Create Audit Logs |<-----| Retry Soft Delete | -// +---------------------------+ +------------------------+ -// -// 5. Audit Logging: Creates logs for: -// - The deleted identity -// - Any rate limits associated with the identity -// -// 6. Transaction Management: -// - All database operations are wrapped in a transaction -// - Rollback occurs automatically if any operation fails -// - Commit only happens after all operations succeed -// -// ERROR HANDLING: -// - Authentication failures result in auth errors -// - Permission failures result in authorization errors -// - Database errors are wrapped with appropriate error codes and descriptions -// - Not Found errors are returned when identity doesn't exist or belongs to wrong workspace package handler import ( @@ -113,36 +48,31 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } // nolint:exhaustruct - req := Request{} - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) - } - - checks := []rbac.PermissionQuery{ - rbac.T(rbac.Tuple{ - ResourceType: rbac.Identity, - ResourceID: "*", - Action: rbac.DeleteIdentity, - }), - } - - if req.IdentityId != nil { - checks = append(checks, rbac.T(rbac.Tuple{ - ResourceType: rbac.Identity, - ResourceID: *req.IdentityId, - Action: rbac.DeleteIdentity, - })) + return err } - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or(checks...))) + err = auth.Verify(ctx, keys.WithPermissions( + rbac.Or( + rbac.T( + rbac.Tuple{ + ResourceType: rbac.Identity, + ResourceID: "*", + Action: rbac.DeleteIdentity, + }, + ), + ), + )) if err != nil { return err } - identity, err := h.getIdentity(ctx, req, auth.AuthorizedWorkspaceID) + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + ExternalID: req.ExternalId, + Deleted: false, + }) if err != nil { if db.IsNotFound(err) { return fault.New("identity not found", @@ -297,24 +227,3 @@ func deleteOldIdentity(ctx context.Context, tx db.DBTX, workspaceID, externalID return nil } - -func (h *Handler) getIdentity(ctx context.Context, req Request, workspaceID string) (db.Identity, error) { - switch { - case req.IdentityId != nil: - return db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: *req.IdentityId, - Deleted: false, - }) - case req.ExternalId != nil: - return db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ - WorkspaceID: workspaceID, - ExternalID: *req.ExternalId, - Deleted: false, - }) - } - - return db.Identity{}, fault.New("missing identity id or external id", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("missing identity id or external id"), fault.Public("You must provide either an identity ID or external ID."), - ) -} diff --git a/go/apps/api/routes/v2_identities_get_identity/200_test.go b/go/apps/api/routes/v2_identities_get_identity/200_test.go index 4af35d3f35..43f60f0bcc 100644 --- a/go/apps/api/routes/v2_identities_get_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_get_identity/200_test.go @@ -14,7 +14,6 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_get_identity" "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/uid" @@ -51,7 +50,7 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) // Create identity with ratelimits using testutil helper - identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: metaBytes, @@ -72,60 +71,15 @@ func TestSuccess(t *testing.T) { }) // No need to set up permissions since we already gave the key the required permission - - t.Run("get by identityId", func(t *testing.T) { - req := handler.Request{ - IdentityId: &identityID, - } - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - require.Equal(t, 200, res.Status, "expected 200, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - // Verify response - - require.Equal(t, identityID, res.Body.Data.Id) - require.Equal(t, externalID, res.Body.Data.ExternalId) - - // Verify metadata - require.Equal(t, "Test User", (*res.Body.Data.Meta)["name"]) - require.Equal(t, "test@example.com", (*res.Body.Data.Meta)["email"]) - require.Equal(t, "pro", (*res.Body.Data.Meta)["plan"]) - require.Equal(t, float64(100), (*res.Body.Data.Meta)["credits"]) - - // Verify ratelimits - require.Len(t, *res.Body.Data.Ratelimits, 2) - - // Ratelimits can be in any order, so we need to find the specific ones - var apiCallsLimit, specialFeatureLimit *openapi.RatelimitResponse - for i := range *res.Body.Data.Ratelimits { - switch (*res.Body.Data.Ratelimits)[i].Name { - case "api_calls": - apiCallsLimit = &(*res.Body.Data.Ratelimits)[i] - case "special_feature": - specialFeatureLimit = &(*res.Body.Data.Ratelimits)[i] - } - } - - require.NotNil(t, apiCallsLimit, "api_calls ratelimit not found") - require.NotNil(t, specialFeatureLimit, "special_feature ratelimit not found") - - require.Equal(t, int64(100), apiCallsLimit.Limit) - require.Equal(t, int64(60000), apiCallsLimit.Duration) - - require.Equal(t, int64(10), specialFeatureLimit.Limit) - require.Equal(t, int64(3600000), specialFeatureLimit.Duration) - }) - t.Run("get by externalId", func(t *testing.T) { req := handler.Request{ - ExternalId: &externalID, + ExternalId: externalID, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, http.StatusOK, res.Status, "expected 200, sent: %+v, received: %s", req, res.RawBody) require.NotNil(t, res.Body) // Verify response - require.Equal(t, identityID, res.Body.Data.Id) require.Equal(t, externalID, res.Body.Data.ExternalId) // Verify metadata @@ -162,7 +116,7 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) req := handler.Request{ - IdentityId: &identityWithoutMetaID, + ExternalId: externalIDWithoutMeta, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status) @@ -197,7 +151,7 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) req := handler.Request{ - IdentityId: &identityWithoutRatelimitsID, + ExternalId: externalIDWithoutRatelimits, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status) @@ -206,40 +160,6 @@ func TestSuccess(t *testing.T) { require.Empty(t, *res.Body.Data.Ratelimits) }) - t.Run("verify environment field is returned", func(t *testing.T) { - // Create a new identity with a custom environment - customEnvIdentityID := uid.New(uid.IdentityPrefix) - customEnvExternalID := "test_user_custom_env" - customEnvironment := "production" - - tx, err := h.DB.RW().Begin(ctx) - require.NoError(t, err) - defer tx.Rollback() - - err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ - ID: customEnvIdentityID, - ExternalID: customEnvExternalID, - WorkspaceID: h.Resources().UserWorkspace.ID, - - Environment: customEnvironment, - CreatedAt: time.Now().UnixMilli(), - Meta: []byte("{}"), - }) - require.NoError(t, err) - - err = tx.Commit() - require.NoError(t, err) - - req := handler.Request{ - IdentityId: &customEnvIdentityID, - } - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - require.Equal(t, 200, res.Status) - - // Note: Environment field is not returned in the response - // This test verifies that we can retrieve an identity with a custom environment - }) - t.Run("retrieve identity with large metadata", func(t *testing.T) { // Create an identity with large metadata largeMetaIdentityID := uid.New(uid.IdentityPrefix) @@ -318,7 +238,7 @@ func TestSuccess(t *testing.T) { // Retrieve the identity req := handler.Request{ - IdentityId: &largeMetaIdentityID, + ExternalId: largeMetaExternalID, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status) @@ -398,7 +318,7 @@ func TestSuccess(t *testing.T) { // Retrieve the identity req := handler.Request{ - IdentityId: &manyRateLimitsIdentityID, + ExternalId: manyRateLimitsExternalID, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status) @@ -446,111 +366,12 @@ func TestSuccess(t *testing.T) { // Immediately retrieve the identity req := handler.Request{ - IdentityId: &recentIdentityID, + ExternalId: recentExternalID, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status) // Verify it's returned correctly - require.Equal(t, recentIdentityID, res.Body.Data.Id) require.Equal(t, recentExternalID, res.Body.Data.ExternalId) - // Note: CreatedAt is not returned in the response - }) - - t.Run("retrieve identity with associated keys", func(t *testing.T) { - // Create an identity with associated keys - identityWithKeysID := uid.New(uid.IdentityPrefix) - identityWithKeysExternalID := "test_user_with_keys" - - tx, err := h.DB.RW().Begin(ctx) - require.NoError(t, err) - defer tx.Rollback() - - // Insert the identity - err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ - ID: identityWithKeysID, - ExternalID: identityWithKeysExternalID, - WorkspaceID: h.Resources().UserWorkspace.ID, - - Environment: "default", - CreatedAt: time.Now().UnixMilli(), - Meta: []byte("{}"), - }) - require.NoError(t, err) - - // Create keyring for associated keys - keyringID := uid.New(uid.KeyAuthPrefix) - err = db.Query.InsertKeyring(ctx, tx, db.InsertKeyringParams{ - ID: keyringID, - WorkspaceID: h.Resources().UserWorkspace.ID, - CreatedAtM: time.Now().UnixMilli(), - DefaultPrefix: sql.NullString{Valid: true, String: "test_"}, - DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, - StoreEncryptedKeys: false, - }) - require.NoError(t, err) - - // Create API for the keys - apiID := uid.New(uid.APIPrefix) - err = db.Query.InsertApi(ctx, tx, db.InsertApiParams{ - ID: apiID, - Name: "Test API for Identity Keys", - WorkspaceID: h.Resources().UserWorkspace.ID, - - AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, - KeyAuthID: sql.NullString{Valid: true, String: keyringID}, - CreatedAtM: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Create associated keys - key1ID := uid.New(uid.KeyPrefix) - key2ID := uid.New(uid.KeyPrefix) - - // Insert first key - err = db.Query.InsertKey(ctx, tx, db.InsertKeyParams{ - ID: key1ID, - KeyringID: keyringID, - WorkspaceID: h.Resources().UserWorkspace.ID, - - IdentityID: sql.NullString{Valid: true, String: identityWithKeysID}, - CreatedAtM: time.Now().UnixMilli(), - Hash: hash.Sha256(uid.New(uid.TestPrefix)), - Start: "test_key1", - Name: sql.NullString{Valid: true, String: "First Key"}, - }) - require.NoError(t, err) - - // Insert second key - err = db.Query.InsertKey(ctx, tx, db.InsertKeyParams{ - ID: key2ID, - KeyringID: keyringID, - WorkspaceID: h.Resources().UserWorkspace.ID, - - IdentityID: sql.NullString{Valid: true, String: identityWithKeysID}, - CreatedAtM: time.Now().UnixMilli(), - Hash: hash.Sha256(uid.New(uid.TestPrefix)), - Start: "test_key2", - Name: sql.NullString{Valid: true, String: "Second Key"}, - }) - require.NoError(t, err) - - err = tx.Commit() - require.NoError(t, err) - - // Retrieve the identity - req := handler.Request{ - IdentityId: &identityWithKeysID, - } - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - require.Equal(t, 200, res.Status) - - // Verify identity data - require.Equal(t, identityWithKeysID, res.Body.Data.Id) - require.Equal(t, identityWithKeysExternalID, res.Body.Data.ExternalId) - - // Note: The current implementation might not return the associated keys directly - // This test verifies that we can retrieve an identity that has associated keys - // If the implementation is updated to return keys, this test should be updated }) } diff --git a/go/apps/api/routes/v2_identities_get_identity/400_test.go b/go/apps/api/routes/v2_identities_get_identity/400_test.go index f3d917055d..deb225d73e 100644 --- a/go/apps/api/routes/v2_identities_get_identity/400_test.go +++ b/go/apps/api/routes/v2_identities_get_identity/400_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_get_identity" - "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" ) func TestBadRequests(t *testing.T) { @@ -27,7 +27,7 @@ func TestBadRequests(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("missing both identityId and externalId", func(t *testing.T) { + t.Run("missing externalId", func(t *testing.T) { req := handler.Request{} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) @@ -36,23 +36,7 @@ func TestBadRequests(t *testing.T) { require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) require.Equal(t, "POST request body for '/v2/identities.getIdentity' failed to validate schema", res.Body.Error.Detail) require.GreaterOrEqual(t, len(res.Body.Error.Errors), 1) - require.Equal(t, "/oneOf", res.Body.Error.Errors[0].Location) - require.Equal(t, "'oneOf' failed, none matched", res.Body.Error.Errors[0].Message) - require.Equal(t, 400, res.Body.Error.Status) - require.Equal(t, "Bad Request", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("empty identityId", func(t *testing.T) { - req := handler.Request{ - IdentityId: ptr.P(""), - } - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) - require.Equal(t, "POST request body for '/v2/identities.getIdentity' failed to validate schema", res.Body.Error.Detail) + require.Equal(t, "/properties/externalId/minLength", res.Body.Error.Errors[0].Location) require.Equal(t, 400, res.Body.Error.Status) require.Equal(t, "Bad Request", res.Body.Error.Title) require.NotEmpty(t, res.Body.Meta.RequestId) @@ -60,25 +44,7 @@ func TestBadRequests(t *testing.T) { t.Run("empty externalId", func(t *testing.T) { req := handler.Request{ - ExternalId: ptr.P(""), - } - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) - require.Equal(t, "POST request body for '/v2/identities.getIdentity' failed to validate schema", res.Body.Error.Detail) - require.Equal(t, 400, res.Body.Error.Status) - require.Equal(t, "Bad Request", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("both identityId and externalId provided", func(t *testing.T) { - identityId := "id_123456789" - externalId := "external_123" - req := handler.Request{ - IdentityId: &identityId, - ExternalId: &externalId, + ExternalId: "", } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) @@ -93,7 +59,7 @@ func TestBadRequests(t *testing.T) { t.Run("missing Authorization header", func(t *testing.T) { req := handler.Request{ - IdentityId: strPtr("identity_123"), + ExternalId: uid.New(uid.TestPrefix), } // Call without auth header @@ -107,7 +73,7 @@ func TestBadRequests(t *testing.T) { t.Run("malformed Authorization header", func(t *testing.T) { req := handler.Request{ - IdentityId: strPtr("identity_123"), + ExternalId: uid.New(uid.TestPrefix), } headers := http.Header{ diff --git a/go/apps/api/routes/v2_identities_get_identity/401_test.go b/go/apps/api/routes/v2_identities_get_identity/401_test.go index 378f3851fb..807ffb0588 100644 --- a/go/apps/api/routes/v2_identities_get_identity/401_test.go +++ b/go/apps/api/routes/v2_identities_get_identity/401_test.go @@ -10,11 +10,6 @@ import ( "github.com/unkeyed/unkey/go/pkg/testutil" ) -// Helper function for creating string pointers -func strPtr(s string) *string { - return &s -} - func TestUnauthorized(t *testing.T) { h := testutil.NewHarness(t) route := &handler.Handler{ @@ -27,7 +22,7 @@ func TestUnauthorized(t *testing.T) { t.Run("invalid root key", func(t *testing.T) { req := handler.Request{ - IdentityId: strPtr("identity_123"), + ExternalId: "identity_123", } // Non-existent key diff --git a/go/apps/api/routes/v2_identities_get_identity/403_test.go b/go/apps/api/routes/v2_identities_get_identity/403_test.go index 3c421c0fde..d5cac6cb20 100644 --- a/go/apps/api/routes/v2_identities_get_identity/403_test.go +++ b/go/apps/api/routes/v2_identities_get_identity/403_test.go @@ -72,7 +72,7 @@ func TestForbidden(t *testing.T) { t.Run("no permission to read any identity", func(t *testing.T) { // The rootKey has no permissions, so it should fail req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) require.Equal(t, http.StatusForbidden, res.Status) @@ -80,32 +80,6 @@ func TestForbidden(t *testing.T) { require.Regexp(t, regexp.MustCompile(`^Missing one of these permissions: \[.*\], have: \[.*\]$`), res.Body.Error.Detail) }) - t.Run("permission for specific identity only", func(t *testing.T) { - // Create a root key with permission for only the other identity - specificPermKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "identity."+otherIdentityID+".read_identity") - specificHeaders := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", specificPermKey)}, - } - - // Try to access the first identity, should fail - req := handler.Request{ - IdentityId: &identityID, - } - res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, specificHeaders, req) - require.Equal(t, http.StatusForbidden, res.Status) - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/authorization/insufficient_permissions", res.Body.Error.Type) - require.Regexp(t, regexp.MustCompile(`^Missing one of these permissions: \[.*\], have: \[.*\]$`), res.Body.Error.Detail) - - // Try to access the permitted identity, should succeed - req = handler.Request{ - IdentityId: &otherIdentityID, - } - successRes := testutil.CallRoute[handler.Request, handler.Response](h, route, specificHeaders, req) - require.Equal(t, http.StatusOK, successRes.Status) - require.Equal(t, otherIdentityID, successRes.Body.Data.Id) - }) - t.Run("permission by external ID but not by ID", func(t *testing.T) { // Create a key with specific identity permission specificPermKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "identity."+otherIdentityID+".read_identity") @@ -116,7 +90,7 @@ func TestForbidden(t *testing.T) { // Try to use externalId when only having permission for specific identity IDs req := handler.Request{ - ExternalId: &externalID, + ExternalId: externalID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, specificHeaders, req) require.Equal(t, http.StatusForbidden, res.Status) diff --git a/go/apps/api/routes/v2_identities_get_identity/404_test.go b/go/apps/api/routes/v2_identities_get_identity/404_test.go index 0f9900708c..78e98d8f70 100644 --- a/go/apps/api/routes/v2_identities_get_identity/404_test.go +++ b/go/apps/api/routes/v2_identities_get_identity/404_test.go @@ -31,24 +31,10 @@ func TestNotFound(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("identity ID does not exist", func(t *testing.T) { - nonExistentID := uid.New(uid.IdentityPrefix) - req := handler.Request{ - IdentityId: &nonExistentID, - } - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, got: %d", res.Status) - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "This identity does not exist.", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - t.Run("external ID does not exist", func(t *testing.T) { nonExistentExternalID := "non_existent_external_id" req := handler.Request{ - ExternalId: &nonExistentExternalID, + ExternalId: nonExistentExternalID, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) require.Equal(t, http.StatusNotFound, res.Status, "expected 404, got: %d", res.Status) @@ -87,16 +73,9 @@ func TestNotFound(t *testing.T) { err = tx.Commit() require.NoError(t, err) - // Try to retrieve the deleted identity by ID - reqById := handler.Request{ - IdentityId: &deletedIdentityID, - } - resById := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, reqById) - require.Equal(t, http.StatusNotFound, resById.Status, "expected 404 for deleted identity (by ID)") - // Try to retrieve the deleted identity by externalId reqByExternalId := handler.Request{ - ExternalId: &deletedExternalID, + ExternalId: deletedExternalID, } resByExternalId := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, reqByExternalId) require.Equal(t, http.StatusNotFound, resByExternalId.Status, "expected 404 for deleted identity (by externalId)") diff --git a/go/apps/api/routes/v2_identities_get_identity/handler.go b/go/apps/api/routes/v2_identities_get_identity/handler.go index 620bea05fd..3e080564fa 100644 --- a/go/apps/api/routes/v2_identities_get_identity/handler.go +++ b/go/apps/api/routes/v2_identities_get_identity/handler.go @@ -60,26 +60,11 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { result, err := db.TxWithResult(ctx, h.DB.RO(), func(ctx context.Context, tx db.DBTX) (IdentityResult, error) { var identity db.Identity - // First try to get the identity - if req.IdentityId != nil { - // Find by IdentityId - identity, err = db.Query.FindIdentityByID(ctx, tx, db.FindIdentityByIDParams{ - ID: *req.IdentityId, - Deleted: false, - }) - } else if req.ExternalId != nil { - // Find by ExternalId - identity, err = db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ - ExternalID: *req.ExternalId, - WorkspaceID: auth.AuthorizedWorkspaceID, - Deleted: false, - }) - } else { - return IdentityResult{}, fault.New("invalid request", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("either identityId or externalId must be provided"), fault.Public("Either identityId or externalId must be provided."), - ) - } + identity, err = db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ + ExternalID: req.ExternalId, + WorkspaceID: auth.AuthorizedWorkspaceID, + Deleted: false, + }) if err != nil { if db.IsNotFound(err) { @@ -161,7 +146,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { RequestId: s.RequestID(), }, Data: openapi.IdentitiesGetIdentityResponseData{ - Id: identity.ID, ExternalId: identity.ExternalID, Meta: &metaMap, Ratelimits: &responseRatelimits, diff --git a/go/apps/api/routes/v2_identities_list_identities/200_test.go b/go/apps/api/routes/v2_identities_list_identities/200_test.go index bdb1be890a..bb6cb0970b 100644 --- a/go/apps/api/routes/v2_identities_list_identities/200_test.go +++ b/go/apps/api/routes/v2_identities_list_identities/200_test.go @@ -101,8 +101,8 @@ func TestSuccess(t *testing.T) { // Verify first identity found := false for _, identity := range res.Body.Data { - for i, id := range identityIDs { - if identity.Id == id { + for i, id := range externalIDs { + if identity.ExternalId == id { assert.Equal(t, externalIDs[i], identity.ExternalId) found = true @@ -165,16 +165,16 @@ func TestSuccess(t *testing.T) { require.Greater(t, len(secondRes.Body.Data), 0) // Ensure no overlap between pages - firstPageIDs := make(map[string]bool) + firstPageExternalIDs := make(map[string]bool) for _, identity := range firstRes.Body.Data { - firstPageIDs[identity.Id] = true + firstPageExternalIDs[identity.ExternalId] = true } // Check a sample identity from second page to ensure no overlap if len(secondRes.Body.Data) > 0 { - sampleID := secondRes.Body.Data[0].Id - _, found := firstPageIDs[sampleID] - assert.False(t, found, "identity %s should not appear in both pages", sampleID) + sampleExternalID := secondRes.Body.Data[0].ExternalId + _, found := firstPageExternalIDs[sampleExternalID] + assert.False(t, found, "identity %s should not appear in both pages", sampleExternalID) } }) @@ -213,7 +213,7 @@ func TestSuccess(t *testing.T) { // The deleted identity should not be in the results for _, identity := range res.Body.Data { - require.NotEqual(t, deletedIdentityID, identity.Id, "Deleted identity should not be returned") + require.NotEqual(t, deletedExternalID, identity.ExternalId, "Deleted identity should not be returned") } }) @@ -257,7 +257,7 @@ func TestSuccess(t *testing.T) { // Should find the identity with Unicode var foundUnicode bool for _, identity := range res.Body.Data { - if identity.Id == unicodeIdentityID { + if identity.ExternalId == unicodeExternalID { foundUnicode = true // Verify the Unicode external ID was preserved @@ -324,7 +324,6 @@ func TestSuccess(t *testing.T) { // Should return exactly one identity require.Equal(t, 1, len(res.Body.Data)) - require.Equal(t, singleIdentityID, res.Body.Data[0].Id) require.Equal(t, singleExternalID, res.Body.Data[0].ExternalId) // Pagination should indicate no more results @@ -353,7 +352,6 @@ func TestSuccess(t *testing.T) { identity := res.Body.Data[0] // ID fields should never be empty - require.NotEmpty(t, identity.Id, "Identity ID should not be empty") require.NotEmpty(t, identity.ExternalId, "External ID should not be empty") // Meta might be nil if none set diff --git a/go/apps/api/routes/v2_identities_list_identities/403_test.go b/go/apps/api/routes/v2_identities_list_identities/403_test.go index 3c9395aa6e..463a9bff2c 100644 --- a/go/apps/api/routes/v2_identities_list_identities/403_test.go +++ b/go/apps/api/routes/v2_identities_list_identities/403_test.go @@ -109,7 +109,7 @@ func TestForbidden(t *testing.T) { foundProd := false for _, identity := range res.Body.Data { // Should be able to see production identity - if identity.Id == prodIdentityID { + if identity.ExternalId == "test_user_prod" { foundProd = true } } @@ -141,13 +141,13 @@ func TestForbidden(t *testing.T) { foundStaging := false for _, identity := range res.Body.Data { - if identity.Id == defaultIdentityID { + if identity.ExternalId == "test_user_default" { foundDefault = true } - if identity.Id == prodIdentityID { + if identity.ExternalId == "test_user_prod" { foundProd = true } - if identity.Id == stagingIdentityID { + if identity.ExternalId == "test_user_staging" { foundStaging = true } } diff --git a/go/apps/api/routes/v2_identities_list_identities/cross_workspace_test.go b/go/apps/api/routes/v2_identities_list_identities/cross_workspace_test.go index d50787c784..93df44c665 100644 --- a/go/apps/api/routes/v2_identities_list_identities/cross_workspace_test.go +++ b/go/apps/api/routes/v2_identities_list_identities/cross_workspace_test.go @@ -53,9 +53,10 @@ func TestCrossWorkspaceForbidden(t *testing.T) { // Create an identity in workspace B identityB := uid.New(uid.IdentityPrefix) + externalID := "user_in_workspace_b" err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ ID: identityB, - ExternalID: "user_in_workspace_b", + ExternalID: externalID, WorkspaceID: workspaceB, Environment: "default", CreatedAt: time.Now().UnixMilli(), @@ -78,9 +79,8 @@ func TestCrossWorkspaceForbidden(t *testing.T) { // But we should not see any identities from workspace B in the results for _, identity := range res.Body.Data { - require.NotEqual(t, identity.Id, identityB, "Identity from workspace B should not be accessible with key from workspace A") + require.NotEqual(t, identity.ExternalId, externalID, "Identity from workspace B should not be accessible with key from workspace A") } - }) } diff --git a/go/apps/api/routes/v2_identities_list_identities/handler.go b/go/apps/api/routes/v2_identities_list_identities/handler.go index e0ae65cdee..c4ac367978 100644 --- a/go/apps/api/routes/v2_identities_list_identities/handler.go +++ b/go/apps/api/routes/v2_identities_list_identities/handler.go @@ -124,7 +124,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { // Create a new identity with its ratelimits newIdentity := openapi.Identity{ - Id: identity.ID, ExternalId: identity.ExternalID, Ratelimits: formattedRatelimits, Meta: nil, diff --git a/go/apps/api/routes/v2_identities_update_identity/200_test.go b/go/apps/api/routes/v2_identities_update_identity/200_test.go index 95e8a500d6..988fd244d1 100644 --- a/go/apps/api/routes/v2_identities_update_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_update_identity/200_test.go @@ -37,14 +37,11 @@ func TestSuccess(t *testing.T) { // Setup test data ctx := context.Background() - tx, err := h.DB.RW().Begin(ctx) - require.NoError(t, err) - defer tx.Rollback() workspaceID := h.Resources().UserWorkspace.ID identityID := uid.New(uid.IdentityPrefix) - externalID := "test_user_123" otherIdentityID := uid.New(uid.IdentityPrefix) + externalID := "test_user_123" otherExternalID := "test_user_456" // Create initial metadata @@ -58,7 +55,7 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) // Insert test identities - err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ + err = db.Query.InsertIdentity(ctx, h.DB.RW(), db.InsertIdentityParams{ ID: identityID, ExternalID: externalID, WorkspaceID: workspaceID, @@ -68,7 +65,7 @@ func TestSuccess(t *testing.T) { }) require.NoError(t, err) - err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ + err = db.Query.InsertIdentity(ctx, h.DB.RW(), db.InsertIdentityParams{ ID: otherIdentityID, ExternalID: otherExternalID, WorkspaceID: workspaceID, @@ -80,7 +77,7 @@ func TestSuccess(t *testing.T) { // Insert test ratelimits for the first identity ratelimitID1 := uid.New(uid.RatelimitPrefix) - err = db.Query.InsertIdentityRatelimit(ctx, tx, db.InsertIdentityRatelimitParams{ + err = db.Query.InsertIdentityRatelimit(ctx, h.DB.RW(), db.InsertIdentityRatelimitParams{ ID: ratelimitID1, WorkspaceID: workspaceID, IdentityID: sql.NullString{String: identityID, Valid: true}, @@ -92,7 +89,7 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) ratelimitID2 := uid.New(uid.RatelimitPrefix) - err = db.Query.InsertIdentityRatelimit(ctx, tx, db.InsertIdentityRatelimitParams{ + err = db.Query.InsertIdentityRatelimit(ctx, h.DB.RW(), db.InsertIdentityRatelimitParams{ ID: ratelimitID2, WorkspaceID: workspaceID, IdentityID: sql.NullString{String: identityID, Valid: true}, @@ -103,50 +100,14 @@ func TestSuccess(t *testing.T) { }) require.NoError(t, err) - err = tx.Commit() - require.NoError(t, err) - - t.Run("update metadata by identityId", func(t *testing.T) { - newMeta := map[string]interface{}{ - "name": "Updated User", - "email": "updated@example.com", - "plan": "pro", - "credits": 100, - } - - req := handler.Request{ - IdentityId: &identityID, - Meta: &newMeta, - } - res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) - require.Equal(t, 200, res.Status, "expected 200, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - // Verify response - require.Equal(t, identityID, res.Body.Data.Id) - require.Equal(t, externalID, res.Body.Data.ExternalId) - - // Verify metadata - require.NotNil(t, res.Body.Data.Meta) - meta := *res.Body.Data.Meta - assert.Equal(t, "Updated User", meta["name"]) - assert.Equal(t, "updated@example.com", meta["email"]) - assert.Equal(t, "pro", meta["plan"]) - assert.Equal(t, float64(100), meta["credits"]) - - // Verify ratelimits remain unchanged - require.NotNil(t, res.Body.Data.Ratelimits) - require.Len(t, *res.Body.Data.Ratelimits, 2) - }) - - t.Run("update metadata by externalId", func(t *testing.T) { + t.Run("update metadata", func(t *testing.T) { newMeta := map[string]interface{}{ "joined": "2023-01-01", "active": true, } req := handler.Request{ - ExternalId: &otherExternalID, + ExternalId: otherExternalID, Meta: &newMeta, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) @@ -154,7 +115,6 @@ func TestSuccess(t *testing.T) { require.NotNil(t, res.Body) // Verify response - require.Equal(t, otherIdentityID, res.Body.Data.Id) require.Equal(t, otherExternalID, res.Body.Data.ExternalId) // Verify metadata @@ -173,7 +133,6 @@ func TestSuccess(t *testing.T) { // 1. Update 'api_calls' limit from 100 to 200 // 2. Add a new 'new_feature' limit // 3. Delete 'special_feature' limit (by not including it) - ratelimits := []openapi.RatelimitRequest{ { Name: "api_calls", @@ -190,7 +149,7 @@ func TestSuccess(t *testing.T) { } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Ratelimits: &ratelimits, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) @@ -198,7 +157,7 @@ func TestSuccess(t *testing.T) { require.NotNil(t, res.Body) // Verify response - require.Equal(t, identityID, res.Body.Data.Id) + require.Equal(t, externalID, res.Body.Data.ExternalId) // Verify exactly 2 ratelimits (should have removed 'special_feature') require.NotNil(t, res.Body.Data.Ratelimits) @@ -237,7 +196,7 @@ func TestSuccess(t *testing.T) { emptyRatelimits := []openapi.RatelimitRequest{} req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Ratelimits: &emptyRatelimits, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) @@ -245,7 +204,7 @@ func TestSuccess(t *testing.T) { require.NotNil(t, res.Body) // Verify response - require.Equal(t, identityID, res.Body.Data.Id) + require.Equal(t, externalID, res.Body.Data.ExternalId) // Verify no ratelimits require.NotNil(t, res.Body.Data.Ratelimits) @@ -257,7 +216,7 @@ func TestSuccess(t *testing.T) { emptyMeta := map[string]interface{}{} req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &emptyMeta, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) @@ -265,7 +224,7 @@ func TestSuccess(t *testing.T) { require.NotNil(t, res.Body) // Verify response - require.Equal(t, identityID, res.Body.Data.Id) + require.Equal(t, externalID, res.Body.Data.ExternalId) // Verify empty metadata require.NotNil(t, res.Body.Data.Meta) @@ -288,7 +247,7 @@ func TestSuccess(t *testing.T) { } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &newMeta, Ratelimits: &ratelimits, } diff --git a/go/apps/api/routes/v2_identities_update_identity/400_test.go b/go/apps/api/routes/v2_identities_update_identity/400_test.go index 7de40a76d4..cdf51f8e5a 100644 --- a/go/apps/api/routes/v2_identities_update_identity/400_test.go +++ b/go/apps/api/routes/v2_identities_update_identity/400_test.go @@ -29,7 +29,7 @@ func TestBadRequests(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKeyID)}, } - t.Run("missing both identityId and externalId", func(t *testing.T) { + t.Run("missing externalId", func(t *testing.T) { meta := map[string]interface{}{ "test": "value", } @@ -47,33 +47,13 @@ func TestBadRequests(t *testing.T) { require.NotEmpty(t, res.Body.Meta.RequestId) }) - t.Run("empty identityId", func(t *testing.T) { - emptyStr := "" - meta := map[string]interface{}{ - "test": "value", - } - req := handler.Request{ - IdentityId: &emptyStr, - Meta: &meta, - } - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) - require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) - require.NotNil(t, res.Body) - - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) - require.Equal(t, "POST request body for '/v2/identities.updateIdentity' failed to validate schema", res.Body.Error.Detail) - require.Equal(t, 400, res.Body.Error.Status) - require.Equal(t, "Bad Request", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - t.Run("empty externalId", func(t *testing.T) { - emptyStr := "" meta := map[string]interface{}{ "test": "value", } + req := handler.Request{ - ExternalId: &emptyStr, + ExternalId: "", Meta: &meta, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) @@ -88,7 +68,7 @@ func TestBadRequests(t *testing.T) { }) t.Run("duplicate ratelimit names", func(t *testing.T) { - identityID := "identity_123" + externalID := "identity_123" ratelimits := []openapi.RatelimitRequest{ { Name: "api_calls", @@ -105,7 +85,7 @@ func TestBadRequests(t *testing.T) { } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Ratelimits: &ratelimits, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) @@ -115,7 +95,7 @@ func TestBadRequests(t *testing.T) { }) t.Run("metadata too large", func(t *testing.T) { - identityID := "identity_123" + externalID := "identity_123" // Create a large metadata object (over 1MB) largeString := strings.Repeat("a", 1024*1024) @@ -124,7 +104,7 @@ func TestBadRequests(t *testing.T) { } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &largeMeta, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) diff --git a/go/apps/api/routes/v2_identities_update_identity/401_test.go b/go/apps/api/routes/v2_identities_update_identity/401_test.go index 2b0695b621..d8fe899fb0 100644 --- a/go/apps/api/routes/v2_identities_update_identity/401_test.go +++ b/go/apps/api/routes/v2_identities_update_identity/401_test.go @@ -24,12 +24,12 @@ func TestUnauthorized(t *testing.T) { h.Register(route) t.Run("missing Authorization header", func(t *testing.T) { - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } @@ -44,12 +44,12 @@ func TestUnauthorized(t *testing.T) { }) t.Run("malformed Authorization header", func(t *testing.T) { - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } @@ -64,12 +64,12 @@ func TestUnauthorized(t *testing.T) { }) t.Run("invalid root key", func(t *testing.T) { - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } @@ -84,12 +84,12 @@ func TestUnauthorized(t *testing.T) { }) t.Run("empty bearer token", func(t *testing.T) { - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } @@ -109,12 +109,12 @@ func TestUnauthorized(t *testing.T) { // Create a root key for different workspace differentWorkspaceKey := h.CreateRootKey(differentWorkspace.ID, "identity.*.update_identity") - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } diff --git a/go/apps/api/routes/v2_identities_update_identity/403_test.go b/go/apps/api/routes/v2_identities_update_identity/403_test.go index b51f4b86de..5620d7991b 100644 --- a/go/apps/api/routes/v2_identities_update_identity/403_test.go +++ b/go/apps/api/routes/v2_identities_update_identity/403_test.go @@ -34,12 +34,12 @@ func TestForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKeyID)}, } - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) @@ -56,12 +56,12 @@ func TestForbidden(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKeyID)}, } - identityID := uid.New(uid.IdentityPrefix) + externalID := uid.New(uid.TestPrefix) meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) @@ -105,36 +105,11 @@ func TestForbidden(t *testing.T) { "test": "value", } req := handler.Request{ - IdentityId: &identityID, + ExternalId: externalID, Meta: &meta, } res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, http.StatusOK, res.Status, "expected 200, got: %d, response: %s", res.Status, res.RawBody) - require.Equal(t, identityID, res.Body.Data.Id) - }) - - t.Run("specific identity permission for wrong identity", func(t *testing.T) { - // Create a different identity ID - differentIdentityId := uid.New(uid.IdentityPrefix) - - // Create root key with permission for the different identity - rootKeyID := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("identity.%s.update_identity", differentIdentityId)) - headers := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", rootKeyID)}, - } - - identityID := uid.New(uid.IdentityPrefix) - meta := map[string]interface{}{ - "test": "value", - } - req := handler.Request{ - IdentityId: &identityID, - Meta: &meta, - } - res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusForbidden, res.Status) - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/authorization/insufficient_permissions", res.Body.Error.Type) - require.Contains(t, res.Body.Error.Detail, "permission") + require.Equal(t, externalID, res.Body.Data.ExternalId) }) } diff --git a/go/apps/api/routes/v2_identities_update_identity/404_test.go b/go/apps/api/routes/v2_identities_update_identity/404_test.go index db7ef7b5f6..aec17f64e7 100644 --- a/go/apps/api/routes/v2_identities_update_identity/404_test.go +++ b/go/apps/api/routes/v2_identities_update_identity/404_test.go @@ -10,7 +10,6 @@ import ( handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_update_identity" "github.com/unkeyed/unkey/go/pkg/testutil" - "github.com/unkeyed/unkey/go/pkg/uid" ) func TestNotFound(t *testing.T) { @@ -30,50 +29,13 @@ func TestNotFound(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKeyID)}, } - t.Run("identity ID does not exist", func(t *testing.T) { - nonExistentID := uid.New(uid.IdentityPrefix) - meta := map[string]interface{}{ - "test": "value", - } - req := handler.Request{ - IdentityId: &nonExistentID, - Meta: &meta, - } - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, got: %d", res.Status) - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "Identity not found in this workspace", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - t.Run("external ID does not exist", func(t *testing.T) { nonExistentExternalID := "non_existent_external_id" meta := map[string]interface{}{ "test": "value", } req := handler.Request{ - ExternalId: &nonExistentExternalID, - Meta: &meta, - } - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, got: %d", res.Status) - require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_not_found", res.Body.Error.Type) - require.Equal(t, "Identity not found in this workspace", res.Body.Error.Detail) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - require.Equal(t, "Not Found", res.Body.Error.Title) - require.NotEmpty(t, res.Body.Meta.RequestId) - }) - - t.Run("identity from different workspace", func(t *testing.T) { - // Try to access an identity ID that might exist in different workspace - differentWorkspaceIdentityId := uid.New(uid.IdentityPrefix) - meta := map[string]interface{}{ - "test": "value", - } - req := handler.Request{ - IdentityId: &differentWorkspaceIdentityId, + ExternalId: nonExistentExternalID, Meta: &meta, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) diff --git a/go/apps/api/routes/v2_identities_update_identity/handler.go b/go/apps/api/routes/v2_identities_update_identity/handler.go index 1a40d53317..69fe1b94a9 100644 --- a/go/apps/api/routes/v2_identities_update_identity/handler.go +++ b/go/apps/api/routes/v2_identities_update_identity/handler.go @@ -56,31 +56,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } // Parse request - req := Request{ - ExternalId: nil, - IdentityId: nil, - Meta: nil, - Ratelimits: nil, - } - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) - } - - // Validate that at least one of identityID or externalID is provided - if req.IdentityId == nil && req.ExternalId == nil { - return fault.New("missing required field", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("missing required field"), fault.Public("Provide either identityId or externalId"), - ) - } - - // Check permissions - var identityIdForPermissions string - if req.IdentityId != nil { - identityIdForPermissions = *req.IdentityId + return err } err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( @@ -89,11 +67,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ResourceID: "*", Action: rbac.UpdateIdentity, }), - rbac.T(rbac.Tuple{ - ResourceType: rbac.Identity, - ResourceID: identityIdForPermissions, - Action: rbac.UpdateIdentity, - }), ))) if err != nil { return err @@ -107,7 +80,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return fault.New("duplicate ratelimit name", fault.Code(codes.App.Validation.InvalidInput.URN()), fault.Internal("duplicate ratelimit name"), - fault.Public(fmt.Sprintf("Ratelimit with name \"%s\" is already defined in the request", ratelimit.Name)), + fault.Public(fmt.Sprintf("Ratelimit with name %q is already defined in the request", ratelimit.Name)), ) } nameSet[ratelimit.Name] = true @@ -135,55 +108,46 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - // Get the identity - var identity db.Identity - var existingRatelimits []db.Ratelimit - - if req.IdentityId != nil { - // Find by identity ID - identity, err = db.Query.FindIdentityByID(ctx, tx, db.FindIdentityByIDParams{ - ID: *req.IdentityId, - Deleted: false, - }) - } else { - // Find by external ID - identity, err = db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ - ExternalID: *req.ExternalId, - WorkspaceID: auth.AuthorizedWorkspaceID, - Deleted: false, - }) - } - + identity, err := db.TxWithResult(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) (db.Identity, error) { + // Find by external ID + identity, err := db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ + ExternalID: req.ExternalId, + WorkspaceID: auth.AuthorizedWorkspaceID, + Deleted: false, + }) if err != nil { - if err == sql.ErrNoRows { - return fault.New("identity not found", + if db.IsNotFound(err) { + // nolint:exhaustruct + return db.Identity{}, fault.New("identity not found", fault.Code(codes.Data.Identity.NotFound.URN()), fault.Internal("identity not found"), fault.Public("Identity not found in this workspace"), ) } - return fault.Wrap(err, + + // nolint:exhaustruct + return db.Identity{}, fault.Wrap(err, fault.Internal("unable to find identity"), fault.Public("We're unable to retrieve the identity."), ) } - // Verify workspace if identity.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("identity not found", + // nolint:exhaustruct + return db.Identity{}, fault.New("identity not found", fault.Code(codes.Data.Identity.NotFound.URN()), - fault.Internal("wrong workspace, masking as 404"), fault.Public("Identity not found in this workspace"), + fault.Internal("wrong workspace, masking as 404"), + fault.Public("Identity not found in this workspace"), ) } - // Get existing ratelimits + var existingRatelimits []db.Ratelimit existingRatelimits, err = db.Query.ListIdentityRatelimitsByID(ctx, tx, sql.NullString{String: identity.ID, Valid: true}) - if err != nil && err != sql.ErrNoRows { - return fault.Wrap(err, + if err != nil && !db.IsNotFound(err) { + // nolint:exhaustruct + return db.Identity{}, fault.Wrap(err, fault.Internal("unable to fetch ratelimits"), fault.Public("We're unable to retrieve the identity's ratelimits."), ) } - // Create the base audit log auditLogs := []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, @@ -207,20 +171,20 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }, } - // Update metadata if provided if req.Meta != nil { err = db.Query.UpdateIdentity(ctx, tx, db.UpdateIdentityParams{ ID: identity.ID, Meta: metaBytes, }) + if err != nil { - return fault.Wrap(err, + // nolint:exhaustruct + return db.Identity{}, fault.Wrap(err, fault.Internal("unable to update metadata"), fault.Public("We're unable to update the identity's metadata."), ) } } - // Handle ratelimits if provided if req.Ratelimits != nil { // Process ratelimits changes // 1. Delete ratelimits that no longer exist @@ -283,7 +247,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if len(rateLimitsToDelete) > 0 { err = db.Query.DeleteManyRatelimitsByIDs(ctx, tx, rateLimitsToDelete) if err != nil { - return fault.Wrap(err, + // nolint:exhaustruct + return db.Identity{}, fault.Wrap(err, fault.Internal("unable to delete ratelimits"), fault.Public("We're unable to delete ratelimits."), ) } @@ -392,9 +357,11 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) if err != nil { - return fault.Wrap(err, + // nolint:exhaustruct + return db.Identity{}, fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database failed to insert ratelimits"), fault.Public("Failed to insert ratelimits"), + fault.Internal("database failed to insert ratelimits"), + fault.Public("Failed to insert ratelimits"), ) } } @@ -402,43 +369,24 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = h.Auditlogs.Insert(ctx, tx, auditLogs) if err != nil { - return err + // nolint:exhaustruct + return db.Identity{}, err } - return nil + return identity, nil }) if err != nil { return err } - // Get updated identity with ratelimits - var identity db.Identity - if req.IdentityId != nil { - identity, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ - ID: *req.IdentityId, - Deleted: false, - }) - } else { - identity, err = db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ - ExternalID: *req.ExternalId, - WorkspaceID: auth.AuthorizedWorkspaceID, - Deleted: false, - }) - } - if err != nil { - return fault.Wrap(err, - fault.Internal("unable to get updated identity"), fault.Public("We were able to update the identity but unable to retrieve the updated data."), - ) - } - updatedRatelimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identity.ID, Valid: true}) - if err != nil && err != sql.ErrNoRows { + if err != nil && !db.IsNotFound(err) { return fault.Wrap(err, - fault.Internal("unable to fetch updated ratelimits"), fault.Public("We were able to update the identity but unable to retrieve the updated ratelimits."), + fault.Internal("unable to fetch updated ratelimits"), + fault.Public("We were able to update the identity but unable to retrieve the updated ratelimits."), ) } - // Format ratelimits for response responseRatelimits := make([]openapi.RatelimitResponse, 0, len(updatedRatelimits)) for _, r := range updatedRatelimits { responseRatelimits = append(responseRatelimits, openapi.RatelimitResponse{ @@ -450,28 +398,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - // Parse metadata - var responseMeta *map[string]interface{} - if len(identity.Meta) > 0 { - metaMap := make(map[string]interface{}) - err = json.Unmarshal(identity.Meta, &metaMap) - if err != nil { - return fault.Wrap(err, - fault.Internal("unable to unmarshal metadata"), fault.Public("We're unable to parse the identity's metadata."), - ) - } - responseMeta = &metaMap - } - - // Build response response := Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), }, Data: openapi.IdentitiesUpdateIdentityResponseData{ - Id: identity.ID, - ExternalId: identity.ExternalID, - Meta: responseMeta, + ExternalId: req.ExternalId, + Meta: req.Meta, Ratelimits: &responseRatelimits, }, } diff --git a/go/apps/api/routes/v2_keys_create_key/handler.go b/go/apps/api/routes/v2_keys_create_key/handler.go index b951061215..5ad157b1a2 100644 --- a/go/apps/api/routes/v2_keys_create_key/handler.go +++ b/go/apps/api/routes/v2_keys_create_key/handler.go @@ -80,7 +80,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Validate API exists and belongs to workspace api, err := db.Query.FindApiByID(ctx, h.DB.RO(), req.ApiId) if err != nil { if db.IsNotFound(err) { @@ -96,7 +95,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Validate API belongs to authorized workspace if api.WorkspaceID != auth.AuthorizedWorkspaceID { return fault.New("api not found", fault.Code(codes.Data.Api.NotFound.URN()), @@ -177,57 +175,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { now := time.Now().UnixMilli() - // 6. Resolve permissions if provided - var resolvedPermissions []db.Permission - if req.Permissions != nil { - for _, permName := range *req.Permissions { - permission, findErr := db.Query.FindPermissionByNameAndWorkspaceID(ctx, h.DB.RO(), db.FindPermissionByNameAndWorkspaceIDParams{ - Name: permName, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if findErr != nil { - if db.IsNotFound(findErr) { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission '%s' was not found.", permName)), - ) - } - return fault.Wrap(findErr, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - resolvedPermissions = append(resolvedPermissions, permission) - } - } - - // 7. Resolve roles if provided - var resolvedRoles []db.Role - if req.Roles != nil { - for _, roleName := range *req.Roles { - role, findErr := db.Query.FindRoleByNameAndWorkspaceID(ctx, h.DB.RO(), db.FindRoleByNameAndWorkspaceIDParams{ - Name: roleName, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if findErr != nil { - if db.IsNotFound(findErr) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role '%s' was not found.", roleName)), - ) - } - return fault.Wrap(findErr, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - resolvedRoles = append(resolvedRoles, role) - } - } - - // 8. Execute all database operations in a transaction err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - // 9. Insert the key insertKeyParams := db.InsertKeyParams{ ID: keyID, KeyringID: api.KeyAuthID.String, @@ -251,6 +199,51 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { insertKeyParams.Name = sql.NullString{String: *req.Name, Valid: true} } + // Handle identity creation/lookup from externalId + if req.ExternalId != nil { + externalID := *req.ExternalId + + // Try to find existing identity + identity, err := db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + ExternalID: externalID, + Deleted: false, + }) + + if err != nil { + if !db.IsNotFound(err) { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("failed to find identity"), + fault.Public("Failed to find identity."), + ) + } + + // Create new identity + identityID := uid.New(uid.IdentityPrefix) + err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ + ID: identityID, + ExternalID: externalID, + WorkspaceID: auth.AuthorizedWorkspaceID, + Environment: "default", + CreatedAt: now, + Meta: []byte("{}"), + }) + + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("failed to create identity"), + fault.Public("Failed to create identity."), + ) + } + insertKeyParams.IdentityID = sql.NullString{Valid: true, String: identityID} + } else { + // Use existing identity + insertKeyParams.IdentityID = sql.NullString{Valid: true, String: identity.ID} + } + } + // Note: owner_id is set to null in the SQL query, so we skip setting it here if req.Meta != nil { metaBytes, marshalErr := json.Marshal(*req.Meta) @@ -329,7 +322,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - // 10. Handle rate limits if provided if req.Ratelimits != nil && len(*req.Ratelimits) > 0 { ratelimitsToInsert := make([]db.InsertKeyRatelimitParams, len(*req.Ratelimits)) for i, ratelimit := range *req.Ratelimits { @@ -358,112 +350,209 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - // 11. Handle permissions if provided + // 11. Handle permissions if provided - with auto-creation var auditLogs []auditlog.AuditLog - permissionsToInsert := make([]db.InsertKeyPermissionParams, len(resolvedPermissions)) - for idx, permission := range resolvedPermissions { - permissionsToInsert[idx] = db.InsertKeyPermissionParams{ - KeyID: keyID, - PermissionID: permission.ID, - WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAt: now, - } - - auditLogs = append(auditLogs, auditlog.AuditLog{ + if req.Permissions != nil { + existingPermissions, err := db.Query.FindPermissionsBySlugs(ctx, tx, db.FindPermissionsBySlugsParams{ 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.Name, keyID), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: "key", - ID: keyID, - Name: insertKeyParams.Name.String, - DisplayName: insertKeyParams.Name.String, - Meta: map[string]any{}, - }, - { - Type: "permission", - ID: permission.ID, - Name: permission.Name, - DisplayName: permission.Name, - Meta: map[string]any{}, - }, - }, + Slugs: *req.Permissions, }) - } - if len(permissionsToInsert) > 0 { - err = db.BulkInsert( - ctx, - tx, - "INSERT INTO key_permissions (key_id, permission_id, workspace_id, created_at_m) VALUES (?, ?, ?, ?)", - permissionsToInsert, - ) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to assign permissions."), + fault.Internal("database error"), + fault.Public("Failed to retrieve permissions."), ) } - } - // 12. Handle roles if provided - rolesToInsert := make([]db.InsertKeyRoleParams, len(resolvedRoles)) - for idx, role := range resolvedRoles { - rolesToInsert[idx] = db.InsertKeyRoleParams{ - KeyID: keyID, - RoleID: role.ID, - WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAtM: now, + existingPermMap := make(map[string]db.FindPermissionsBySlugsRow) + for _, p := range existingPermissions { + existingPermMap[p.Slug] = p } - 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.Name, keyID), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: "key", - ID: keyID, - DisplayName: insertKeyParams.Name.String, - Name: insertKeyParams.Name.String, - Meta: map[string]any{}, - }, - { - Type: "role", - ID: role.ID, - DisplayName: role.Name, - Name: role.Name, - Meta: map[string]any{}, + permissionsToCreate := []db.InsertPermissionParams{} + requestedPermissions := []db.FindPermissionsBySlugsRow{} + + for _, requestedSlug := range *req.Permissions { + existingPerm, exists := existingPermMap[requestedSlug] + if exists { + requestedPermissions = append(requestedPermissions, existingPerm) + continue + } + + newPermID := uid.New(uid.PermissionPrefix) + permissionsToCreate = append(permissionsToCreate, db.InsertPermissionParams{ + PermissionID: newPermID, + WorkspaceID: auth.AuthorizedWorkspaceID, + Name: requestedSlug, + Slug: requestedSlug, + Description: sql.NullString{String: fmt.Sprintf("Auto-created permission: %s", requestedSlug), Valid: true}, + CreatedAtM: now, + }) + + requestedPermissions = append(requestedPermissions, db.FindPermissionsBySlugsRow{ + ID: newPermID, + Slug: requestedSlug, + }) + } + + if len(permissionsToCreate) > 0 { + err = db.BulkInsert(ctx, tx, + "INSERT INTO permissions (id, workspace_id, name, slug, description, created_at_m) VALUES (?, ?, ?, ?, ?, ?)", + permissionsToCreate, + ) + + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to create permissions."), + ) + } + } + + permissionsToInsert := []db.InsertKeyPermissionParams{} + for _, reqPerm := range requestedPermissions { + permissionsToInsert = append(permissionsToInsert, db.InsertKeyPermissionParams{ + KeyID: keyID, + PermissionID: reqPerm.ID, + 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", reqPerm.Slug, keyID), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: "key", + ID: keyID, + Name: insertKeyParams.Name.String, + DisplayName: insertKeyParams.Name.String, + Meta: map[string]any{}, + }, + { + Type: "permission", + ID: reqPerm.ID, + Name: reqPerm.Slug, + DisplayName: reqPerm.Slug, + Meta: map[string]any{}, + }, }, - }, - }) + }) + } + + if len(permissionsToInsert) > 0 { + err = db.BulkInsert(ctx, tx, + "INSERT INTO keys_permissions (key_id, permission_id, workspace_id, created_at_m) VALUES (?, ?, ?, ?)", + permissionsToInsert, + ) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to assign permissions."), + ) + } + } } - if len(rolesToInsert) > 0 { - err = db.BulkInsert( - ctx, - tx, - "INSERT INTO keys_roles (key_id, role_id, workspace_id, created_at_m) VALUES (?, ?, ?, ?)", - rolesToInsert, - ) + // 12. Handle roles if provided - with auto-creation + if req.Roles != nil { + existingRoles, err := db.Query.FindRolesByNames(ctx, tx, db.FindRolesByNamesParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Names: *req.Roles, + }) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to assign roles."), + fault.Internal("database error"), + fault.Public("Failed to retrieve roles."), + ) + } + + // Find which roles need to be created + existingRoleMap := make(map[string]db.FindRolesByNamesRow) + for _, r := range existingRoles { + existingRoleMap[r.Name] = r + } + + // Create missing roles in bulk and build final list + requestedRoles := []db.FindRolesByNamesRow{} + + for _, requestedName := range *req.Roles { + existingRole, exists := existingRoleMap[requestedName] + if exists { + requestedRoles = append(requestedRoles, existingRole) + continue + } + + return fault.New("role not found", + fault.Code(codes.Data.Role.NotFound.URN()), + fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role %q was not found.", requestedName)), + ) + } + + // Insert all requested roles + rolesToInsert := []db.InsertKeyRoleParams{} + for _, reqRole := range requestedRoles { + rolesToInsert = append(rolesToInsert, db.InsertKeyRoleParams{ + KeyID: keyID, + RoleID: reqRole.ID, + 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", reqRole.Name, keyID), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: "key", + ID: keyID, + DisplayName: insertKeyParams.Name.String, + Name: insertKeyParams.Name.String, + Meta: map[string]any{}, + }, + { + Type: "role", + ID: reqRole.ID, + DisplayName: reqRole.Name, + Name: reqRole.Name, + Meta: map[string]any{}, + }, + }, + }) + } + + if len(rolesToInsert) > 0 { + err = db.BulkInsert(ctx, tx, + "INSERT INTO keys_roles (key_id, role_id, workspace_id, created_at_m) VALUES (?, ?, ?, ?)", + rolesToInsert, ) + + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to assign roles."), + ) + } } } diff --git a/go/apps/api/routes/v2_keys_get_key/handler.go b/go/apps/api/routes/v2_keys_get_key/handler.go index 81f885b83a..b18fb3db21 100644 --- a/go/apps/api/routes/v2_keys_get_key/handler.go +++ b/go/apps/api/routes/v2_keys_get_key/handler.go @@ -270,7 +270,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { k.Identity = &openapi.Identity{ ExternalId: identity.ExternalID, - Id: identity.ID, Meta: nil, Ratelimits: nil, } diff --git a/go/apps/api/routes/v2_keys_verify_key/200_test.go b/go/apps/api/routes/v2_keys_verify_key/200_test.go index 0fe6d1f413..132a6e442c 100644 --- a/go/apps/api/routes/v2_keys_verify_key/200_test.go +++ b/go/apps/api/routes/v2_keys_verify_key/200_test.go @@ -503,7 +503,7 @@ func TestSuccess(t *testing.T) { require.True(t, res.Body.Data.Valid, "Key should be valid but got %t", res.Body.Data.Valid) require.Len(t, ptr.SafeDeref(res.Body.Data.Roles), 1, "Key should have 1 role") require.Len(t, ptr.SafeDeref(res.Body.Data.Permissions), 3, "Key should have 3 permissions") - require.EqualValues(t, openapi.Identity{ExternalId: externalId, Id: identity, Meta: &meta, Ratelimits: nil}, ptr.SafeDeref(res.Body.Data.Identity)) + require.EqualValues(t, openapi.Identity{ExternalId: externalId, Meta: &meta, Ratelimits: nil}, ptr.SafeDeref(res.Body.Data.Identity)) require.Equal(t, keyName, ptr.SafeDeref(res.Body.Data.Name), "Key should have the same name") }) } 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 9d0bfd8280..e4fd5b68a3 100644 --- a/go/apps/api/routes/v2_keys_verify_key/handler.go +++ b/go/apps/api/routes/v2_keys_verify_key/handler.go @@ -183,7 +183,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if key.Key.IdentityID.Valid { res.Data.Identity = &openapi.Identity{ ExternalId: key.Key.ExternalID.String, - Id: key.Key.IdentityID.String, Ratelimits: nil, Meta: nil, } diff --git a/go/apps/api/routes/v2_ratelimit_delete_override/handler.go b/go/apps/api/routes/v2_ratelimit_delete_override/handler.go index 4f10a7efdd..4384b3e7f9 100644 --- a/go/apps/api/routes/v2_ratelimit_delete_override/handler.go +++ b/go/apps/api/routes/v2_ratelimit_delete_override/handler.go @@ -49,14 +49,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // nolint:exhaustruct - req := Request{} - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), - fault.Public("The request body is invalid."), - ) + return err } namespace, err := getNamespace(ctx, h, auth.AuthorizedWorkspaceID, req) diff --git a/go/apps/api/routes/v2_ratelimit_get_override/handler.go b/go/apps/api/routes/v2_ratelimit_get_override/handler.go index fc3004a651..dd5362c8cf 100644 --- a/go/apps/api/routes/v2_ratelimit_get_override/handler.go +++ b/go/apps/api/routes/v2_ratelimit_get_override/handler.go @@ -49,13 +49,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // nolint:exhaustruct - req := Request{} - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } response, err := db.Query.FindRatelimitNamespace(ctx, h.DB.RO(), db.FindRatelimitNamespaceParams{ diff --git a/go/apps/api/routes/v2_ratelimit_limit/handler.go b/go/apps/api/routes/v2_ratelimit_limit/handler.go index 3693a199a4..e042d1f00c 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/handler.go +++ b/go/apps/api/routes/v2_ratelimit_limit/handler.go @@ -59,12 +59,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // Parse request body - req := new(Request) - if bindErr := s.BindBody(req); bindErr != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("We're unable to parse the request body as JSON."), - ) + req, err := zen.BindBody[Request](s) + if err != nil { + return err } cost := int64(1) diff --git a/go/apps/api/routes/v2_ratelimit_list_overrides/handler.go b/go/apps/api/routes/v2_ratelimit_list_overrides/handler.go index a8337c94d6..75b3019e19 100644 --- a/go/apps/api/routes/v2_ratelimit_list_overrides/handler.go +++ b/go/apps/api/routes/v2_ratelimit_list_overrides/handler.go @@ -42,13 +42,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // nolint:exhaustruct - req := Request{} - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } namespace, err := getNamespace(ctx, h, auth.AuthorizedWorkspaceID, req) diff --git a/go/apps/api/routes/v2_ratelimit_set_override/handler.go b/go/apps/api/routes/v2_ratelimit_set_override/handler.go index 09911f28bb..860c696b1c 100644 --- a/go/apps/api/routes/v2_ratelimit_set_override/handler.go +++ b/go/apps/api/routes/v2_ratelimit_set_override/handler.go @@ -52,14 +52,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // nolint:exhaustruct - req := Request{} - err = s.BindBody(&req) + req, err := zen.BindBody[Request](s) if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.UnexpectedError.URN()), - fault.Internal("invalid request body"), fault.Public("The request body is invalid."), - ) + return err } namespace, err := getNamespace(ctx, h, auth.AuthorizedWorkspaceID, req) diff --git a/go/apps/ctrl/services/version/create_version.go b/go/apps/ctrl/services/version/create_version.go index 936ed6661d..eba8cbe6fb 100644 --- a/go/apps/ctrl/services/version/create_version.go +++ b/go/apps/ctrl/services/version/create_version.go @@ -20,7 +20,7 @@ func (s *Service) CreateVersion( // Validate workspace exists _, err := db.Query.FindWorkspaceByID(ctx, s.db.RO(), req.Msg.GetWorkspaceId()) if err != nil { - if err == sql.ErrNoRows { + if db.IsNotFound(err) { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("workspace not found: %s", req.Msg.GetWorkspaceId())) } @@ -30,7 +30,7 @@ func (s *Service) CreateVersion( // Validate project exists and belongs to workspace project, err := db.Query.FindProjectById(ctx, s.db.RO(), req.Msg.GetProjectId()) if err != nil { - if err == sql.ErrNoRows { + if db.IsNotFound(err) { return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("project not found: %s", req.Msg.GetProjectId())) } @@ -59,7 +59,7 @@ func (s *Service) CreateVersion( Name: branchName, }) if err != nil { - if err == sql.ErrNoRows { + if db.IsNotFound(err) { // Branch doesn't exist, create it branchID = uid.New("branch") err = db.Query.InsertBranch(ctx, s.db.RW(), db.InsertBranchParams{ diff --git a/go/cmd/api/main.go b/go/cmd/api/main.go index 160530d427..ed28550263 100644 --- a/go/cmd/api/main.go +++ b/go/cmd/api/main.go @@ -213,7 +213,7 @@ func action(ctx context.Context, cmd *cli.Command) error { // Database configuration DatabasePrimary: cmd.String("database-primary"), - DatabaseReadonlyReplica: cmd.String("database-readonly-replica"), + DatabaseReadonlyReplica: cmd.String("database-replica"), // ClickHouse ClickhouseURL: cmd.String("clickhouse-url"), diff --git a/go/cmd/version/bootstrap.go b/go/cmd/version/bootstrap.go index 08b02480b8..604627fd54 100644 --- a/go/cmd/version/bootstrap.go +++ b/go/cmd/version/bootstrap.go @@ -67,7 +67,7 @@ func bootstrapProjectAction(ctx context.Context, cmd *cli.Command) error { // Create workspace if it doesn't exist _, err = db.Query.FindWorkspaceByID(ctx, sqlDB, workspaceID) if err != nil { - if err == sql.ErrNoRows { + if db.IsNotFound(err) { // Workspace doesn't exist, create it fmt.Printf("πŸ“ Creating workspace: %s\n", workspaceID) now := time.Now().UnixMilli() @@ -95,7 +95,7 @@ func bootstrapProjectAction(ctx context.Context, cmd *cli.Command) error { }) if err == nil { return fmt.Errorf("project with slug '%s' already exists in workspace '%s'", projectSlug, workspaceID) - } else if err != sql.ErrNoRows { + } else if !db.IsNotFound(err) { return fmt.Errorf("failed to check existing project: %w", err) } diff --git a/go/deploy/metald/internal/database/repository.go b/go/deploy/metald/internal/database/repository.go index 9fcde4f3bc..598b6bd666 100644 --- a/go/deploy/metald/internal/database/repository.go +++ b/go/deploy/metald/internal/database/repository.go @@ -201,7 +201,7 @@ func (r *VMRepository) UpdateVMStateWithContext(ctx context.Context, vmID string slog.Any("process_id", processID), ) query := ` - UPDATE vms + UPDATE vms SET state = ?, process_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL ` @@ -396,7 +396,7 @@ func (r *VMRepository) DeleteVMWithContext(ctx context.Context, vmID string) err slog.String("vm_id", vmID), ) query := ` - UPDATE vms + UPDATE vms SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL ` @@ -500,7 +500,7 @@ func (r *VMRepository) UpdateVMPortMappingsWithContext(ctx context.Context, vmID ) query := ` - UPDATE vms + UPDATE vms SET port_mappings = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL ` diff --git a/go/pkg/db/permission_find_by_slugs.sql_generated.go b/go/pkg/db/permission_find_by_slugs.sql_generated.go new file mode 100644 index 0000000000..10d4b3b2af --- /dev/null +++ b/go/pkg/db/permission_find_by_slugs.sql_generated.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: permission_find_by_slugs.sql + +package db + +import ( + "context" + "strings" +) + +const findPermissionsBySlugs = `-- name: FindPermissionsBySlugs :many +SELECT id, slug FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) +` + +type FindPermissionsBySlugsParams struct { + WorkspaceID string `db:"workspace_id"` + Slugs []string `db:"slugs"` +} + +type FindPermissionsBySlugsRow struct { + ID string `db:"id"` + Slug string `db:"slug"` +} + +// FindPermissionsBySlugs +// +// SELECT id, slug FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) +func (q *Queries) FindPermissionsBySlugs(ctx context.Context, db DBTX, arg FindPermissionsBySlugsParams) ([]FindPermissionsBySlugsRow, error) { + query := findPermissionsBySlugs + var queryParams []interface{} + queryParams = append(queryParams, arg.WorkspaceID) + if len(arg.Slugs) > 0 { + for _, v := range arg.Slugs { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:slugs*/?", strings.Repeat(",?", len(arg.Slugs))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:slugs*/?", "NULL", 1) + } + rows, err := db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindPermissionsBySlugsRow + for rows.Next() { + var i FindPermissionsBySlugsRow + if err := rows.Scan(&i.ID, &i.Slug); 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/querier_generated.go b/go/pkg/db/querier_generated.go index 2e4d195e76..f6f25687f0 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -318,6 +318,10 @@ type Querier interface { // AND workspace_id = ? // LIMIT 1 FindPermissionBySlugAndWorkspaceID(ctx context.Context, db DBTX, arg FindPermissionBySlugAndWorkspaceIDParams) (Permission, error) + //FindPermissionsBySlugs + // + // SELECT id, slug FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) + FindPermissionsBySlugs(ctx context.Context, db DBTX, arg FindPermissionsBySlugsParams) ([]FindPermissionsBySlugsRow, error) //FindProjectById // // SELECT @@ -421,6 +425,10 @@ type Querier interface { // WHERE role_id = ? // AND permission_id = ? FindRolePermissionByRoleAndPermissionID(ctx context.Context, db DBTX, arg FindRolePermissionByRoleAndPermissionIDParams) ([]RolesPermission, error) + //FindRolesByNames + // + // SELECT id, name FROM roles WHERE workspace_id = ? AND name IN (/*SLICE:names*/?) + FindRolesByNames(ctx context.Context, db DBTX, arg FindRolesByNamesParams) ([]FindRolesByNamesRow, error) //FindVersionById // // SELECT diff --git a/go/pkg/db/queries/permission_find_by_slugs.sql b/go/pkg/db/queries/permission_find_by_slugs.sql new file mode 100644 index 0000000000..9c316fec47 --- /dev/null +++ b/go/pkg/db/queries/permission_find_by_slugs.sql @@ -0,0 +1,2 @@ +-- name: FindPermissionsBySlugs :many +SELECT id, slug FROM permissions WHERE workspace_id = ? AND slug IN (sqlc.slice('slugs')); diff --git a/go/pkg/db/queries/role_find_by_names.sql b/go/pkg/db/queries/role_find_by_names.sql new file mode 100644 index 0000000000..66e3ea6503 --- /dev/null +++ b/go/pkg/db/queries/role_find_by_names.sql @@ -0,0 +1,2 @@ +-- name: FindRolesByNames :many +SELECT id, name FROM roles WHERE workspace_id = sqlc.arg('workspace_id') AND name IN (sqlc.slice('names')) diff --git a/go/pkg/db/role_find_by_names.sql_generated.go b/go/pkg/db/role_find_by_names.sql_generated.go new file mode 100644 index 0000000000..f73222a3d3 --- /dev/null +++ b/go/pkg/db/role_find_by_names.sql_generated.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: role_find_by_names.sql + +package db + +import ( + "context" + "strings" +) + +const findRolesByNames = `-- name: FindRolesByNames :many +SELECT id, name FROM roles WHERE workspace_id = ? AND name IN (/*SLICE:names*/?) +` + +type FindRolesByNamesParams struct { + WorkspaceID string `db:"workspace_id"` + Names []string `db:"names"` +} + +type FindRolesByNamesRow struct { + ID string `db:"id"` + Name string `db:"name"` +} + +// FindRolesByNames +// +// SELECT id, name FROM roles WHERE workspace_id = ? AND name IN (/*SLICE:names*/?) +func (q *Queries) FindRolesByNames(ctx context.Context, db DBTX, arg FindRolesByNamesParams) ([]FindRolesByNamesRow, error) { + query := findRolesByNames + var queryParams []interface{} + queryParams = append(queryParams, arg.WorkspaceID) + if len(arg.Names) > 0 { + for _, v := range arg.Names { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:names*/?", strings.Repeat(",?", len(arg.Names))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:names*/?", "NULL", 1) + } + rows, err := db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindRolesByNamesRow + for rows.Next() { + var i FindRolesByNamesRow + if err := rows.Scan(&i.ID, &i.Name); 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/tx.go b/go/pkg/db/tx.go index d60f045613..90e3a21cc9 100644 --- a/go/pkg/db/tx.go +++ b/go/pkg/db/tx.go @@ -124,12 +124,14 @@ func TxWithResult[T any](ctx context.Context, db *Replica, fn func(context.Conte t, err = fn(ctx, tx) if err != nil { rollbackErr := tx.Rollback() + if rollbackErr != nil && rollbackErr != sql.ErrTxDone { return t, fault.Wrap(rollbackErr, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), fault.Internal("database failed to rollback transaction"), fault.Public("Unable to rollback database transaction."), ) } + return t, err } diff --git a/go/pkg/hydra/store/sqlc-queries-analysis.md b/go/pkg/hydra/store/sqlc-queries-analysis.md deleted file mode 100644 index f0e22e2ac3..0000000000 --- a/go/pkg/hydra/store/sqlc-queries-analysis.md +++ /dev/null @@ -1,625 +0,0 @@ -# SQLC Query Analysis: Hydra Store Operations - -This document analyzes all database operations currently implemented in the GORM store to understand what queries we need to implement in SQLC. - -## Overview - -The Hydra store has **25 distinct database operations** across 4 main entity types: -- **Workflow Executions** (11 operations) -- **Workflow Steps** (4 operations) -- **Leases** (6 operations) -- **Cron Jobs** (4 operations) - -## 1. Workflow Execution Operations - -### 1.1 CreateWorkflow -**Purpose**: Insert a new workflow execution record -**GORM Query**: -```go -s.db.WithContext(ctx).Create(workflow) -``` -**SQL Equivalent**: -```sql -INSERT INTO workflow_executions ( - id, workflow_name, status, input_data, output_data, error_message, - created_at, started_at, completed_at, max_attempts, remaining_attempts, - next_retry_at, namespace, trigger_type, trigger_source, sleep_until, - trace_id, span_id -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -``` - -### 1.2 GetWorkflow -**Purpose**: Retrieve a specific workflow by ID and namespace -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("id = ? AND namespace = ?", id, namespace). - First(&workflow) -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_executions -WHERE id = ? AND namespace = ? -LIMIT 1 -``` - -### 1.3 GetPendingWorkflows / GetPendingWorkflowsWithOffset -**Purpose**: Get workflows ready for execution (most critical query for performance) -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND (status = ? OR (status = ? AND next_retry_at <= ?) OR (status = ? AND sleep_until <= ?))", - namespace, - store.WorkflowStatusPending, - store.WorkflowStatusFailed, - now, - store.WorkflowStatusSleeping, - now, - ). - Where("workflow_name IN ?", workflowNames). // Optional filter - Order("created_at ASC"). - Offset(offset). - Limit(limit) -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_executions -WHERE namespace = ? - AND ( - status = 'pending' - OR (status = 'failed' AND next_retry_at <= ?) - OR (status = 'sleeping' AND sleep_until <= ?) - ) - AND (? = 0 OR workflow_name = ANY(?)) -- Optional workflow name filter -ORDER BY created_at ASC -LIMIT ? OFFSET ? -``` -**Performance Critical**: This is the hottest query path - needs optimal indexing - -### 1.4 UpdateWorkflowStatus -**Purpose**: Update workflow status and optionally error message -**GORM Query**: -```go -s.db.WithContext(ctx). - Model(emptyWorkflowExecution). - Where("id = ? AND namespace = ?", id, namespace). - Updates(map[string]any{ - "status": status, - "error_message": errorMsg, // Optional - }) -``` -**SQL Equivalent**: -```sql -UPDATE workflow_executions -SET status = ?, error_message = COALESCE(?, error_message) -WHERE id = ? AND namespace = ? -``` - -### 1.5 CompleteWorkflow -**Purpose**: Mark workflow as completed with timestamp and optional output -**GORM Query**: -```go -s.db.WithContext(ctx). - Model(emptyWorkflowExecution). - Where("id = ? AND namespace = ?", id, namespace). - Updates(map[string]any{ - "status": store.WorkflowStatusCompleted, - "completed_at": now, - "output_data": outputData, // Optional - }) -``` -**SQL Equivalent**: -```sql -UPDATE workflow_executions -SET status = 'completed', - completed_at = ?, - output_data = COALESCE(?, output_data) -WHERE id = ? AND namespace = ? -``` - -### 1.6 FailWorkflow -**Purpose**: Handle workflow failure with retry logic (complex business logic) -**GORM Operations**: -1. **Read current state**: -```go -s.db.WithContext(ctx). - Where("id = ? AND namespace = ?", id, namespace). - First(&workflow) -``` -2. **Update with retry logic**: -```go -s.db.WithContext(ctx). - Model(emptyWorkflowExecution). - Where("id = ? AND namespace = ?", id, namespace). - Updates(map[string]any{ - "error_message": errorMsg, - "remaining_attempts": workflow.RemainingAttempts - 1, - "status": store.WorkflowStatusFailed, - "completed_at": now, // If final failure - "next_retry_at": nextRetry, // If retryable - }) -``` -**SQL Equivalent** (requires transaction): -```sql --- First, get current state -SELECT id, max_attempts, remaining_attempts -FROM workflow_executions -WHERE id = ? AND namespace = ?; - --- Then update based on retry logic -UPDATE workflow_executions -SET error_message = ?, - remaining_attempts = remaining_attempts - 1, - status = 'failed', - completed_at = CASE WHEN (? = true OR remaining_attempts <= 1) THEN ? ELSE completed_at END, - next_retry_at = CASE WHEN (? = false AND remaining_attempts > 1) THEN ? ELSE NULL END -WHERE id = ? AND namespace = ? -``` - -### 1.7 SleepWorkflow -**Purpose**: Put workflow to sleep until a specific time -**GORM Query**: -```go -s.db.WithContext(ctx). - Model(emptyWorkflowExecution). - Where("id = ? AND namespace = ?", id, namespace). - Updates(map[string]any{ - "status": store.WorkflowStatusSleeping, - "sleep_until": sleepUntil, - }) -``` -**SQL Equivalent**: -```sql -UPDATE workflow_executions -SET status = 'sleeping', sleep_until = ? -WHERE id = ? AND namespace = ? -``` - -### 1.8 GetSleepingWorkflows -**Purpose**: Find workflows ready to wake up from sleep -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND status = ? AND sleep_until <= ?", - namespace, store.WorkflowStatusSleeping, beforeTime). - Order("sleep_until ASC") -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_executions -WHERE namespace = ? - AND status = 'sleeping' - AND sleep_until <= ? -ORDER BY sleep_until ASC -``` - -### 1.9 ResetOrphanedWorkflows -**Purpose**: Reset running workflows that have no active lease (cleanup operation) -**Raw SQL** (already optimized): -```sql -UPDATE workflow_executions -SET status = 'pending' -WHERE namespace = ? - AND status = 'running' - AND id NOT IN ( - SELECT resource_id - FROM leases - WHERE kind = 'workflow' AND namespace = ? - ) -``` - -### 1.10 GetAllWorkflows (Testing) -**Purpose**: Get all workflows in namespace for testing -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ?", namespace). - Find(&workflows) -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_executions WHERE namespace = ? -``` - -## 2. Workflow Step Operations - -### 2.1 CreateStep -**Purpose**: Insert a new workflow step record -**GORM Query**: -```go -s.db.WithContext(ctx).Create(step) -``` -**SQL Equivalent**: -```sql -INSERT INTO workflow_steps ( - id, execution_id, step_name, step_order, status, output_data, - error_message, started_at, completed_at, max_attempts, - remaining_attempts, namespace -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -``` - -### 2.2 GetStep -**Purpose**: Retrieve any step by execution, name -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND execution_id = ? AND step_name = ?", - namespace, executionID, stepName). - First(&step) -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_steps -WHERE namespace = ? AND execution_id = ? AND step_name = ? -LIMIT 1 -``` - -### 2.3 GetCompletedStep -**Purpose**: Retrieve only completed steps (for checkpointing) -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND execution_id = ? AND step_name = ? AND status = ?", - namespace, executionID, stepName, store.StepStatusCompleted). - First(&step) -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_steps -WHERE namespace = ? - AND execution_id = ? - AND step_name = ? - AND status = 'completed' -LIMIT 1 -``` - -### 2.4 UpdateStepStatus -**Purpose**: Update step status, output data, and completion time -**GORM Operations**: -1. **Read current step**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND execution_id = ? AND step_name = ?", namespace, executionID, stepName). - First(&step) -``` -2. **Update step**: -```go -s.db.WithContext(ctx). - Model(emptyWorkflowStep). - Where("namespace = ? AND execution_id = ? AND step_name = ?", namespace, executionID, stepName). - Updates(map[string]any{ - "status": status, - "completed_at": now, - "output_data": outputData, // Optional - "error_message": errorMsg, // Optional - }) -``` -**SQL Equivalent**: -```sql -UPDATE workflow_steps -SET status = ?, - completed_at = ?, - output_data = COALESCE(?, output_data), - error_message = COALESCE(?, error_message) -WHERE namespace = ? AND execution_id = ? AND step_name = ? -``` - -### 2.5 GetAllSteps (Testing) -**Purpose**: Get all steps in namespace for testing -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ?", namespace). - Find(&steps) -``` -**SQL Equivalent**: -```sql -SELECT * FROM workflow_steps WHERE namespace = ? -``` - -## 3. Lease Operations - -### 3.1 AcquireWorkflowLease -**Purpose**: Complex transactional lease acquisition (most critical for correctness) -**GORM Transaction**: Multiple operations in sequence: - -1. **Check workflow availability**: -```go -tx.Where("id = ? AND namespace = ?", workflowID, namespace).First(&workflow) -``` - -2. **Validate workflow state** (business logic in Go) - -3. **Check existing lease**: -```go -tx.Where("resource_id = ? AND kind = ?", workflowID, "workflow").First(&existingLease) -``` - -4. **Create or update lease**: -```go -tx.Create(lease) // For new lease -// OR -tx.Save(&existingLease) // For renewal/takeover -``` - -5. **Update workflow to running**: -```go -tx.Model(emptyWorkflowExecution). - Where("id = ? AND namespace = ?", workflowID, namespace). - Updates(map[string]any{ - "status": store.WorkflowStatusRunning, - "started_at": gorm.Expr("CASE WHEN started_at IS NULL THEN ? ELSE started_at END", now), - "sleep_until": nil, - }) -``` - -**SQL Equivalent** (single transaction with proper locking): -```sql -BEGIN; - --- 1. Lock and validate workflow -SELECT id, status, next_retry_at, sleep_until -FROM workflow_executions -WHERE id = ? AND namespace = ? -FOR UPDATE; - --- 2. Check/acquire lease with proper upsert -INSERT INTO leases (resource_id, kind, namespace, worker_id, acquired_at, expires_at, heartbeat_at) -VALUES (?, 'workflow', ?, ?, ?, ?, ?) -ON DUPLICATE KEY UPDATE - worker_id = CASE - WHEN expires_at <= ? THEN VALUES(worker_id) -- Expired, can take over - WHEN worker_id = VALUES(worker_id) THEN VALUES(worker_id) -- Same worker, can renew - ELSE worker_id -- Different worker, keep existing - END, - acquired_at = CASE - WHEN expires_at <= ? OR worker_id = VALUES(worker_id) THEN VALUES(acquired_at) - ELSE acquired_at - END, - expires_at = CASE - WHEN expires_at <= ? OR worker_id = VALUES(worker_id) THEN VALUES(expires_at) - ELSE expires_at - END, - heartbeat_at = CASE - WHEN expires_at <= ? OR worker_id = VALUES(worker_id) THEN VALUES(heartbeat_at) - ELSE heartbeat_at - END; - --- 3. Update workflow status if lease was acquired -UPDATE workflow_executions -SET status = 'running', - started_at = CASE WHEN started_at IS NULL THEN ? ELSE started_at END, - sleep_until = NULL -WHERE id = ? AND namespace = ? - AND EXISTS ( - SELECT 1 FROM leases - WHERE resource_id = ? AND worker_id = ? - ); - -COMMIT; -``` - -### 3.2 AcquireLease (Generic) -**Purpose**: Simple lease creation for cron jobs -**GORM Query**: -```go -s.db.WithContext(ctx).Create(lease) -``` -**SQL Equivalent**: -```sql -INSERT INTO leases (resource_id, kind, namespace, worker_id, acquired_at, expires_at, heartbeat_at) -VALUES (?, ?, ?, ?, ?, ?, ?) -``` - -### 3.3 HeartbeatLease -**Purpose**: Update lease expiration and heartbeat time -**GORM Query**: -```go -s.db.WithContext(ctx). - Model(emptyLease). - Where("resource_id = ? AND worker_id = ?", resourceID, workerID). - Updates(map[string]any{ - "heartbeat_at": now, - "expires_at": expiresAt, - }) -``` -**SQL Equivalent**: -```sql -UPDATE leases -SET heartbeat_at = ?, expires_at = ? -WHERE resource_id = ? AND worker_id = ? -``` - -### 3.4 ReleaseLease -**Purpose**: Delete a lease owned by specific worker -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("resource_id = ? AND worker_id = ?", resourceID, workerID). - Delete(emptyLease) -``` -**SQL Equivalent**: -```sql -DELETE FROM leases -WHERE resource_id = ? AND worker_id = ? -``` - -### 3.5 GetLease -**Purpose**: Retrieve lease information by resource ID -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("resource_id = ?", resourceID). - First(&lease) -``` -**SQL Equivalent**: -```sql -SELECT * FROM leases WHERE resource_id = ? LIMIT 1 -``` - -### 3.6 CleanupExpiredLeases -**Purpose**: Bulk delete expired leases -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND expires_at < ?", namespace, now). - Delete(emptyLease) -``` -**SQL Equivalent**: -```sql -DELETE FROM leases -WHERE namespace = ? AND expires_at < ? -``` - -### 3.7 GetExpiredLeases -**Purpose**: Get list of expired leases (for monitoring) -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND expires_at < ?", namespace, now). - Find(&leases) -``` -**SQL Equivalent**: -```sql -SELECT * FROM leases -WHERE namespace = ? AND expires_at < ? -``` - -## 4. Cron Job Operations - -### 4.1 UpsertCronJob -**Purpose**: Insert or update cron job configuration -**GORM Operations**: -1. **Check if exists**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND name = ?", cronJob.Namespace, cronJob.Name). - First(&existing) -``` -2. **Update or create**: -```go -s.db.WithContext(ctx).Save(cronJob) // If exists -// OR -s.db.WithContext(ctx).Create(cronJob) // If new -``` -**SQL Equivalent**: -```sql -INSERT INTO cron_jobs (id, name, cron_spec, namespace, workflow_name, enabled, created_at, updated_at, last_run_at, next_run_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON DUPLICATE KEY UPDATE - cron_spec = VALUES(cron_spec), - workflow_name = VALUES(workflow_name), - enabled = VALUES(enabled), - updated_at = VALUES(updated_at), - next_run_at = VALUES(next_run_at) -``` - -### 4.2 GetCronJob -**Purpose**: Retrieve specific cron job by namespace and name -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND name = ?", namespace, name). - First(&cronJob) -``` -**SQL Equivalent**: -```sql -SELECT * FROM cron_jobs -WHERE namespace = ? AND name = ? -LIMIT 1 -``` - -### 4.3 GetCronJobs -**Purpose**: Get all enabled cron jobs in namespace -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND enabled = ?", namespace, true). - Find(&cronJobs) -``` -**SQL Equivalent**: -```sql -SELECT * FROM cron_jobs -WHERE namespace = ? AND enabled = true -``` - -### 4.4 GetDueCronJobs -**Purpose**: Get cron jobs ready to execute -**GORM Query**: -```go -s.db.WithContext(ctx). - Where("namespace = ? AND enabled = ? AND next_run_at <= ?", namespace, true, beforeTime). - Find(&cronJobs) -``` -**SQL Equivalent**: -```sql -SELECT * FROM cron_jobs -WHERE namespace = ? - AND enabled = true - AND next_run_at <= ? -``` - -### 4.5 UpdateCronJobLastRun -**Purpose**: Update cron job execution timestamps -**GORM Query**: -```go -s.db.WithContext(ctx). - Model(emptyCronJob). - Where("id = ? AND namespace = ?", cronJobID, namespace). - Updates(map[string]any{ - "last_run_at": lastRunAt, - "next_run_at": nextRunAt, - "updated_at": time.Now().UnixMilli(), - }) -``` -**SQL Equivalent**: -```sql -UPDATE cron_jobs -SET last_run_at = ?, next_run_at = ?, updated_at = ? -WHERE id = ? AND namespace = ? -``` - -## 5. Transaction Support - -### WithTx -**Purpose**: Execute multiple operations in a transaction -**GORM Implementation**: -```go -s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - txStore := &gormStore{db: tx, clock: s.clock} - return fn(txStore) -}) -``` -**SQLC Implementation**: Will use `database/sql` transactions with `tx.Begin()` / `tx.Commit()` / `tx.Rollback()` - -## 6. Critical Performance & Security Considerations - -### 6.1 Hot Path Queries (Need Optimal Indexing) -1. **GetPendingWorkflows** - Most frequent query, needs composite index on (namespace, status, created_at) -2. **AcquireWorkflowLease** - Race condition critical, needs proper SELECT FOR UPDATE -3. **HeartbeatLease** - High frequency, needs index on (resource_id, worker_id) - -### 6.2 Race Condition Fixes Needed -1. **AcquireWorkflowLease** - Current GORM implementation has TOCTOU races -2. **Step creation/updates** - Need better concurrency control -3. **Lease operations** - Need atomic upsert patterns - -### 6.3 Missing in Current Implementation -1. **Proper row locking** with SELECT FOR UPDATE -2. **Atomic upsert** operations for lease acquisition -3. **Bulk operations** for better performance -4. **Query timeouts** and resource limits - -## 7. SQLC Query Organization - -Suggested file structure for SQLC queries: -``` -store/sqlc/queries/ -β”œβ”€β”€ workflows.sql # Workflow execution operations (1.1-1.10) -β”œβ”€β”€ steps.sql # Workflow step operations (2.1-2.5) -β”œβ”€β”€ leases.sql # Lease operations (3.1-3.7) -β”œβ”€β”€ cron.sql # Cron job operations (4.1-4.5) -└── transactions.sql # Complex transactional operations -``` - -Each file will contain the corresponding SQL queries with proper parameter binding and return types for SQLC code generation. \ No newline at end of file diff --git a/go/pkg/hydra/store/sqlc_store.go.bak b/go/pkg/hydra/store/sqlc_store.go.bak deleted file mode 100644 index 472edbcfb0..0000000000 --- a/go/pkg/hydra/store/sqlc_store.go.bak +++ /dev/null @@ -1,979 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "errors" - "strings" - "time" - - "github.com/unkeyed/unkey/go/pkg/clock" - sqlcstore "github.com/unkeyed/unkey/go/pkg/hydra/store/sqlc" - - // MySQL driver for database/sql - _ "github.com/go-sql-driver/mysql" -) - -var ( - ErrStepNotFound = errors.New("step not found") -) - -// sqlcStore implements the Store interface using SQLC instead of GORM -type sqlcStore struct { - db *sql.DB - queries *sqlcstore.Queries - clock clock.Clock -} - -// NewSQLCStoreFromDSN creates a new Store implementation using SQLC from a DSN -// This creates its own database connection independent of GORM -func NewSQLCStoreFromDSN(dsn string, clk clock.Clock) (Store, error) { - if clk == nil { - clk = clock.New() - } - - // Open direct database connection for SQLC - sqlDB, err := sql.Open("mysql", dsn) - if err != nil { - return nil, err - } - - // Test the connection - if err := sqlDB.Ping(); err != nil { - sqlDB.Close() - return nil, err - } - - queries := sqlcstore.New(sqlDB) - - return &sqlcStore{ - db: sqlDB, - queries: queries, - clock: clk, - }, nil -} - -// For now, just implement the one method we have a query for -func (s *sqlcStore) GetWorkflow(ctx context.Context, namespace, id string) (*WorkflowExecution, error) { - workflow, err := s.queries.GetWorkflow(ctx, sqlcstore.GetWorkflowParams{ - ID: id, - Namespace: namespace, - }) - if err != nil { - return nil, err - } - - // Convert SQLC model to store model - result := &WorkflowExecution{ - ID: workflow.ID, - WorkflowName: workflow.WorkflowName, - Status: WorkflowStatus(workflow.Status), - InputData: workflow.InputData, - OutputData: workflow.OutputData, - CreatedAt: workflow.CreatedAt, - MaxAttempts: workflow.MaxAttempts, - RemainingAttempts: workflow.RemainingAttempts, - Namespace: workflow.Namespace, - } - - // Handle nullable fields - if workflow.ErrorMessage.Valid { - result.ErrorMessage = workflow.ErrorMessage.String - } - if workflow.StartedAt.Valid { - result.StartedAt = &workflow.StartedAt.Int64 - } - if workflow.CompletedAt.Valid { - result.CompletedAt = &workflow.CompletedAt.Int64 - } - if workflow.NextRetryAt.Valid { - result.NextRetryAt = &workflow.NextRetryAt.Int64 - } - if workflow.TriggerType.Valid { - result.TriggerType = TriggerType(workflow.TriggerType.WorkflowExecutionsTriggerType) - } - if workflow.TriggerSource.Valid { - result.TriggerSource = &workflow.TriggerSource.String - } - if workflow.SleepUntil.Valid { - result.SleepUntil = &workflow.SleepUntil.Int64 - } - if workflow.TraceID.Valid { - result.TraceID = workflow.TraceID.String - } - if workflow.SpanID.Valid { - result.SpanID = workflow.SpanID.String - } - - return result, nil -} - -// CreateWorkflow creates a new workflow execution record -func (s *sqlcStore) CreateWorkflow(ctx context.Context, workflow *WorkflowExecution) error { - // Set CreatedAt if not already set - if workflow.CreatedAt == 0 { - workflow.CreatedAt = s.clock.Now().UnixMilli() - } - - // Convert store model to SQLC parameters - var startedAt, completedAt, nextRetryAt, sleepUntil sql.NullInt64 - var triggerSource sql.NullString - - if workflow.StartedAt != nil { - startedAt = sql.NullInt64{Int64: *workflow.StartedAt, Valid: true} - } - if workflow.CompletedAt != nil { - completedAt = sql.NullInt64{Int64: *workflow.CompletedAt, Valid: true} - } - if workflow.NextRetryAt != nil { - nextRetryAt = sql.NullInt64{Int64: *workflow.NextRetryAt, Valid: true} - } - if workflow.SleepUntil != nil { - sleepUntil = sql.NullInt64{Int64: *workflow.SleepUntil, Valid: true} - } - if workflow.TriggerSource != nil { - triggerSource = sql.NullString{String: *workflow.TriggerSource, Valid: true} - } - - params := sqlcstore.CreateWorkflowParams{ - ID: workflow.ID, - WorkflowName: workflow.WorkflowName, - Status: sqlcstore.WorkflowExecutionsStatus(workflow.Status), - InputData: workflow.InputData, - OutputData: workflow.OutputData, - ErrorMessage: sql.NullString{String: workflow.ErrorMessage, Valid: workflow.ErrorMessage != ""}, - CreatedAt: workflow.CreatedAt, - StartedAt: startedAt, - CompletedAt: completedAt, - MaxAttempts: int32(workflow.MaxAttempts), - RemainingAttempts: int32(workflow.RemainingAttempts), - NextRetryAt: nextRetryAt, - Namespace: workflow.Namespace, - TriggerType: sqlcstore.NullWorkflowExecutionsTriggerType{WorkflowExecutionsTriggerType: sqlcstore.WorkflowExecutionsTriggerType(workflow.TriggerType), Valid: workflow.TriggerType != ""}, - TriggerSource: triggerSource, - SleepUntil: sleepUntil, - TraceID: sql.NullString{String: workflow.TraceID, Valid: workflow.TraceID != ""}, - SpanID: sql.NullString{String: workflow.SpanID, Valid: workflow.SpanID != ""}, - } - - return s.queries.CreateWorkflow(ctx, params) -} - -func (s *sqlcStore) GetPendingWorkflows(ctx context.Context, namespace string, limit int, workflowNames []string) ([]WorkflowExecution, error) { - now := s.clock.Now().UnixMilli() - - // If workflowNames filter is empty, get all pending workflows - if len(workflowNames) == 0 { - params := sqlcstore.GetPendingWorkflowsParams{ - Namespace: namespace, - NextRetryAt: sql.NullInt64{Int64: now, Valid: true}, - SleepUntil: sql.NullInt64{Int64: now, Valid: true}, - Limit: int32(limit), - } - - sqlcWorkflows, err := s.queries.GetPendingWorkflows(ctx, params) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models - result := make([]WorkflowExecution, len(sqlcWorkflows)) - for i, sqlcWf := range sqlcWorkflows { - result[i] = WorkflowExecution{ - ID: sqlcWf.ID, - WorkflowName: sqlcWf.WorkflowName, - Status: WorkflowStatus(sqlcWf.Status), - InputData: sqlcWf.InputData, - OutputData: sqlcWf.OutputData, - ErrorMessage: sqlcWf.ErrorMessage.String, - CreatedAt: sqlcWf.CreatedAt, - StartedAt: nullInt64ToPtr(sqlcWf.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcWf.CompletedAt), - MaxAttempts: sqlcWf.MaxAttempts, - RemainingAttempts: sqlcWf.RemainingAttempts, - NextRetryAt: nullInt64ToPtr(sqlcWf.NextRetryAt), - Namespace: sqlcWf.Namespace, - TriggerType: TriggerType(sqlcWf.TriggerType.WorkflowExecutionsTriggerType), - TriggerSource: nullStringToPtr(sqlcWf.TriggerSource), - SleepUntil: nullInt64ToPtr(sqlcWf.SleepUntil), - TraceID: sqlcWf.TraceID.String, - SpanID: sqlcWf.SpanID.String, - } - } - return result, nil - } - - // For filtered queries, we need to call the query for each workflow name - // This is less efficient but handles the slice limitation - var allWorkflows []WorkflowExecution - remainingLimit := limit - - for _, workflowName := range workflowNames { - if remainingLimit <= 0 { - break - } - - params := sqlcstore.GetPendingWorkflowsFilteredParams{ - Namespace: namespace, - NextRetryAt: sql.NullInt64{Int64: now, Valid: true}, - SleepUntil: sql.NullInt64{Int64: now, Valid: true}, - WorkflowName: workflowName, - Limit: int32(remainingLimit), - } - - sqlcWorkflows, err := s.queries.GetPendingWorkflowsFiltered(ctx, params) - if err != nil { - return nil, err - } - - // Convert and append - for _, sqlcWf := range sqlcWorkflows { - if len(allWorkflows) >= limit { - break - } - allWorkflows = append(allWorkflows, WorkflowExecution{ - ID: sqlcWf.ID, - WorkflowName: sqlcWf.WorkflowName, - Status: WorkflowStatus(sqlcWf.Status), - InputData: sqlcWf.InputData, - OutputData: sqlcWf.OutputData, - ErrorMessage: sqlcWf.ErrorMessage.String, - CreatedAt: sqlcWf.CreatedAt, - StartedAt: nullInt64ToPtr(sqlcWf.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcWf.CompletedAt), - MaxAttempts: sqlcWf.MaxAttempts, - RemainingAttempts: sqlcWf.RemainingAttempts, - NextRetryAt: nullInt64ToPtr(sqlcWf.NextRetryAt), - Namespace: sqlcWf.Namespace, - TriggerType: TriggerType(sqlcWf.TriggerType.WorkflowExecutionsTriggerType), - TriggerSource: nullStringToPtr(sqlcWf.TriggerSource), - SleepUntil: nullInt64ToPtr(sqlcWf.SleepUntil), - TraceID: sqlcWf.TraceID.String, - SpanID: sqlcWf.SpanID.String, - }) - } - - remainingLimit = limit - len(allWorkflows) - } - - return allWorkflows, nil -} - -func (s *sqlcStore) AcquireWorkflowLease(ctx context.Context, workflowID, namespace, workerID string, leaseDuration time.Duration) error { - now := s.clock.Now().UnixMilli() - expiresAt := now + leaseDuration.Milliseconds() - - // Begin transaction - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Create queries instance for the transaction - txQueries := sqlcstore.New(tx) - - // First, check if workflow is still available for leasing - workflow, err := txQueries.GetWorkflow(ctx, sqlcstore.GetWorkflowParams{ - ID: workflowID, - Namespace: namespace, - }) - if err != nil { - if err == sql.ErrNoRows { - return errors.New("workflow not found") - } - return err - } - - // Check if workflow is in a leasable state - status := WorkflowStatus(workflow.Status) - if status != WorkflowStatusPending && - status != WorkflowStatusFailed && - status != WorkflowStatusSleeping { - return errors.New("workflow not available for acquisition") - } - - // For failed workflows, check if retry time has passed - if status == WorkflowStatusFailed && workflow.NextRetryAt.Valid && workflow.NextRetryAt.Int64 > now { - return errors.New("workflow not ready for retry yet") - } - - // For sleeping workflows, check if sleep time has passed - if status == WorkflowStatusSleeping && workflow.SleepUntil.Valid && workflow.SleepUntil.Int64 > now { - return errors.New("workflow still sleeping") - } - - // Check for existing lease - existingLease, err := txQueries.GetLease(ctx, sqlcstore.GetLeaseParams{ - ResourceID: workflowID, - Kind: "workflow", - }) - - switch { - case err == nil: - // Lease exists - if existingLease.ExpiresAt > now { - if existingLease.WorkerID != workerID { - return errors.New("workflow already leased by another worker") - } - // Renew existing lease - err = txQueries.UpdateLease(ctx, sqlcstore.UpdateLeaseParams{ - WorkerID: workerID, - AcquiredAt: now, - ExpiresAt: expiresAt, - HeartbeatAt: now, - ResourceID: workflowID, - Kind: "workflow", - }) - if err != nil { - return err - } - } else { - // Take over expired lease - err = txQueries.UpdateLease(ctx, sqlcstore.UpdateLeaseParams{ - WorkerID: workerID, - AcquiredAt: now, - ExpiresAt: expiresAt, - HeartbeatAt: now, - ResourceID: workflowID, - Kind: "workflow", - }) - if err != nil { - return err - } - } - case err == sql.ErrNoRows: - // Create new lease - err = txQueries.CreateLease(ctx, sqlcstore.CreateLeaseParams{ - ResourceID: workflowID, - Kind: "workflow", - Namespace: namespace, - WorkerID: workerID, - AcquiredAt: now, - ExpiresAt: expiresAt, - HeartbeatAt: now, - }) - if err != nil { - // Check if it's a duplicate key error (race condition) - if isDuplicateKeyError(err) { - return errors.New("workflow already leased by another worker") - } - return err - } - default: - return err - } - - // Update workflow status to running - err = txQueries.UpdateWorkflowToRunning(ctx, sqlcstore.UpdateWorkflowToRunningParams{ - StartedAt: sql.NullInt64{Int64: now, Valid: true}, - ID: workflowID, - Namespace: namespace, - }) - if err != nil { - return err - } - - // Commit transaction - return tx.Commit() -} - -func (s *sqlcStore) UpdateWorkflowStatus(ctx context.Context, namespace, id string, status WorkflowStatus, errorMsg string) error { - params := sqlcstore.UpdateWorkflowStatusParams{ - Status: sqlcstore.WorkflowExecutionsStatus(status), - ErrorMessage: sql.NullString{String: errorMsg, Valid: errorMsg != ""}, - ID: id, - Namespace: namespace, - } - - return s.queries.UpdateWorkflowStatus(ctx, params) -} - -func (s *sqlcStore) CompleteWorkflow(ctx context.Context, namespace, id string, outputData []byte) error { - now := s.clock.Now().UnixMilli() - - params := sqlcstore.CompleteWorkflowParams{ - CompletedAt: sql.NullInt64{Int64: now, Valid: true}, - OutputData: outputData, - ID: id, - Namespace: namespace, - } - - return s.queries.CompleteWorkflow(ctx, params) -} - -func (s *sqlcStore) FailWorkflow(ctx context.Context, namespace, id string, errorMsg string, isFinal bool) error { - // First get the current workflow to check remaining attempts - workflow, err := s.GetWorkflow(ctx, namespace, id) - if err != nil { - return err - } - - now := s.clock.Now().UnixMilli() - newRemainingAttempts := workflow.RemainingAttempts - 1 - - if isFinal || newRemainingAttempts <= 0 { - // Final failure - no more retries - params := sqlcstore.FailWorkflowFinalParams{ - ErrorMessage: sql.NullString{String: errorMsg, Valid: errorMsg != ""}, - CompletedAt: sql.NullInt64{Int64: now, Valid: true}, - ID: id, - Namespace: namespace, - } - return s.queries.FailWorkflowFinal(ctx, params) - } else { - // Retry-eligible failure - calculate next retry time - attemptsUsed := workflow.MaxAttempts - newRemainingAttempts - backoffSeconds := int64(1 << attemptsUsed) - nextRetry := now + (backoffSeconds * 1000) - - params := sqlcstore.FailWorkflowWithRetryParams{ - ErrorMessage: sql.NullString{String: errorMsg, Valid: errorMsg != ""}, - NextRetryAt: sql.NullInt64{Int64: nextRetry, Valid: true}, - ID: id, - Namespace: namespace, - } - return s.queries.FailWorkflowWithRetry(ctx, params) - } -} - -func (s *sqlcStore) SleepWorkflow(ctx context.Context, namespace, id string, sleepUntil int64) error { - params := sqlcstore.SleepWorkflowParams{ - SleepUntil: sql.NullInt64{Int64: sleepUntil, Valid: true}, - ID: id, - Namespace: namespace, - } - - return s.queries.SleepWorkflow(ctx, params) -} - -func (s *sqlcStore) GetSleepingWorkflows(ctx context.Context, namespace string, beforeTime int64) ([]WorkflowExecution, error) { - params := sqlcstore.GetSleepingWorkflowsParams{ - Namespace: namespace, - SleepUntil: sql.NullInt64{Int64: beforeTime, Valid: true}, - } - - sqlcWorkflows, err := s.queries.GetSleepingWorkflows(ctx, params) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models - result := make([]WorkflowExecution, len(sqlcWorkflows)) - for i, sqlcWf := range sqlcWorkflows { - result[i] = WorkflowExecution{ - ID: sqlcWf.ID, - WorkflowName: sqlcWf.WorkflowName, - Status: WorkflowStatus(sqlcWf.Status), - InputData: sqlcWf.InputData, - OutputData: sqlcWf.OutputData, - ErrorMessage: sqlcWf.ErrorMessage.String, - CreatedAt: sqlcWf.CreatedAt, - StartedAt: nullInt64ToPtr(sqlcWf.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcWf.CompletedAt), - MaxAttempts: sqlcWf.MaxAttempts, - RemainingAttempts: sqlcWf.RemainingAttempts, - NextRetryAt: nullInt64ToPtr(sqlcWf.NextRetryAt), - Namespace: sqlcWf.Namespace, - TriggerType: TriggerType(sqlcWf.TriggerType.WorkflowExecutionsTriggerType), - TriggerSource: nullStringToPtr(sqlcWf.TriggerSource), - SleepUntil: nullInt64ToPtr(sqlcWf.SleepUntil), - TraceID: sqlcWf.TraceID.String, - SpanID: sqlcWf.SpanID.String, - } - } - return result, nil -} - -func (s *sqlcStore) CreateStep(ctx context.Context, step *WorkflowStep) error { - // Set ID if not already set (following GORM logic) - if step.ID == "" { - step.ID = step.ExecutionID + "-" + step.StepName - } - - var startedAt, completedAt sql.NullInt64 - if step.StartedAt != nil { - startedAt = sql.NullInt64{Int64: *step.StartedAt, Valid: true} - } - if step.CompletedAt != nil { - completedAt = sql.NullInt64{Int64: *step.CompletedAt, Valid: true} - } - - params := sqlcstore.CreateStepParams{ - ID: step.ID, - ExecutionID: step.ExecutionID, - StepName: step.StepName, - StepOrder: step.StepOrder, - Status: sqlcstore.WorkflowStepsStatus(step.Status), - OutputData: step.OutputData, - ErrorMessage: sql.NullString{String: step.ErrorMessage, Valid: step.ErrorMessage != ""}, - StartedAt: startedAt, - CompletedAt: completedAt, - MaxAttempts: step.MaxAttempts, - RemainingAttempts: step.RemainingAttempts, - Namespace: step.Namespace, - } - - return s.queries.CreateStep(ctx, params) -} - -func (s *sqlcStore) GetStep(ctx context.Context, namespace, executionID, stepName string) (*WorkflowStep, error) { - params := sqlcstore.GetStepParams{ - Namespace: namespace, - ExecutionID: executionID, - StepName: stepName, - } - - sqlcStep, err := s.queries.GetStep(ctx, params) - if err != nil { - if err == sql.ErrNoRows { - return nil, ErrStepNotFound - } - return nil, err - } - - // Convert SQLC model to store model - result := &WorkflowStep{ - ID: sqlcStep.ID, - ExecutionID: sqlcStep.ExecutionID, - StepName: sqlcStep.StepName, - StepOrder: sqlcStep.StepOrder, - Status: StepStatus(sqlcStep.Status), - OutputData: sqlcStep.OutputData, - ErrorMessage: sqlcStep.ErrorMessage.String, - StartedAt: nullInt64ToPtr(sqlcStep.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcStep.CompletedAt), - MaxAttempts: sqlcStep.MaxAttempts, - RemainingAttempts: sqlcStep.RemainingAttempts, - Namespace: sqlcStep.Namespace, - } - - return result, nil -} - -func (s *sqlcStore) GetCompletedStep(ctx context.Context, namespace, executionID, stepName string) (*WorkflowStep, error) { - params := sqlcstore.GetCompletedStepParams{ - Namespace: namespace, - ExecutionID: executionID, - StepName: stepName, - } - - sqlcStep, err := s.queries.GetCompletedStep(ctx, params) - if err != nil { - if err == sql.ErrNoRows { - return nil, ErrStepNotFound - } - return nil, err - } - - // Convert SQLC model to store model - result := &WorkflowStep{ - ID: sqlcStep.ID, - ExecutionID: sqlcStep.ExecutionID, - StepName: sqlcStep.StepName, - StepOrder: sqlcStep.StepOrder, - Status: StepStatus(sqlcStep.Status), - OutputData: sqlcStep.OutputData, - ErrorMessage: sqlcStep.ErrorMessage.String, - StartedAt: nullInt64ToPtr(sqlcStep.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcStep.CompletedAt), - MaxAttempts: sqlcStep.MaxAttempts, - RemainingAttempts: sqlcStep.RemainingAttempts, - Namespace: sqlcStep.Namespace, - } - - return result, nil -} - -func (s *sqlcStore) UpdateStepStatus(ctx context.Context, namespace, executionID, stepName string, status StepStatus, outputData []byte, errorMsg string) error { - now := s.clock.Now().UnixMilli() - - params := sqlcstore.UpdateStepStatusParams{ - Status: sqlcstore.WorkflowStepsStatus(status), - CompletedAt: sql.NullInt64{Int64: now, Valid: true}, - OutputData: outputData, - ErrorMessage: sql.NullString{String: errorMsg, Valid: errorMsg != ""}, - Namespace: namespace, - ExecutionID: executionID, - StepName: stepName, - } - - return s.queries.UpdateStepStatus(ctx, params) -} - -func (s *sqlcStore) UpsertCronJob(ctx context.Context, cronJob *CronJob) error { - // First try to get existing cron job - existing, err := s.queries.GetCronJob(ctx, sqlcstore.GetCronJobParams{ - Namespace: cronJob.Namespace, - Name: cronJob.Name, - }) - - if err == sql.ErrNoRows { - // Create new cron job - var lastRunAt sql.NullInt64 - if cronJob.LastRunAt != nil { - lastRunAt = sql.NullInt64{Int64: *cronJob.LastRunAt, Valid: true} - } - - params := sqlcstore.CreateCronJobParams{ - ID: cronJob.ID, - Name: cronJob.Name, - CronSpec: cronJob.CronSpec, - Namespace: cronJob.Namespace, - WorkflowName: sql.NullString{String: cronJob.WorkflowName, Valid: cronJob.WorkflowName != ""}, - Enabled: cronJob.Enabled, - CreatedAt: cronJob.CreatedAt, - UpdatedAt: cronJob.UpdatedAt, - LastRunAt: lastRunAt, - NextRunAt: cronJob.NextRunAt, - } - return s.queries.CreateCronJob(ctx, params) - } else if err != nil { - return err - } - - // Update existing cron job - cronJob.ID = existing.ID - cronJob.CreatedAt = existing.CreatedAt - cronJob.UpdatedAt = s.clock.Now().UnixMilli() - - params := sqlcstore.UpdateCronJobParams{ - CronSpec: cronJob.CronSpec, - WorkflowName: sql.NullString{String: cronJob.WorkflowName, Valid: cronJob.WorkflowName != ""}, - Enabled: cronJob.Enabled, - UpdatedAt: cronJob.UpdatedAt, - NextRunAt: cronJob.NextRunAt, - ID: cronJob.ID, - Namespace: cronJob.Namespace, - } - return s.queries.UpdateCronJob(ctx, params) -} - -func (s *sqlcStore) GetCronJob(ctx context.Context, namespace, name string) (*CronJob, error) { - params := sqlcstore.GetCronJobParams{ - Namespace: namespace, - Name: name, - } - - sqlcCronJob, err := s.queries.GetCronJob(ctx, params) - if err != nil { - if err == sql.ErrNoRows { - return nil, errors.New("cron job not found") - } - return nil, err - } - - // Convert SQLC model to store model - result := &CronJob{ - ID: sqlcCronJob.ID, - Name: sqlcCronJob.Name, - CronSpec: sqlcCronJob.CronSpec, - Namespace: sqlcCronJob.Namespace, - WorkflowName: sqlcCronJob.WorkflowName.String, - Enabled: sqlcCronJob.Enabled, - CreatedAt: sqlcCronJob.CreatedAt, - UpdatedAt: sqlcCronJob.UpdatedAt, - LastRunAt: nullInt64ToPtr(sqlcCronJob.LastRunAt), - NextRunAt: sqlcCronJob.NextRunAt, - } - - return result, nil -} - -func (s *sqlcStore) GetCronJobs(ctx context.Context, namespace string) ([]CronJob, error) { - sqlcCronJobs, err := s.queries.GetCronJobs(ctx, namespace) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models - result := make([]CronJob, len(sqlcCronJobs)) - for i, sqlcCron := range sqlcCronJobs { - result[i] = CronJob{ - ID: sqlcCron.ID, - Name: sqlcCron.Name, - CronSpec: sqlcCron.CronSpec, - Namespace: sqlcCron.Namespace, - WorkflowName: sqlcCron.WorkflowName.String, - Enabled: sqlcCron.Enabled, - CreatedAt: sqlcCron.CreatedAt, - UpdatedAt: sqlcCron.UpdatedAt, - LastRunAt: nullInt64ToPtr(sqlcCron.LastRunAt), - NextRunAt: sqlcCron.NextRunAt, - } - } - return result, nil -} - -func (s *sqlcStore) GetDueCronJobs(ctx context.Context, namespace string, beforeTime int64) ([]CronJob, error) { - params := sqlcstore.GetDueCronJobsParams{ - Namespace: namespace, - NextRunAt: beforeTime, - } - - sqlcCronJobs, err := s.queries.GetDueCronJobs(ctx, params) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models - result := make([]CronJob, len(sqlcCronJobs)) - for i, sqlcCron := range sqlcCronJobs { - result[i] = CronJob{ - ID: sqlcCron.ID, - Name: sqlcCron.Name, - CronSpec: sqlcCron.CronSpec, - Namespace: sqlcCron.Namespace, - WorkflowName: sqlcCron.WorkflowName.String, - Enabled: sqlcCron.Enabled, - CreatedAt: sqlcCron.CreatedAt, - UpdatedAt: sqlcCron.UpdatedAt, - LastRunAt: nullInt64ToPtr(sqlcCron.LastRunAt), - NextRunAt: sqlcCron.NextRunAt, - } - } - return result, nil -} - -func (s *sqlcStore) UpdateCronJobLastRun(ctx context.Context, namespace, cronJobID string, lastRunAt, nextRunAt int64) error { - now := s.clock.Now().UnixMilli() - - params := sqlcstore.UpdateCronJobLastRunParams{ - LastRunAt: sql.NullInt64{Int64: lastRunAt, Valid: true}, - NextRunAt: nextRunAt, - UpdatedAt: now, - ID: cronJobID, - Namespace: namespace, - } - - return s.queries.UpdateCronJobLastRun(ctx, params) -} - -func (s *sqlcStore) AcquireLease(ctx context.Context, lease *Lease) error { - params := sqlcstore.CreateLeaseParams{ - ResourceID: lease.ResourceID, - Kind: sqlcstore.LeasesKind(lease.Kind), - Namespace: lease.Namespace, - WorkerID: lease.WorkerID, - AcquiredAt: lease.AcquiredAt, - ExpiresAt: lease.ExpiresAt, - HeartbeatAt: lease.HeartbeatAt, - } - - err := s.queries.CreateLease(ctx, params) - if err != nil { - if isDuplicateKeyError(err) { - return errors.New("lease already held by another worker") - } - return err - } - return nil -} - -func (s *sqlcStore) HeartbeatLease(ctx context.Context, resourceID, workerID string, expiresAt int64) error { - now := s.clock.Now().UnixMilli() - - params := sqlcstore.HeartbeatLeaseParams{ - HeartbeatAt: now, - ExpiresAt: expiresAt, - ResourceID: resourceID, - WorkerID: workerID, - } - - // Execute the update - SQLC :exec queries don't return results for row counting - // We'll trust that if no error occurs, the update was successful - return s.queries.HeartbeatLease(ctx, params) -} - -func (s *sqlcStore) ReleaseLease(ctx context.Context, resourceID, workerID string) error { - params := sqlcstore.ReleaseLeaseParams{ - ResourceID: resourceID, - WorkerID: workerID, - } - - return s.queries.ReleaseLease(ctx, params) -} - -func (s *sqlcStore) GetLease(ctx context.Context, resourceID string) (*Lease, error) { - params := sqlcstore.GetLeaseParams{ - ResourceID: resourceID, - Kind: "workflow", // Default to workflow kind for this use case - } - - sqlcLease, err := s.queries.GetLease(ctx, params) - if err != nil { - if err == sql.ErrNoRows { - return nil, errors.New("lease not found") - } - return nil, err - } - - // Convert SQLC model to store model - result := &Lease{ - ResourceID: sqlcLease.ResourceID, - Kind: string(sqlcLease.Kind), - Namespace: sqlcLease.Namespace, - WorkerID: sqlcLease.WorkerID, - AcquiredAt: sqlcLease.AcquiredAt, - ExpiresAt: sqlcLease.ExpiresAt, - HeartbeatAt: sqlcLease.HeartbeatAt, - } - - return result, nil -} - -func (s *sqlcStore) CleanupExpiredLeases(ctx context.Context, namespace string) error { - now := s.clock.Now().UnixMilli() - - params := sqlcstore.CleanupExpiredLeasesParams{ - Namespace: namespace, - ExpiresAt: now, - } - - return s.queries.CleanupExpiredLeases(ctx, params) -} - -func (s *sqlcStore) GetExpiredLeases(ctx context.Context, namespace string) ([]Lease, error) { - now := s.clock.Now().UnixMilli() - - params := sqlcstore.GetExpiredLeasesParams{ - Namespace: namespace, - ExpiresAt: now, - } - - sqlcLeases, err := s.queries.GetExpiredLeases(ctx, params) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models - result := make([]Lease, len(sqlcLeases)) - for i, sqlcLease := range sqlcLeases { - result[i] = Lease{ - ResourceID: sqlcLease.ResourceID, - Kind: string(sqlcLease.Kind), - Namespace: sqlcLease.Namespace, - WorkerID: sqlcLease.WorkerID, - AcquiredAt: sqlcLease.AcquiredAt, - ExpiresAt: sqlcLease.ExpiresAt, - HeartbeatAt: sqlcLease.HeartbeatAt, - } - } - return result, nil -} - -func (s *sqlcStore) ResetOrphanedWorkflows(ctx context.Context, namespace string) error { - params := sqlcstore.ResetOrphanedWorkflowsParams{ - Namespace: namespace, - Namespace_2: namespace, // Same namespace used for both conditions - } - - return s.queries.ResetOrphanedWorkflows(ctx, params) -} - -func (s *sqlcStore) WithTx(ctx context.Context, fn func(Store) error) error { - // Begin transaction - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - // Create a new SQLC store instance with the transaction - txQueries := s.queries.WithTx(tx) - txStore := &sqlcStore{ - db: s.db, // Keep the original DB for potential nested operations - queries: txQueries, - clock: s.clock, - } - - // Execute the function with the transactional store - if err := fn(txStore); err != nil { - return err - } - - // Commit the transaction - return tx.Commit() -} - -func (s *sqlcStore) GetAllWorkflows(ctx context.Context, namespace string) ([]WorkflowExecution, error) { - sqlcWorkflows, err := s.queries.GetAllWorkflows(ctx, namespace) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models (reusing conversion logic from GetPendingWorkflows) - result := make([]WorkflowExecution, len(sqlcWorkflows)) - for i, sqlcWf := range sqlcWorkflows { - result[i] = WorkflowExecution{ - ID: sqlcWf.ID, - WorkflowName: sqlcWf.WorkflowName, - Status: WorkflowStatus(sqlcWf.Status), - InputData: sqlcWf.InputData, - OutputData: sqlcWf.OutputData, - ErrorMessage: sqlcWf.ErrorMessage.String, - CreatedAt: sqlcWf.CreatedAt, - StartedAt: nullInt64ToPtr(sqlcWf.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcWf.CompletedAt), - MaxAttempts: sqlcWf.MaxAttempts, - RemainingAttempts: sqlcWf.RemainingAttempts, - NextRetryAt: nullInt64ToPtr(sqlcWf.NextRetryAt), - Namespace: sqlcWf.Namespace, - TriggerType: TriggerType(sqlcWf.TriggerType.WorkflowExecutionsTriggerType), - TriggerSource: nullStringToPtr(sqlcWf.TriggerSource), - SleepUntil: nullInt64ToPtr(sqlcWf.SleepUntil), - TraceID: sqlcWf.TraceID.String, - SpanID: sqlcWf.SpanID.String, - } - } - return result, nil -} - -func (s *sqlcStore) GetAllSteps(ctx context.Context, namespace string) ([]WorkflowStep, error) { - sqlcSteps, err := s.queries.GetAllSteps(ctx, namespace) - if err != nil { - return nil, err - } - - // Convert SQLC models to store models (reusing conversion logic from GetStep) - result := make([]WorkflowStep, len(sqlcSteps)) - for i, sqlcStep := range sqlcSteps { - result[i] = WorkflowStep{ - ID: sqlcStep.ID, - ExecutionID: sqlcStep.ExecutionID, - StepName: sqlcStep.StepName, - StepOrder: sqlcStep.StepOrder, - Status: StepStatus(sqlcStep.Status), - OutputData: sqlcStep.OutputData, - ErrorMessage: sqlcStep.ErrorMessage.String, - StartedAt: nullInt64ToPtr(sqlcStep.StartedAt), - CompletedAt: nullInt64ToPtr(sqlcStep.CompletedAt), - MaxAttempts: sqlcStep.MaxAttempts, - RemainingAttempts: sqlcStep.RemainingAttempts, - Namespace: sqlcStep.Namespace, - } - } - return result, nil -} - -// Helper functions for converting from nullable types -func nullInt64ToPtr(n sql.NullInt64) *int64 { - if !n.Valid { - return nil - } - return &n.Int64 -} - -func nullStringToPtr(n sql.NullString) *string { - if !n.Valid { - return nil - } - return &n.String -} - -func isDuplicateKeyError(err error) bool { - errStr := err.Error() - return strings.Contains(errStr, "duplicate") || - strings.Contains(errStr, "UNIQUE constraint") || - strings.Contains(errStr, "PRIMARY KEY constraint") -} diff --git a/go/pkg/hydra/worker.go b/go/pkg/hydra/worker.go index fd93930560..afa53d2ea0 100644 --- a/go/pkg/hydra/worker.go +++ b/go/pkg/hydra/worker.go @@ -10,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/circuitbreaker" "github.com/unkeyed/unkey/go/pkg/clock" + "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/hydra/metrics" "github.com/unkeyed/unkey/go/pkg/hydra/store" "github.com/unkeyed/unkey/go/pkg/otel/tracing" @@ -400,12 +401,12 @@ func (w *worker) executeWorkflow(ctx context.Context, e *store.WorkflowExecution // Use lease-validated failure to ensure correctness failureTime := w.clock.Now().UnixMilli() result, failErr := w.engine.GetDB().ExecContext(ctx, ` - UPDATE workflow_executions + UPDATE workflow_executions SET status = 'failed', error_message = ?, remaining_attempts = remaining_attempts - 1, completed_at = ?, next_retry_at = NULL WHERE id = ? AND workflow_executions.namespace = ? AND EXISTS ( - SELECT 1 FROM leases - WHERE resource_id = ? AND kind = 'workflow' + SELECT 1 FROM leases + WHERE resource_id = ? AND kind = 'workflow' AND worker_id = ? AND expires_at > ? )`, sql.NullString{String: noHandlerErr.Error(), Valid: true}, @@ -494,12 +495,12 @@ func (w *worker) executeWorkflow(ctx context.Context, e *store.WorkflowExecution if isFinal { // Final failure - no more retries result, failErr = w.engine.GetDB().ExecContext(ctx, ` - UPDATE workflow_executions + UPDATE workflow_executions SET status = 'failed', error_message = ?, remaining_attempts = remaining_attempts - 1, completed_at = ?, next_retry_at = NULL WHERE id = ? AND workflow_executions.namespace = ? AND EXISTS ( - SELECT 1 FROM leases - WHERE resource_id = ? AND kind = 'workflow' + SELECT 1 FROM leases + WHERE resource_id = ? AND kind = 'workflow' AND worker_id = ? AND expires_at > ? )`, sql.NullString{String: err.Error(), Valid: true}, @@ -514,12 +515,12 @@ func (w *worker) executeWorkflow(ctx context.Context, e *store.WorkflowExecution // Failure with retry - calculate next retry time nextRetryAt := w.clock.Now().Add(time.Duration(e.MaxAttempts-e.RemainingAttempts+1) * time.Second).UnixMilli() result, failErr = w.engine.GetDB().ExecContext(ctx, ` - UPDATE workflow_executions + UPDATE workflow_executions SET status = 'failed', error_message = ?, remaining_attempts = remaining_attempts - 1, next_retry_at = ? WHERE id = ? AND workflow_executions.namespace = ? AND EXISTS ( - SELECT 1 FROM leases - WHERE resource_id = ? AND kind = 'workflow' + SELECT 1 FROM leases + WHERE resource_id = ? AND kind = 'workflow' AND worker_id = ? AND expires_at > ? )`, sql.NullString{String: err.Error(), Valid: true}, @@ -570,12 +571,12 @@ func (w *worker) executeWorkflow(ctx context.Context, e *store.WorkflowExecution // Use lease-validated completion to ensure correctness now = w.clock.Now().UnixMilli() result, err := w.engine.GetDB().ExecContext(ctx, ` - UPDATE workflow_executions + UPDATE workflow_executions SET status = 'completed', completed_at = ?, output_data = ? WHERE id = ? AND workflow_executions.namespace = ? AND EXISTS ( - SELECT 1 FROM leases - WHERE resource_id = ? AND kind = 'workflow' + SELECT 1 FROM leases + WHERE resource_id = ? AND kind = 'workflow' AND worker_id = ? AND expires_at > ? )`, sql.NullInt64{Int64: now, Valid: true}, @@ -843,12 +844,12 @@ func (w *worker) executeCronJob(ctx context.Context, cronJob store.CronJob) { nextRun := calculateNextRun(cronJob.CronSpec, w.engine.clock.Now()) updateTime := w.engine.clock.Now().UnixMilli() result, err := w.engine.GetDB().ExecContext(ctx, ` - UPDATE cron_jobs + UPDATE cron_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ? AND namespace = ? AND EXISTS ( - SELECT 1 FROM leases - WHERE resource_id = ? AND kind = 'cron_job' + SELECT 1 FROM leases + WHERE resource_id = ? AND kind = 'cron_job' AND worker_id = ? AND expires_at > ? )`, sql.NullInt64{Int64: now, Valid: true}, @@ -908,7 +909,7 @@ func (w *worker) acquireWorkflowLease(ctx context.Context, workflowID, workerID Namespace: w.engine.namespace, }) if err != nil { - if err == sql.ErrNoRows { + if db.IsNotFound(err) { return fmt.Errorf("workflow not found") } return err @@ -946,7 +947,7 @@ func (w *worker) acquireWorkflowLease(ctx context.Context, workflowID, workerID if err != nil { // If lease creation failed, try to take over ONLY expired leases leaseResult, leaseErr := tx.ExecContext(ctx, ` - UPDATE leases + UPDATE leases SET worker_id = ?, acquired_at = ?, expires_at = ?, heartbeat_at = ? WHERE resource_id = ? AND kind = ? AND expires_at < ?`, workerID, now, expiresAt, now, workflowID, store.LeasesKindWorkflow, now) diff --git a/go/pkg/zen/README.md b/go/pkg/zen/README.md index 6d04d74b3b..bfda47d98b 100644 --- a/go/pkg/zen/README.md +++ b/go/pkg/zen/README.md @@ -4,12 +4,12 @@

Read our blog post about why we built Zen and how it works

- Zen is a lightweight, minimalistic HTTP framework for Go, designed to wrap the standard library with just enough abstraction to streamline your development processβ€”nothing more, nothing less. ## Why "Zen"? + The name "Zen" reflects the philosophy behind the framework: simplicity, clarity, and efficiency. @@ -21,6 +21,7 @@ clarity, and efficiency. complexity or dependencies. ## Features + - Built directly on the Go standard library (net/http). - Thin abstractions for routing, middleware, and error handling. - Support for HTTPS connections with TLS certificates. @@ -90,8 +91,9 @@ func main() { createUserRoute := zen.NewRoute("POST", "/users", func(ctx context.Context, s *zen.Session) error { // Parse request body var req CreateUserRequest - if err := s.BindBody(&req); err != nil { - return err // This will be handled by error middleware + req, err := zen.BindBody[CreateUserRequest](s) + if err != nil { + return err } // Additional validation logic @@ -167,7 +169,7 @@ func main() { if err != nil { log.Fatalf("failed to load TLS configuration: %v", err) } - + // Create a server with TLS configuration server, err := zen.New(zen.Config{ TLS: tlsConfig, @@ -175,9 +177,9 @@ func main() { if err != nil { log.Fatalf("failed to create server: %v", err) } - + // Register routes... - + // Start the HTTPS server with context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -239,6 +241,7 @@ err := server.Shutdown(shutdownCtx) ``` When a server's context is canceled, it will: + 1. Stop accepting new connections 2. Complete any in-flight requests 3. Release resources and exit gracefully diff --git a/go/pkg/zen/middleware_errors.go b/go/pkg/zen/middleware_errors.go index 9d2c8c332a..06607b162d 100644 --- a/go/pkg/zen/middleware_errors.go +++ b/go/pkg/zen/middleware_errors.go @@ -16,7 +16,6 @@ func WithErrorHandling(logger logging.Logger) Middleware { return func(next HandleFunc) HandleFunc { return func(ctx context.Context, s *Session) error { err := next(ctx, s) - if err == nil { return nil } diff --git a/go/pkg/zen/request_util.go b/go/pkg/zen/request_util.go index 3f3b97ee7e..d7b2766846 100644 --- a/go/pkg/zen/request_util.go +++ b/go/pkg/zen/request_util.go @@ -8,7 +8,6 @@ import ( // BindBody binds the request body to the given struct. // If it fails, an error is returned, that you can directly return from your handler. func BindBody[T any](s *Session) (T, error) { - // nolint:exhaustruct var req T err := s.BindBody(&req) @@ -20,5 +19,4 @@ func BindBody[T any](s *Session) (T, error) { } return req, nil - }