From fb661ebfb9dfa701c7663404da049679e91da3cb Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Tue, 22 Jul 2025 20:11:15 +0200 Subject: [PATCH 01/20] change some endpoints --- go/apps/api/openapi/gen.go | 163 ++------ go/apps/api/openapi/openapi-generated.yaml | 372 +++++------------- .../EmptyResponse.yaml} | 2 +- .../openapi/spec/common/KeyResponseData.yaml | 2 +- .../api/openapi/spec/common/permission.yaml | 14 +- .../V2ApisDeleteApiResponseBody.yaml | 3 + .../V2KeysAddPermissionsRequestBody.yaml | 50 +-- .../V2KeysAddPermissionsResponseData.yaml | 25 +- .../V2KeysDeleteKeyResponseBody.yaml | 3 +- .../V2KeysRemovePermissionsRequestBody.yaml | 34 +- .../V2KeysRemovePermissionsResponseData.yaml | 37 +- .../v2/keys/removePermissions/index.yaml | 60 +-- .../V2KeysSetPermissionsRequestBody.yaml | 57 +-- .../V2KeysSetPermissionsResponseData.yaml | 14 +- .../V2KeysUpdateKeyResponseBody.yaml | 3 +- .../V2KeysUpdateKeyResponseData.yaml | 4 - ...ermissionsDeletePermissionRequestBody.yaml | 9 +- ...rmissionsDeletePermissionResponseBody.yaml | 3 + .../V2PermissionsDeleteRoleResponseBody.yaml | 3 + .../v2_keys_set_permissions/404_test.go | 31 +- .../routes/v2_keys_set_permissions/handler.go | 309 ++++++--------- .../api/routes/v2_keys_update_key/handler.go | 2 +- .../200_test.go | 4 +- .../400_test.go | 4 +- .../401_test.go | 2 +- .../403_test.go | 4 +- .../404_test.go | 6 +- .../handler.go | 41 +- .../v2_permissions_get_permission/200_test.go | 1 - .../v2_permissions_get_permission/handler.go | 1 - .../routes/v2_permissions_get_role/handler.go | 1 - .../handler.go | 1 - .../v2_permissions_list_roles/handler.go | 1 - go/pkg/db/bulk_version_insert.sql.go | 5 +- ...by_key_and_permission_ids.sql_generated.go | 41 ++ ...ission_find_by_id_or_slug.sql_generated.go | 42 ++ ...n_find_many_by_id_or_slug.sql_generated.go | 77 ++++ go/pkg/db/querier_generated.go | 17 + ..._delete_many_by_key_and_permission_ids.sql | 3 + .../queries/permission_find_by_id_or_slug.sql | 4 + .../permission_find_many_by_id_or_slug.sql | 4 + 41 files changed, 555 insertions(+), 904 deletions(-) rename go/apps/api/openapi/spec/{paths/v2/keys/deleteKey/KeysDeleteKeyResponseData.yaml => common/EmptyResponse.yaml} (68%) delete mode 100644 go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseData.yaml create mode 100644 go/pkg/db/key_permission_delete_many_by_key_and_permission_ids.sql_generated.go create mode 100644 go/pkg/db/permission_find_by_id_or_slug.sql_generated.go create mode 100644 go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go create mode 100644 go/pkg/db/queries/key_permission_delete_many_by_key_and_permission_ids.sql create mode 100644 go/pkg/db/queries/permission_find_by_id_or_slug.sql create mode 100644 go/pkg/db/queries/permission_find_many_by_id_or_slug.sql diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index e687f6c5f3..0d818aa5b4 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -96,6 +96,9 @@ type ConflictErrorResponse struct { Meta Meta `json:"meta"` } +// EmptyResponse Empty response object by design. A successful response indicates this operation was successfully executed. +type EmptyResponse = map[string]interface{} + // ForbiddenErrorResponse Error response when the provided credentials are valid but lack sufficient permissions for the requested operation. This occurs when: // - The root key doesn't have the required permissions for this endpoint // - The operation requires elevated privileges that the current key lacks @@ -202,9 +205,6 @@ type KeyResponseData struct { UpdatedAt *int64 `json:"updatedAt,omitempty"` } -// KeysDeleteKeyResponseData Empty response object by design. A successful response indicates the key was deleted successfully. -type KeysDeleteKeyResponseData = map[string]interface{} - // KeysVerifyKeyCredits Controls credit consumption for usage-based billing and quota enforcement. // Omitting this field uses the default cost of 1 credit per verification. // Credits provide globally consistent usage tracking, essential for paid APIs with strict quotas. @@ -305,11 +305,6 @@ type Pagination struct { // Permission defines model for Permission. type Permission struct { - // CreatedAt Unix timestamp in milliseconds indicating when this permission was first created. - // Useful for auditing and understanding the evolution of your permission structure. - // Automatically set by the system and cannot be modified. - CreatedAt int64 `json:"createdAt"` - // Description Optional detailed explanation of what this permission grants access to. // Helps team members understand the scope and implications of granting this permission. // Include information about what resources can be accessed and what actions can be performed. @@ -550,6 +545,9 @@ type V2ApisDeleteApiRequestBody struct { // V2ApisDeleteApiResponseBody defines model for V2ApisDeleteApiResponseBody. type V2ApisDeleteApiResponseBody struct { + // Data Empty response object by design. A successful response indicates this operation was successfully executed. + Data EmptyResponse `json:"data"` + // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. Meta Meta `json:"meta"` } @@ -779,39 +777,16 @@ type V2IdentitiesUpdateIdentityResponseBody struct { type V2KeysAddPermissionsRequestBody struct { // KeyId Specifies which key receives the additional permissions using the database identifier returned from `keys.createKey`. // Do not confuse this with the actual API key string that users include in requests. - // Added permissions supplement existing permissions and roles without replacing them. - // Permission changes take effect immediately but may take up to 30 seconds to propagate across all regions. KeyId string `json:"keyId"` // Permissions Grants additional permissions to the key through direct assignment or automatic creation. // Duplicate permissions are ignored automatically, making this operation idempotent. - // Use either ID for existing permissions or slug for new permissions with optional auto-creation. // - // Permission changes take effect immediately but cache propagation across regions may take up to 30 seconds. + // You can either use a permission slug, or the permission ID. + // + // If slugs are used, the permission will be auto created IF the root key has the given permissions, otherwise this operation will fail with a 404 error. // Adding permissions never removes existing permissions or role-based permissions. - Permissions []struct { - // Create Enables automatic permission creation when the specified slug does not exist. - // Only works with slug-based references, not ID-based references. - // Requires the `rbac.*.create_permission` permission on your root key. - // - // Created permissions are permanent and visible workspace-wide to all API keys. - // Use carefully to avoid permission proliferation from typos or uncontrolled creation. - // Consider centralizing permission creation in controlled processes for better governance. - // Auto-created permissions use the slug as both the name and identifier. - Create *bool `json:"create,omitempty"` - - // Id References an existing permission by its database identifier. - // Use when you know the exact permission ID and want to ensure you're referencing a specific permission. - // Takes precedence over slug when both are provided in the same object. - // The referenced permission must already exist in your workspace. - Id *string `json:"id,omitempty"` - - // Slug Identifies the permission by its human-readable name using hierarchical naming patterns. - // Use `resource.action` format for logical organization and verification flexibility. - // Slugs must be unique within your `workspace` and support wildcard matching during verification. - // Combined with `create=true`, allows automatic permission creation for streamlined workflows. - Slug *string `json:"slug,omitempty"` - } `json:"permissions"` + Permissions []string `json:"permissions"` } // V2KeysAddPermissionsResponseBody defines model for V2KeysAddPermissionsResponseBody. @@ -844,16 +819,7 @@ type V2KeysAddPermissionsResponseBody struct { // - This list does NOT include permissions granted through roles // - For a complete permission picture, use `/v2/keys.getKey` instead // - An empty array indicates the key has no direct permissions assigned -type V2KeysAddPermissionsResponseData = []struct { - // Id The unique identifier of the permission (begins with `perm_`). This ID can be used in other API calls to reference this specific permission. IDs are guaranteed unique and won't change, making them ideal for scripting and automation. You can store these IDs in your system for consistent reference. - Id string `json:"id"` - - // Name The human-readable name of the permission. - Name string `json:"name"` - - // Slug The slug of the permission, typically following a `resource.action` pattern like `documents.read`. - Slug string `json:"slug"` -} +type V2KeysAddPermissionsResponseData = []Permission // V2KeysAddRolesRequestBody defines model for V2KeysAddRolesRequestBody. type V2KeysAddRolesRequestBody struct { @@ -1043,8 +1009,8 @@ type V2KeysDeleteKeyRequestBody struct { // V2KeysDeleteKeyResponseBody defines model for V2KeysDeleteKeyResponseBody. type V2KeysDeleteKeyResponseBody struct { - // Data Empty response object by design. A successful response indicates the key was deleted successfully. - Data *KeysDeleteKeyResponseData `json:"data,omitempty"` + // Data Empty response object by design. A successful response indicates this operation was successfully executed. + Data EmptyResponse `json:"data"` // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. Meta Meta `json:"meta"` @@ -1092,27 +1058,14 @@ type V2KeysGetKeyResponseBody struct { type V2KeysRemovePermissionsRequestBody struct { // KeyId Specifies which key to remove permissions from using the database identifier returned from `keys.createKey`. // Do not confuse this with the actual API key string that users include in requests. - // Removing permissions only affects direct assignments, not permissions inherited from roles. - // Permission changes take effect immediately but may take up to 30 seconds to propagate across all regions. KeyId string `json:"keyId"` // Permissions Removes direct permissions from the key without affecting role-based permissions. - // Operations are idempotent - removing non-existent permissions has no effect and causes no errors. - // Use either ID for existing permissions or name for exact string matching. + // + // You can either use a permission slug, or the permission ID. // // After removal, verification checks for these permissions will fail unless granted through roles. - // Permission changes take effect immediately but cache propagation across regions may take up to 30 seconds. - // Removing all direct permissions does not disable the key, only removes its direct permission grants. - Permissions []struct { - // Id References the permission to remove by its database identifier. - // Use when you know the exact permission ID and want to ensure you're removing a specific permission. - // Takes precedence over name when both are provided in the same object. - // Essential for automation scripts where precision prevents accidental permission removal. - Id *string `json:"id,omitempty"` - - // Slug Identifies the permission by slug for removal from the key's direct assignment list. - Slug *string `json:"slug,omitempty"` - } `json:"permissions"` + Permissions []string `json:"permissions"` } // V2KeysRemovePermissionsResponseBody defines model for V2KeysRemovePermissionsResponseBody. @@ -1124,12 +1077,10 @@ type V2KeysRemovePermissionsResponseBody struct { // - Permissions sorted alphabetically by name for consistent response format // - Both the permission ID and name for each remaining permission // - // Important notes: + // Notes: // - This list does NOT include permissions granted through roles // - For a complete permission picture, use `/v2/keys.getKey` instead // - An empty array indicates the key has no direct permissions assigned - // - Any cached versions of the key are immediately invalidated to ensure consistency - // - Changes to permissions take effect within seconds for new verifications // - All permission removals are logged to the audit log for security tracking Data V2KeysRemovePermissionsResponseData `json:"data"` @@ -1144,31 +1095,12 @@ type V2KeysRemovePermissionsResponseBody struct { // - Permissions sorted alphabetically by name for consistent response format // - Both the permission ID and name for each remaining permission // -// Important notes: +// Notes: // - This list does NOT include permissions granted through roles // - For a complete permission picture, use `/v2/keys.getKey` instead // - An empty array indicates the key has no direct permissions assigned -// - Any cached versions of the key are immediately invalidated to ensure consistency -// - Changes to permissions take effect within seconds for new verifications // - All permission removals are logged to the audit log for security tracking -type V2KeysRemovePermissionsResponseData = []struct { - // Id The unique identifier of the permission (begins with `perm_`). This ID can be used in other API calls to reference this specific permission. IDs are guaranteed unique and won't change, making them ideal for scripting and automation. You can store these IDs in your system for consistent reference. - Id string `json:"id"` - - // Name The name of the permission - Name string `json:"name"` - - // Slug The slug of the permission, typically following a `resource.action` pattern like `documents.read`. Names are human-readable identifiers used both for assignment and verification. - // - // During verification: - // - The exact name is matched (e.g., `documents.read`) - // - Hierarchical wildcards are supported in verification requests - // - A key with permission `documents.*` grants access to `documents.read`, `documents.write`, etc. - // - Wildcards can appear at any level: `billing.*.view` matches `billing.invoices.view` and `billing.payments.view` - // - // However, when adding permissions, you must specify each exact permission - wildcards are not valid for assignment. - Slug string `json:"slug"` -} +type V2KeysRemovePermissionsResponseData = []Permission // V2KeysRemoveRolesRequestBody defines model for V2KeysRemoveRolesRequestBody. type V2KeysRemoveRolesRequestBody struct { @@ -1246,36 +1178,19 @@ type V2KeysRemoveRolesResponseData = []struct { // V2KeysSetPermissionsRequestBody defines model for V2KeysSetPermissionsRequestBody. type V2KeysSetPermissionsRequestBody struct { - // KeyId The unique identifier of the key to set permissions on (begins with 'key_'). This ID comes from the createKey response and identifies which key will have its permissions replaced. This is the database ID, not the actual API key string that users authenticate with. + // KeyId Specifies which key receives the additional permissions using the database identifier returned from `keys.createKey`. + // Do not confuse this with the actual API key string that users include in requests. KeyId string `json:"keyId"` - // Permissions The permissions to set for this key. This is a complete replacement operation - it overwrites all existing direct permissions with this new set. + // Permissions The permissions to set for this key. + // + // This is a complete replacement operation - it overwrites all existing direct permissions with this new set. // // Key behaviors: // - Providing an empty array removes all direct permissions from the key // - This only affects direct permissions - permissions granted through roles are not affected // - All existing direct permissions not included in this list will be removed - // - The complete list approach allows synchronizing permissions with external systems - // - Permission changes take effect immediately for new verifications - // - // Unlike addPermissions (which only adds) or removePermissions (which only removes), this endpoint performs a wholesale replacement of the permission set. - Permissions []struct { - // Create When true, if a permission with this slug doesn't exist, it will be automatically created on-the-fly. Only works when specifying slug, not ID. - // - // SECURITY CONSIDERATIONS: - // - Requires the `rbac.*.create_permission` permission on your root key - // - Created permissions are permanent and visible throughout your workspace - // - Use carefully to avoid permission proliferation and inconsistency - // - Consider using a controlled process for permission creation instead - // - Typos with `create=true` will create unintended permissions that persist in your system - Create *bool `json:"create,omitempty"` - - // Id The ID of an existing permission (begins with `perm_`). Provide either ID or slug for each permission, not both. Using ID is more precise and guarantees you're referencing the exact permission intended, regardless of slug changes or duplicates. IDs are particularly useful in automation scripts and when migrating permissions between environments. - Id *string `json:"id,omitempty"` - - // Slug The slug of the permission. Provide either ID or slug for each permission, not both. Slugs must match exactly as defined in your permission system - including case sensitivity and the complete hierarchical path. Slugs are generally more human-readable but can be ambiguous if not carefully managed across your workspace. - Slug *string `json:"slug,omitempty"` - } `json:"permissions"` + Permissions []string `json:"permissions"` } // V2KeysSetPermissionsResponseBody defines model for V2KeysSetPermissionsResponseBody. @@ -1310,13 +1225,7 @@ type V2KeysSetPermissionsResponseBody struct { // - An empty array means the key has no direct permissions assigned // - For a complete permission picture including roles, use keys.getKey instead // - All permission changes are logged in the audit log for security tracking -type V2KeysSetPermissionsResponseData = []struct { - // Id The unique identifier of the permission - Id string `json:"id"` - - // Name The name of the permission - Name string `json:"name"` -} +type V2KeysSetPermissionsResponseData = []Permission // V2KeysSetRolesRequestBody defines model for V2KeysSetRolesRequestBody. type V2KeysSetRolesRequestBody struct { @@ -1487,16 +1396,13 @@ 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. - Data *V2KeysUpdateKeyResponseData `json:"data,omitempty"` + // Data Empty response object by design. A successful response indicates this operation was successfully executed. + Data EmptyResponse `json:"data"` // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. Meta Meta `json:"meta"` } -// V2KeysUpdateKeyResponseData Empty response object by design. A successful response indicates the key was updated successfully. -type V2KeysUpdateKeyResponseData = map[string]interface{} - // V2KeysVerifyKeyRequestBody defines model for V2KeysVerifyKeyRequestBody. type V2KeysVerifyKeyRequestBody struct { // ApiId Specifies which API the key belongs to for complete environment isolation. @@ -1660,7 +1566,9 @@ type V2PermissionsCreateRoleResponseData struct { // V2PermissionsDeletePermissionRequestBody defines model for V2PermissionsDeletePermissionRequestBody. type V2PermissionsDeletePermissionRequestBody struct { - // PermissionId Specifies which permission to permanently delete from your workspace. + // Permission Specifies which permission to permanently delete from your workspace. + // + // This can be a permission ID or a permission slug. // // WARNING: Deleting a permission has immediate and irreversible consequences: // - All API keys with this permission will lose that access immediately @@ -1672,12 +1580,14 @@ type V2PermissionsDeletePermissionRequestBody struct { // - Have updated any keys or roles that depend on this permission // - Have migrated to alternative permissions if needed // - Have notified affected users about the access changes - // - Have the correct permission ID (double-check against your permission list) - PermissionId string `json:"permissionId"` + Permission string `json:"permission"` } // V2PermissionsDeletePermissionResponseBody defines model for V2PermissionsDeletePermissionResponseBody. type V2PermissionsDeletePermissionResponseBody struct { + // Data Empty response object by design. A successful response indicates this operation was successfully executed. + Data EmptyResponse `json:"data"` + // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. Meta Meta `json:"meta"` } @@ -1703,6 +1613,9 @@ type V2PermissionsDeleteRoleRequestBody struct { // V2PermissionsDeleteRoleResponseBody defines model for V2PermissionsDeleteRoleResponseBody. type V2PermissionsDeleteRoleResponseBody struct { + // Data Empty response object by design. A successful response indicates this operation was successfully executed. + Data EmptyResponse `json:"data"` + // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. Meta Meta `json:"meta"` } diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 257f59b478..676f7457e6 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-21T12:07:10Z +# Generated at: 2025-07-22T15:25:41Z # Source: openapi-split.yaml components: @@ -156,9 +156,12 @@ components: type: object required: - meta + - data properties: meta: $ref: "#/components/schemas/Meta" + data: + $ref: "#/components/schemas/EmptyResponse" additionalProperties: false NotFoundErrorResponse: type: object @@ -502,8 +505,6 @@ components: description: | Specifies which key receives the additional permissions using the database identifier returned from `keys.createKey`. Do not confuse this with the actual API key string that users include in requests. - Added permissions supplement existing permissions and roles without replacing them. - Permission changes take effect immediately but may take up to 30 seconds to propagate across all regions. example: key_2cGKbMxRyIzhCxo1Idjz8q permissions: type: array @@ -512,48 +513,16 @@ components: description: | Grants additional permissions to the key through direct assignment or automatic creation. Duplicate permissions are ignored automatically, making this operation idempotent. - Use either ID for existing permissions or slug for new permissions with optional auto-creation. - Permission changes take effect immediately but cache propagation across regions may take up to 30 seconds. + You can either use a permission slug, or the permission ID. + + If slugs are used, the permission will be auto created IF the root key has the given permissions, otherwise this operation will fail with a 404 error. Adding permissions never removes existing permissions or role-based permissions. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - References an existing permission by its database identifier. - Use when you know the exact permission ID and want to ensure you're referencing a specific permission. - Takes precedence over slug when both are provided in the same object. - The referenced permission must already exist in your workspace. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - slug: - type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z0-9_.]+$" - description: | - Identifies the permission by its human-readable name using hierarchical naming patterns. - Use `resource.action` format for logical organization and verification flexibility. - Slugs must be unique within your `workspace` and support wildcard matching during verification. - Combined with `create=true`, allows automatic permission creation for streamlined workflows. - example: documents.write - create: - type: boolean - default: false - description: | - Enables automatic permission creation when the specified slug does not exist. - Only works with slug-based references, not ID-based references. - Requires the `rbac.*.create_permission` permission on your root key. - - Created permissions are permanent and visible workspace-wide to all API keys. - Use carefully to avoid permission proliferation from typos or uncontrolled creation. - Consider centralizing permission creation in controlled processes for better governance. - Auto-created permissions use the slug as both the name and identifier. - additionalProperties: false + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" additionalProperties: false V2KeysAddPermissionsResponseBody: type: object @@ -841,11 +810,12 @@ components: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" data: - "$ref": "#/components/schemas/KeysDeleteKeyResponseData" + "$ref": "#/components/schemas/EmptyResponse" V2KeysGetKeyRequestBody: type: object properties: @@ -910,8 +880,6 @@ components: description: | Specifies which key to remove permissions from using the database identifier returned from `keys.createKey`. Do not confuse this with the actual API key string that users include in requests. - Removing permissions only affects direct assignments, not permissions inherited from roles. - Permission changes take effect immediately but may take up to 30 seconds to propagate across all regions. example: key_2cGKbMxRyIzhCxo1Idjz8q permissions: type: array @@ -919,35 +887,15 @@ components: maxItems: 1000 description: | Removes direct permissions from the key without affecting role-based permissions. - Operations are idempotent - removing non-existent permissions has no effect and causes no errors. - Use either ID for existing permissions or name for exact string matching. + + You can either use a permission slug, or the permission ID. After removal, verification checks for these permissions will fail unless granted through roles. - Permission changes take effect immediately but cache propagation across regions may take up to 30 seconds. - Removing all direct permissions does not disable the key, only removes its direct permission grants. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - References the permission to remove by its database identifier. - Use when you know the exact permission ID and want to ensure you're removing a specific permission. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where precision prevents accidental permission removal. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - slug: - type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z0-9_.]+$" - description: | - Identifies the permission by slug for removal from the key's direct assignment list. - example: documents.write - additionalProperties: false + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" additionalProperties: false V2KeysRemovePermissionsResponseBody: type: object @@ -1033,48 +981,29 @@ components: properties: keyId: type: string - description: The unique identifier of the key to set permissions on (begins with 'key_'). This ID comes from the createKey response and identifies which key will have its permissions replaced. This is the database ID, not the actual API key string that users authenticate with. - example: key_2cGKbMxRyIzhCxo1Idjz8q minLength: 3 + maxLength: 255 + pattern: "^[a-zA-Z0-9_]+$" + description: | + Specifies which key receives the additional permissions using the database identifier returned from `keys.createKey`. + Do not confuse this with the actual API key string that users include in requests. + example: key_2cGKbMxRyIzhCxo1Idjz8q permissions: type: array description: |- - The permissions to set for this key. This is a complete replacement operation - it overwrites all existing direct permissions with this new set. + The permissions to set for this key. + + This is a complete replacement operation - it overwrites all existing direct permissions with this new set. Key behaviors: - Providing an empty array removes all direct permissions from the key - This only affects direct permissions - permissions granted through roles are not affected - All existing direct permissions not included in this list will be removed - - The complete list approach allows synchronizing permissions with external systems - - Permission changes take effect immediately for new verifications - - Unlike addPermissions (which only adds) or removePermissions (which only removes), this endpoint performs a wholesale replacement of the permission set. items: - type: object - properties: - id: - type: string - description: The ID of an existing permission (begins with `perm_`). Provide either ID or slug for each permission, not both. Using ID is more precise and guarantees you're referencing the exact permission intended, regardless of slug changes or duplicates. IDs are particularly useful in automation scripts and when migrating permissions between environments. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - minLength: 3 - slug: - type: string - description: The slug of the permission. Provide either ID or slug for each permission, not both. Slugs must match exactly as defined in your permission system - including case sensitivity and the complete hierarchical path. Slugs are generally more human-readable but can be ambiguous if not carefully managed across your workspace. - example: documents.write - minLength: 1 - create: - type: boolean - description: |- - When true, if a permission with this slug doesn't exist, it will be automatically created on-the-fly. Only works when specifying slug, not ID. - - SECURITY CONSIDERATIONS: - - Requires the `rbac.*.create_permission` permission on your root key - - Created permissions are permanent and visible throughout your workspace - - Use carefully to avoid permission proliferation and inconsistency - - Consider using a controlled process for permission creation instead - - Typos with `create=true` will create unintended permissions that persist in your system - default: false - additionalProperties: false + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" additionalProperties: false V2KeysSetPermissionsResponseBody: type: object @@ -1343,11 +1272,12 @@ components: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" data: - "$ref": "#/components/schemas/V2KeysUpdateKeyResponseData" + "$ref": "#/components/schemas/EmptyResponse" V2KeysVerifyKeyRequestBody: type: object additionalProperties: false @@ -1553,16 +1483,18 @@ components: V2PermissionsDeletePermissionRequestBody: type: object required: - - permissionId + - permission properties: - permissionId: + permission: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" description: | Specifies which permission to permanently delete from your workspace. + This can be a permission ID or a permission slug. + WARNING: Deleting a permission has immediate and irreversible consequences: - All API keys with this permission will lose that access immediately - All roles containing this permission will have it removed @@ -1573,16 +1505,18 @@ components: - Have updated any keys or roles that depend on this permission - Have migrated to alternative permissions if needed - Have notified affected users about the access changes - - Have the correct permission ID (double-check against your permission list) example: perm_1234567890abcdef additionalProperties: false V2PermissionsDeletePermissionResponseBody: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" + data: + "$ref": "#/components/schemas/EmptyResponse" additionalProperties: false V2PermissionsDeleteRoleRequestBody: type: object @@ -1615,9 +1549,12 @@ components: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" + data: + "$ref": "#/components/schemas/EmptyResponse" additionalProperties: false V2PermissionsGetPermissionRequestBody: type: object @@ -2147,6 +2084,10 @@ components: - message type: object description: Individual validation error details. Each validation error provides precise information about what failed, where it failed, and how to fix it, enabling efficient error resolution. + EmptyResponse: + type: object + additionalProperties: false + description: Empty response object by design. A successful response indicates this operation was successfully executed. V2ApisGetApiResponseData: type: object properties: @@ -2265,7 +2206,7 @@ components: type: array items: type: string - description: List of permissions granted to this key. + description: List of permission slugs granted to this key. example: - documents.read - documents.write @@ -2504,25 +2445,49 @@ components: - For a complete permission picture, use `/v2/keys.getKey` instead - An empty array indicates the key has no direct permissions assigned items: - type: object - required: - - id - - name - - slug - properties: - id: - type: string - description: The unique identifier of the permission (begins with `perm_`). This ID can be used in other API calls to reference this specific permission. IDs are guaranteed unique and won't change, making them ideal for scripting and automation. You can store these IDs in your system for consistent reference. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: "The human-readable name of the permission. " - example: Can write documents - slug: - type: string - description: |- - The slug of the permission, typically following a `resource.action` pattern like `documents.read`. - example: documents.write + "$ref": "#/components/schemas/Permission" + Permission: + type: object + properties: + id: + type: string + minLength: 3 + maxLength: 255 + pattern: "^[a-zA-Z0-9_]+$" + description: | + The unique identifier for this permission within Unkey's system. + Generated automatically when the permission is created and used to reference this permission in API operations. + Always begins with 'perm_' followed by alphanumeric characters and underscores. + example: perm_1234567890abcdef + name: + type: string + minLength: 1 + maxLength: 512 + description: | + The human-readable name for this permission that describes its purpose. + Should be descriptive enough for developers to understand what access it grants. + Use clear, semantic names that reflect the resources or actions being permitted. + Names must be unique within your workspace to avoid confusion and conflicts. + example: "users.read" + slug: + type: string + minLength: 1 + maxLength: 512 + description: The URL-safe identifier when this permission was created. + example: users-read + description: + type: string + maxLength: 2048 + description: | + Optional detailed explanation of what this permission grants access to. + Helps team members understand the scope and implications of granting this permission. + Include information about what resources can be accessed and what actions can be performed. + Not visible to end users - this is for internal documentation and team clarity. + example: "Allows reading user profile information and account details" + required: + - id + - name + - slug V2KeysAddRolesResponseData: type: array description: |- @@ -2567,10 +2532,6 @@ components: required: - keyId - key - KeysDeleteKeyResponseData: - type: object - additionalProperties: false - description: Empty response object by design. A successful response indicates the key was deleted successfully. V2KeysRemovePermissionsResponseData: type: array description: |- @@ -2581,41 +2542,13 @@ components: - Permissions sorted alphabetically by name for consistent response format - Both the permission ID and name for each remaining permission - Important notes: + Notes: - This list does NOT include permissions granted through roles - For a complete permission picture, use `/v2/keys.getKey` instead - An empty array indicates the key has no direct permissions assigned - - Any cached versions of the key are immediately invalidated to ensure consistency - - Changes to permissions take effect within seconds for new verifications - All permission removals are logged to the audit log for security tracking items: - type: object - required: - - id - - name - - slug - properties: - id: - type: string - description: The unique identifier of the permission (begins with `perm_`). This ID can be used in other API calls to reference this specific permission. IDs are guaranteed unique and won't change, making them ideal for scripting and automation. You can store these IDs in your system for consistent reference. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the permission - example: documents.write - slug: - type: string - description: |- - The slug of the permission, typically following a `resource.action` pattern like `documents.read`. Names are human-readable identifiers used both for assignment and verification. - - During verification: - - The exact name is matched (e.g., `documents.read`) - - Hierarchical wildcards are supported in verification requests - - A key with permission `documents.*` grants access to `documents.read`, `documents.write`, etc. - - Wildcards can appear at any level: `billing.*.view` matches `billing.invoices.view` and `billing.payments.view` - - However, when adding permissions, you must specify each exact permission - wildcards are not valid for assignment. - example: documents.write + "$ref": "#/components/schemas/Permission" V2KeysRemoveRolesResponseData: type: array description: |- @@ -2663,19 +2596,7 @@ components: - For a complete permission picture including roles, use keys.getKey instead - All permission changes are logged in the audit log for security tracking items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the permission - example: perm_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the permission - example: documents.write + "$ref": "#/components/schemas/Permission" V2KeysSetRolesResponseData: type: array description: |- @@ -2707,10 +2628,6 @@ components: type: string description: The name of the role. This is a human-readable identifier that's unique within your workspace. Role names are descriptive labels that help identify what access level or function a role provides. Good naming practices include naming by access level ('admin', 'editor'), by department ('billing_team', 'support_staff'), or by feature area ('reporting_user', 'settings_manager'). example: admin - V2KeysUpdateKeyResponseData: - type: object - additionalProperties: false - description: Empty response object by design. A successful response indicates the key was updated successfully. KeysVerifyKeyCredits: type: object required: @@ -2937,60 +2854,6 @@ components: - permission additionalProperties: false description: Complete permission details including ID, name, and metadata. - Permission: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - The unique identifier for this permission within Unkey's system. - Generated automatically when the permission is created and used to reference this permission in API operations. - Always begins with 'perm_' followed by alphanumeric characters and underscores. - example: perm_1234567890abcdef - name: - type: string - minLength: 1 - maxLength: 512 - description: | - The human-readable name for this permission that describes its purpose. - Should be descriptive enough for developers to understand what access it grants. - Use clear, semantic names that reflect the resources or actions being permitted. - Names must be unique within your workspace to avoid confusion and conflicts. - example: "users.read" - slug: - type: string - minLength: 1 - maxLength: 512 - description: | - The URL-safe identifier when this permission was created. - example: users-read - description: - type: string - maxLength: 2048 - description: | - Optional detailed explanation of what this permission grants access to. - Helps team members understand the scope and implications of granting this permission. - Include information about what resources can be accessed and what actions can be performed. - Not visible to end users - this is for internal documentation and team clarity. - example: "Allows reading user profile information and account details" - createdAt: - type: integer - format: int64 - minimum: 0 - maximum: 9223372036854776000 - description: | - Unix timestamp in milliseconds indicating when this permission was first created. - Useful for auditing and understanding the evolution of your permission structure. - Automatically set by the system and cannot be modified. - example: 1701425400000 - required: - - id - - name - - slug - - createdAt V2PermissionsGetRoleResponseData: type: object properties: @@ -3285,7 +3148,7 @@ info: This structure ensures you always have the context needed to debug issues and take corrective action. title: Unkey API version: 2.0.0 -openapi: 3.0.3 +openapi: 3.0.0 paths: /v2/apis.createApi: post: @@ -4688,7 +4551,10 @@ paths: /v2/keys.removePermissions: post: description: |- - Removes one or more permissions from an existing API key. This endpoint is used to selectively revoke access rights from a key without deleting it or affecting other permissions. + Removes one or more permissions from an existing API key. + + This endpoint is used to selectively revoke access rights from a key without deleting it or affecting other permissions. + Key features: - Selective removal - revoke specific permissions while leaving others intact - Direct permissions only - doesn't affect permissions granted through roles @@ -4705,40 +4571,14 @@ paths: value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - id: perm_2zF4mNyP9BsRj2aQwDxVkT - mixed: - description: You can combine ID-based and name-based references in a single request. This is useful when you have exact IDs for some permissions but only names for others. - summary: Mix of ID and name references - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - name: reports.export - removeAccessGroup: - description: A common pattern is removing all permissions related to a specific resource (e.g., 'documents') when revoking access to that resource type. - summary: Remove all permissions for a resource group - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: documents.read - - name: documents.write - - name: documents.delete - - name: documents.share + - perm_2zF4mNyP9BsRj2aQwDxVkT + - documents.delete removeAll: description: Setting an empty permissions array removes all direct permissions from the key. This doesn't remove permissions granted through roles. The key remains valid but will have no direct permissions. summary: Remove all permissions from key value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: [] - withNames: - description: Using permission names is more human-readable but requires exact name matches, including full path and correct case sensitivity. - summary: Remove permissions using names - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: documents.write - - name: documents.delete schema: $ref: '#/components/schemas/V2KeysRemovePermissionsRequestBody' required: true @@ -4748,7 +4588,7 @@ paths: application/json: examples: standard: - description: The response body contains only metadata and an empty data object. This minimalist response structure is by design - if you receive a 200 status code, all requested permissions have been successfully removed (or weren't present to begin with). + description: Successfully removed all specified permissions from the key. summary: Successful removal value: data: {} @@ -4756,7 +4596,7 @@ paths: requestId: req_2cGKbMxRyIzhCxo1Idjz8q schema: $ref: '#/components/schemas/V2KeysRemovePermissionsResponseBody' - description: Permissions successfully removed from the key. All requested permissions have been removed if they were present. Any permissions that weren't assigned to the key were simply ignored without causing an error. + description: Successfully removed all specified permissions from the key. "400": content: application/json: @@ -4871,7 +4711,7 @@ paths: requestId: req_4yZaAbCdEfGhIjKlMnOpQrS schema: $ref: '#/components/schemas/InternalServerErrorResponse' - description: Internal Server Error - An unexpected error occurred while processing the request + description: Internal Server Error security: - rootKey: [] summary: Remove permissions from an API key diff --git a/go/apps/api/openapi/spec/paths/v2/keys/deleteKey/KeysDeleteKeyResponseData.yaml b/go/apps/api/openapi/spec/common/EmptyResponse.yaml similarity index 68% rename from go/apps/api/openapi/spec/paths/v2/keys/deleteKey/KeysDeleteKeyResponseData.yaml rename to go/apps/api/openapi/spec/common/EmptyResponse.yaml index 6a873f2296..8f46b8592a 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/deleteKey/KeysDeleteKeyResponseData.yaml +++ b/go/apps/api/openapi/spec/common/EmptyResponse.yaml @@ -1,4 +1,4 @@ type: object properties: {} additionalProperties: false -description: Empty response object by design. A successful response indicates the key was deleted successfully. +description: Empty response object by design. A successful response indicates this operation was successfully executed. diff --git a/go/apps/api/openapi/spec/common/KeyResponseData.yaml b/go/apps/api/openapi/spec/common/KeyResponseData.yaml index ddc51d2a21..47bd63970e 100644 --- a/go/apps/api/openapi/spec/common/KeyResponseData.yaml +++ b/go/apps/api/openapi/spec/common/KeyResponseData.yaml @@ -60,7 +60,7 @@ properties: type: array items: type: string - description: List of permissions granted to this key. + description: List of permission slugs granted to this key. example: - documents.read - documents.write diff --git a/go/apps/api/openapi/spec/common/permission.yaml b/go/apps/api/openapi/spec/common/permission.yaml index 7da3e81286..93e2425a89 100644 --- a/go/apps/api/openapi/spec/common/permission.yaml +++ b/go/apps/api/openapi/spec/common/permission.yaml @@ -24,8 +24,7 @@ properties: type: string minLength: 1 maxLength: 512 - description: | - The URL-safe identifier when this permission was created. + description: The URL-safe identifier when this permission was created. example: users-read description: type: string @@ -36,18 +35,7 @@ properties: Include information about what resources can be accessed and what actions can be performed. Not visible to end users - this is for internal documentation and team clarity. example: "Allows reading user profile information and account details" - createdAt: - type: integer - format: int64 - minimum: 0 - maximum: 9223372036854775807 # Max int64 value for future-proofing - description: | - Unix timestamp in milliseconds indicating when this permission was first created. - Useful for auditing and understanding the evolution of your permission structure. - Automatically set by the system and cannot be modified. - example: 1701425400000 required: - id - name - slug - - createdAt diff --git a/go/apps/api/openapi/spec/paths/v2/apis/deleteApi/V2ApisDeleteApiResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/apis/deleteApi/V2ApisDeleteApiResponseBody.yaml index 47c08eac8b..056b42c716 100644 --- a/go/apps/api/openapi/spec/paths/v2/apis/deleteApi/V2ApisDeleteApiResponseBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/apis/deleteApi/V2ApisDeleteApiResponseBody.yaml @@ -1,7 +1,10 @@ type: object required: - meta + - data properties: meta: $ref: "../../../../common/Meta.yaml" + data: + $ref: "../../../../common/EmptyResponse.yaml" additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml index 3e4d55966c..d205640abc 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml @@ -11,56 +11,22 @@ properties: description: | Specifies which key receives the additional permissions using the database identifier returned from `keys.createKey`. Do not confuse this with the actual API key string that users include in requests. - Added permissions supplement existing permissions and roles without replacing them. - Permission changes take effect immediately but may take up to 30 seconds to propagate across all regions. example: key_2cGKbMxRyIzhCxo1Idjz8q permissions: type: array minItems: 1 - maxItems: 1000 # Allow extensive permission sets for complex applications + maxItems: 1000 description: | Grants additional permissions to the key through direct assignment or automatic creation. Duplicate permissions are ignored automatically, making this operation idempotent. - Use either ID for existing permissions or slug for new permissions with optional auto-creation. - Permission changes take effect immediately but cache propagation across regions may take up to 30 seconds. + You can either use a permission slug, or the permission ID. + + If slugs are used, the permission will be auto created IF the root key has the given permissions, otherwise this operation will fail with a 404 error. Adding permissions never removes existing permissions or role-based permissions. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 # Reasonable upper bound for database identifiers - pattern: "^[a-zA-Z0-9_]+$" - description: | - References an existing permission by its database identifier. - Use when you know the exact permission ID and want to ensure you're referencing a specific permission. - Takes precedence over slug when both are provided in the same object. - The referenced permission must already exist in your workspace. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - slug: - type: string - minLength: 1 - maxLength: 100 # Keep permission names concise and readable - pattern: "^[a-zA-Z0-9_.]+$" - description: | - Identifies the permission by its human-readable name using hierarchical naming patterns. - Use `resource.action` format for logical organization and verification flexibility. - Slugs must be unique within your `workspace` and support wildcard matching during verification. - Combined with `create=true`, allows automatic permission creation for streamlined workflows. - example: documents.write - create: - type: boolean - default: false - description: | - Enables automatic permission creation when the specified slug does not exist. - Only works with slug-based references, not ID-based references. - Requires the `rbac.*.create_permission` permission on your root key. - - Created permissions are permanent and visible workspace-wide to all API keys. - Use carefully to avoid permission proliferation from typos or uncontrolled creation. - Consider centralizing permission creation in controlled processes for better governance. - Auto-created permissions use the slug as both the name and identifier. - additionalProperties: false + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsResponseData.yaml index af0289a61c..a122c0970d 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsResponseData.yaml @@ -12,27 +12,4 @@ description: |- - For a complete permission picture, use `/v2/keys.getKey` instead - An empty array indicates the key has no direct permissions assigned items: - type: object - required: - - id - - name - - slug - properties: - id: - type: string - description: - The unique identifier of the permission (begins with `perm_`). - This ID can be used in other API calls to reference this specific permission. - IDs are guaranteed unique and won't change, making them ideal for scripting - and automation. You can store these IDs in your system for consistent - reference. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: "The human-readable name of the permission. " - example: Can write documents - slug: - type: string - description: |- - The slug of the permission, typically following a `resource.action` pattern like `documents.read`. - example: documents.write + "$ref": "../../../../common/Permission.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/deleteKey/V2KeysDeleteKeyResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/deleteKey/V2KeysDeleteKeyResponseBody.yaml index f616f9538b..494e0b23c6 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/deleteKey/V2KeysDeleteKeyResponseBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/deleteKey/V2KeysDeleteKeyResponseBody.yaml @@ -1,8 +1,9 @@ type: object required: - meta + - data properties: meta: "$ref": "../../../../common/Meta.yaml" data: - "$ref": "./KeysDeleteKeyResponseData.yaml" + "$ref": "../../../../common/EmptyResponse.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml index 99a5741a3b..492b5fd587 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml @@ -11,8 +11,6 @@ properties: description: | Specifies which key to remove permissions from using the database identifier returned from `keys.createKey`. Do not confuse this with the actual API key string that users include in requests. - Removing permissions only affects direct assignments, not permissions inherited from roles. - Permission changes take effect immediately but may take up to 30 seconds to propagate across all regions. example: key_2cGKbMxRyIzhCxo1Idjz8q permissions: type: array @@ -20,33 +18,13 @@ properties: maxItems: 1000 # Allow extensive permission sets for complex applications description: | Removes direct permissions from the key without affecting role-based permissions. - Operations are idempotent - removing non-existent permissions has no effect and causes no errors. - Use either ID for existing permissions or name for exact string matching. + + You can either use a permission slug, or the permission ID. After removal, verification checks for these permissions will fail unless granted through roles. - Permission changes take effect immediately but cache propagation across regions may take up to 30 seconds. - Removing all direct permissions does not disable the key, only removes its direct permission grants. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 # Reasonable upper bound for database identifiers - pattern: "^[a-zA-Z0-9_]+$" - description: | - References the permission to remove by its database identifier. - Use when you know the exact permission ID and want to ensure you're removing a specific permission. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where precision prevents accidental permission removal. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - slug: - type: string - minLength: 1 - maxLength: 100 # Keep permission slugs concise and readable - pattern: "^[a-zA-Z0-9_.]+$" - description: | - Identifies the permission by slug for removal from the key's direct assignment list. - example: documents.write - additionalProperties: false + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml index 5521105f99..c7d6129ae4 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml @@ -7,43 +7,10 @@ description: |- - Permissions sorted alphabetically by name for consistent response format - Both the permission ID and name for each remaining permission - Important notes: + Notes: - This list does NOT include permissions granted through roles - For a complete permission picture, use `/v2/keys.getKey` instead - An empty array indicates the key has no direct permissions assigned - - Any cached versions of the key are immediately invalidated to ensure consistency - - Changes to permissions take effect within seconds for new verifications - All permission removals are logged to the audit log for security tracking items: - type: object - required: - - id - - name - - slug - properties: - id: - type: string - description: - The unique identifier of the permission (begins with `perm_`). - This ID can be used in other API calls to reference this specific permission. - IDs are guaranteed unique and won't change, making them ideal for scripting - and automation. You can store these IDs in your system for consistent - reference. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the permission - example: documents.write - slug: - type: string - description: |- - The slug of the permission, typically following a `resource.action` pattern like `documents.read`. Names are human-readable identifiers used both for assignment and verification. - - During verification: - - The exact name is matched (e.g., `documents.read`) - - Hierarchical wildcards are supported in verification requests - - A key with permission `documents.*` grants access to `documents.read`, `documents.write`, etc. - - Wildcards can appear at any level: `billing.*.view` matches `billing.invoices.view` and `billing.payments.view` - - However, when adding permissions, you must specify each exact permission - wildcards are not valid for assignment. - example: documents.write + "$ref": "../../../../common/Permission.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml index 78a53723a1..750f245543 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml @@ -3,7 +3,10 @@ post: - keys summary: Remove permissions from an API key description: |- - Removes one or more permissions from an existing API key. This endpoint is used to selectively revoke access rights from a key without deleting it or affecting other permissions. + Removes one or more permissions from an existing API key. + + This endpoint is used to selectively revoke access rights from a key without deleting it or affecting other permissions. + Key features: - Selective removal - revoke specific permissions while leaving others intact - Direct permissions only - doesn't affect permissions granted through roles @@ -36,45 +39,11 @@ post: value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - id: perm_2zF4mNyP9BsRj2aQwDxVkT - withNames: - summary: Remove permissions using names - description: - Using permission names is more human-readable but requires - exact name matches, including full path and correct case sensitivity. - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: documents.write - - name: documents.delete - mixed: - summary: Mix of ID and name references - description: You can combine ID-based and name-based references in - a single request. This is useful when you have exact IDs for some - permissions but only names for others. - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - name: reports.export - removeAccessGroup: - summary: Remove all permissions for a resource group - description: A common pattern is removing all permissions related - to a specific resource (e.g., 'documents') when revoking access - to that resource type. - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: documents.read - - name: documents.write - - name: documents.delete - - name: documents.share + - perm_2zF4mNyP9BsRj2aQwDxVkT + - documents.delete responses: "200": - description: Permissions successfully removed from the key. All requested - permissions have been removed if they were present. Any permissions that - weren't assigned to the key were simply ignored without causing an error. + description: Successfully removed all specified permissions from the key. content: application/json: schema: @@ -82,17 +51,13 @@ post: examples: standard: summary: Successful removal - description: The response body contains only metadata and an empty - data object. This minimalist response structure is by design - - if you receive a 200 status code, all requested permissions have - been successfully removed (or weren't present to begin with). + description: Successfully removed all specified permissions from the key. value: meta: requestId: req_2cGKbMxRyIzhCxo1Idjz8q data: {} "400": - description: Bad Request - Invalid keyId format, missing required fields, - or malformed permission entries + description: Bad Request - Invalid keyId format, missing required fields, or malformed permission entries content: application/json: schema: @@ -159,9 +124,7 @@ post: requestId: req_0uVwX4yZaAbCdEfGhIjKl error: title: Forbidden - detail: - Your root key requires the 'rbac.*.remove_permission_from_key' - permission to perform this operation + detail: Your root key requires the 'rbac.*.remove_permission_from_key' permission to perform this operation status: 403 type: forbidden "404": @@ -182,8 +145,7 @@ post: status: 404 type: not_found "500": - description: Internal Server Error - An unexpected error occurred while - processing the request + description: Internal Server Error content: application/json: schema: diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml index cab2154e21..0e036ce4de 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml @@ -5,58 +5,27 @@ required: properties: keyId: type: string - description: The unique identifier of the key to set permissions on (begins - with 'key_'). This ID comes from the createKey response and identifies - which key will have its permissions replaced. This is the database ID, - not the actual API key string that users authenticate with. - example: key_2cGKbMxRyIzhCxo1Idjz8q minLength: 3 + maxLength: 255 # Reasonable upper bound for database identifiers + pattern: "^[a-zA-Z0-9_]+$" + description: | + Specifies which key receives the additional permissions using the database identifier returned from `keys.createKey`. + Do not confuse this with the actual API key string that users include in requests. + example: key_2cGKbMxRyIzhCxo1Idjz8q permissions: type: array description: |- - The permissions to set for this key. This is a complete replacement operation - it overwrites all existing direct permissions with this new set. + The permissions to set for this key. + + This is a complete replacement operation - it overwrites all existing direct permissions with this new set. Key behaviors: - Providing an empty array removes all direct permissions from the key - This only affects direct permissions - permissions granted through roles are not affected - All existing direct permissions not included in this list will be removed - - The complete list approach allows synchronizing permissions with external systems - - Permission changes take effect immediately for new verifications - - Unlike addPermissions (which only adds) or removePermissions (which only removes), this endpoint performs a wholesale replacement of the permission set. items: - type: object - properties: - id: - type: string - description: The ID of an existing permission (begins with `perm_`). - Provide either ID or slug for each permission, not both. Using ID - is more precise and guarantees you're referencing the exact permission - intended, regardless of slug changes or duplicates. IDs are particularly - useful in automation scripts and when migrating permissions between - environments. - example: perm_1n9McEIBSqy44Qy7hzWyM5 - minLength: 3 - slug: - type: string - description: The slug of the permission. Provide either ID or slug - for each permission, not both. Slugs must match exactly as defined - in your permission system - including case sensitivity and the complete - hierarchical path. Slugs are generally more human-readable but can - be ambiguous if not carefully managed across your workspace. - example: documents.write - minLength: 1 - create: - type: boolean - description: |- - When true, if a permission with this slug doesn't exist, it will be automatically created on-the-fly. Only works when specifying slug, not ID. - - SECURITY CONSIDERATIONS: - - Requires the `rbac.*.create_permission` permission on your root key - - Created permissions are permanent and visible throughout your workspace - - Use carefully to avoid permission proliferation and inconsistency - - Consider using a controlled process for permission creation instead - - Typos with `create=true` will create unintended permissions that persist in your system - default: false - additionalProperties: false + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsResponseData.yaml index 5ed1264c71..fead64f80f 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsResponseData.yaml @@ -13,16 +13,4 @@ description: |- - For a complete permission picture including roles, use keys.getKey instead - All permission changes are logged in the audit log for security tracking items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the permission - example: perm_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the permission - example: documents.write + "$ref": "../../../../common/Permission.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml index 0cd2958a25..494e0b23c6 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml @@ -1,8 +1,9 @@ type: object required: - meta + - data properties: meta: "$ref": "../../../../common/Meta.yaml" data: - "$ref": "./V2KeysUpdateKeyResponseData.yaml" + "$ref": "../../../../common/EmptyResponse.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseData.yaml deleted file mode 100644 index efd7ce15ab..0000000000 --- a/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseData.yaml +++ /dev/null @@ -1,4 +0,0 @@ -type: object -properties: {} -additionalProperties: false -description: Empty response object by design. A successful response indicates the key was updated successfully. diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionRequestBody.yaml index 6869b53168..f40a9d219b 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionRequestBody.yaml @@ -1,15 +1,17 @@ type: object required: - - permissionId + - permission properties: - permissionId: + permission: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" description: | Specifies which permission to permanently delete from your workspace. + This can be a permission ID or a permission slug. + WARNING: Deleting a permission has immediate and irreversible consequences: - All API keys with this permission will lose that access immediately - All roles containing this permission will have it removed @@ -20,6 +22,5 @@ properties: - Have updated any keys or roles that depend on this permission - Have migrated to alternative permissions if needed - Have notified affected users about the access changes - - Have the correct permission ID (double-check against your permission list) example: perm_1234567890abcdef additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionResponseBody.yaml index 507a9f5d89..00c12ad1eb 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionResponseBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/deletePermission/V2PermissionsDeletePermissionResponseBody.yaml @@ -1,7 +1,10 @@ type: object required: - meta + - data properties: meta: "$ref": "../../../../common/Meta.yaml" + data: + "$ref": "../../../../common/EmptyResponse.yaml" additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleResponseBody.yaml index 507a9f5d89..00c12ad1eb 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleResponseBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleResponseBody.yaml @@ -1,7 +1,10 @@ type: object required: - meta + - data properties: meta: "$ref": "../../../../common/Meta.yaml" + data: + "$ref": "../../../../common/EmptyResponse.yaml" additionalProperties: false diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index 63a82c32a7..3f14ce6914 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -56,14 +56,8 @@ func TestNotFound(t *testing.T) { nonExistentKeyID := uid.New(uid.KeyPrefix) req := handler.Request{ - KeyId: nonExistentKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: nonExistentKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -101,14 +95,8 @@ func TestNotFound(t *testing.T) { nonExistentPermissionID := uid.New(uid.TestPrefix) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &nonExistentPermissionID}, - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -143,16 +131,9 @@ func TestNotFound(t *testing.T) { keyID := keyResponse.KeyID nonExistentPermissionName := "nonexistent.permission.name" - req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &nonExistentPermissionName}, - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index b50f091019..33fdf65487 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" "net/http" - "slices" + "strings" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -104,91 +104,61 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 6. Resolve and validate requested permissions - requestedPermissions := make([]db.Permission, 0, len(req.Permissions)) - for _, permissionRef := range req.Permissions { - var permission db.Permission + permissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Ids: req.Permissions, + }) - // nolint:nestif - if permissionRef.Id != nil && *permissionRef.Id != "" { - // Find by ID - permission, err = db.Query.FindPermissionByID(ctx, h.DB.RO(), *permissionRef.Id) - if err != nil { - if db.IsNotFound(err) { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission with ID '%s' was not found.", *permissionRef.Id)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - } else if permissionRef.Slug != nil && *permissionRef.Slug != "" { - // Find by slug - permission, err = db.Query.FindPermissionBySlugAndWorkspaceID(ctx, h.DB.RO(), db.FindPermissionBySlugAndWorkspaceIDParams{ - Slug: *permissionRef.Slug, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if err != nil { - if db.IsNotFound(err) { - // Check if we should create the permission - if permissionRef.Create != nil && *permissionRef.Create { - // Create permission using slug as both name and slug - permissionID := uid.New(uid.PermissionPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: permissionID, - WorkspaceID: auth.AuthorizedWorkspaceID, - Name: *permissionRef.Slug, - Slug: *permissionRef.Slug, - Description: sql.NullString{String: "", Valid: false}, - CreatedAtM: time.Now().UnixMilli(), - }) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to create permission."), - ) - } - - // Fetch the newly created permission - permission, err = db.Query.FindPermissionByID(ctx, h.DB.RO(), permissionID) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve created permission."), - ) - } - } else { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission with slug '%s' was not found.", *permissionRef.Slug)), - ) - } - } else { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - } - } else { - return fault.New("invalid permission reference", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("permission missing id and slug"), fault.Public("Each permission must specify either 'id' or 'slug'."), - ) + missingPermissions := make(map[string]struct{}) + for _, permission := range req.Permissions { + missingPermissions[permission] = struct{}{} + } + + for _, permission := range permissions { + if _, ok := missingPermissions[permission.ID]; ok { + delete(missingPermissions, permission.ID) + } + + if _, ok := missingPermissions[permission.Slug]; ok { + delete(missingPermissions, permission.Slug) } + } + + permissionsToSet := make([]db.Permission, 0) + permissionsToInsert := make([]db.InsertPermissionParams, 0) - // Validate permission belongs to the same workspace - if permission.WorkspaceID != auth.AuthorizedWorkspaceID { + for _, permission := range permissions { + permissionsToSet = append(permissionsToSet, permission) + } + + for perm := range missingPermissions { + if strings.HasPrefix(perm, "perm_") { return fault.New("permission not found", fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission belongs to different workspace"), fault.Public(fmt.Sprintf("Permission '%s' was not found.", permission.Slug)), + fault.Internal("permission not found"), + fault.Public(fmt.Sprintf("Permission with ID '%s' was not found.", perm)), ) } - requestedPermissions = append(requestedPermissions, permission) + permissionID := uid.New(uid.PermissionPrefix) + now := time.Now().UnixMilli() + permissionsToInsert = append(permissionsToInsert, db.InsertPermissionParams{ + PermissionID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: sql.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) + + permissionsToSet = append(permissionsToSet, db.Permission{ + ID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: sql.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) } // 7. Calculate differential update @@ -224,99 +194,98 @@ 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 { var auditLogs []auditlog.AuditLog - // Remove permissions that are no longer needed - for _, permissionID := range permissionsToRemove { - err = db.Query.DeleteKeyPermissionByKeyAndPermissionID(ctx, tx, db.DeleteKeyPermissionByKeyAndPermissionIDParams{ - KeyID: req.KeyId, - PermissionID: permissionID, + if len(permissionsToRemove) > 0 { + err = db.Query.DeleteManyKeyPermissionByKeyAndPermissionIDs(ctx, tx, db.DeleteManyKeyPermissionByKeyAndPermissionIDsParams{ + KeyID: req.KeyId, + Ids: permissionsToRemove, }) + if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to remove permission assignment."), + fault.Internal("database error"), + fault.Public("Failed to remove permission assignment."), ) } - // Find the permission for audit log - var permissionName string - for _, p := range currentPermissions { - if p.ID == permissionID { - permissionName = p.Name - break - } + for _, perm := range permissionsToRemove { + // auditLogs = append(auditLogs, auditlog.AuditLog{ + // WorkspaceID: auth.AuthorizedWorkspaceID, + // Event: auditlog.AuthDisconnectPermissionKeyEvent, + // ActorType: auditlog.RootKeyActor, + // ActorID: auth.Key.ID, + // ActorName: "root key", + // ActorMeta: map[string]any{}, + // Display: fmt.Sprintf("Removed permission %s from key %s", permissionName, req.KeyId), + // RemoteIP: s.Location(), + // UserAgent: s.UserAgent(), + // Resources: []auditlog.AuditLogResource{ + // { + // Type: "key", + // ID: req.KeyId, + // Name: key.Name.String, + // DisplayName: key.Name.String, + // Meta: map[string]any{}, + // }, + // { + // Type: "permission", + // ID: permissionID, + // Name: permissionName, + // DisplayName: permissionName, + // Meta: map[string]any{}, + // }, + // }, + // }) } + } - auditLogs = append(auditLogs, auditlog.AuditLog{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Event: auditlog.AuthDisconnectPermissionKeyEvent, - ActorType: auditlog.RootKeyActor, - ActorID: auth.Key.ID, - ActorName: "root key", - ActorMeta: map[string]any{}, - Display: fmt.Sprintf("Removed permission %s from key %s", permissionName, req.KeyId), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: "key", - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: "permission", - ID: permissionID, - Name: permissionName, - DisplayName: permissionName, - Meta: map[string]any{}, + if len(permissionsToAdd) > 0 { + toAdd := make([]db.InsertKeyPermissionParams, len(permissionsToAdd)) + for idx, permission := range permissionsToAdd { + toAdd[idx] = db.InsertKeyPermissionParams{ + KeyID: req.KeyId, + PermissionID: permission.ID, + WorkspaceID: auth.AuthorizedWorkspaceID, + CreatedAt: time.Now().UnixMilli(), + } + + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.AuthConnectPermissionKeyEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Added permission %s to key %s", permission.Name, req.KeyId), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: "key", + ID: req.KeyId, + Name: key.Name.String, + DisplayName: key.Name.String, + Meta: map[string]any{}, + }, + { + Type: "permission", + ID: permission.ID, + Name: permission.Slug, + DisplayName: permission.Name, + Meta: map[string]any{}, + }, }, - }, - }) - } + }) + } - // Add new permissions - for _, permission := range permissionsToAdd { - err = db.Query.InsertKeyPermission(ctx, tx, db.InsertKeyPermissionParams{ - KeyID: req.KeyId, - PermissionID: permission.ID, - WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAt: time.Now().UnixMilli(), - }) + err = db.BulkQuery.InsertKeyPermissions(ctx, h.DB.RW(), toAdd) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to add permission assignment."), + fault.Internal("database error"), + fault.Public("Failed to add permissions to key."), ) } - - auditLogs = append(auditLogs, auditlog.AuditLog{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Event: auditlog.AuthConnectPermissionKeyEvent, - ActorType: auditlog.RootKeyActor, - ActorID: auth.Key.ID, - ActorName: "root key", - ActorMeta: map[string]any{}, - Display: fmt.Sprintf("Added permission %s to key %s", permission.Name, req.KeyId), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: "key", - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: "permission", - ID: permission.ID, - Name: permission.Name, - DisplayName: permission.Name, - Meta: map[string]any{}, - }, - }, - }) } // Insert audit logs @@ -335,36 +304,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) - // 10. Get final state of permissions and build response - finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final permission state."), - ) - } - - // Sort permissions alphabetically by name for consistent response - slices.SortFunc(finalPermissions, func(a, b db.Permission) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 - } - return 0 - }) - // Build response data - responseData := make(openapi.V2KeysSetPermissionsResponseData, len(finalPermissions)) - for i, permission := range finalPermissions { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - }{ - Id: permission.ID, - Name: permission.Name, - } - } + responseData := make(openapi.V2KeysSetPermissionsResponseData, 0) // 11. Return success response return s.JSON(http.StatusOK, Response{ diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index 9304762520..728ef13f69 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -569,6 +569,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Meta: openapi.Meta{ RequestId: s.RequestID(), }, - Data: &openapi.V2KeysUpdateKeyResponseData{}, + Data: openapi.EmptyResponse{}, }) } diff --git a/go/apps/api/routes/v2_permissions_delete_permission/200_test.go b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go index b21aaf989f..b6bbed7cde 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go @@ -64,7 +64,7 @@ func TestSuccess(t *testing.T) { // Now delete the permission req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -123,7 +123,7 @@ func TestSuccess(t *testing.T) { // Delete the permission req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_permissions_delete_permission/400_test.go b/go/apps/api/routes/v2_permissions_delete_permission/400_test.go index 28282e102b..1518eea308 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/400_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/400_test.go @@ -57,7 +57,7 @@ func TestValidationErrors(t *testing.T) { // Test case for empty permissionId t.Run("empty permissionId", func(t *testing.T) { req := handler.Request{ - PermissionId: "", // Empty string is invalid + Permission: "", // Empty string is invalid } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -95,7 +95,7 @@ func TestValidationErrors(t *testing.T) { // Test case for invalid permissionId format t.Run("invalid permissionId format", func(t *testing.T) { req := handler.Request{ - PermissionId: "not_a_valid_permission_id_format", + Permission: "not_a_valid_permission_id_format", } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_delete_permission/401_test.go b/go/apps/api/routes/v2_permissions_delete_permission/401_test.go index a919fc8122..7e5e7015f5 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/401_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/401_test.go @@ -24,7 +24,7 @@ func TestAuthenticationErrors(t *testing.T) { // Create a valid request req := handler.Request{ - PermissionId: "perm_test123", + Permission: "perm_test123", } // Test case for missing authorization header diff --git a/go/apps/api/routes/v2_permissions_delete_permission/403_test.go b/go/apps/api/routes/v2_permissions_delete_permission/403_test.go index 2253df8fe1..9afb9f3ca1 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/403_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/403_test.go @@ -57,7 +57,7 @@ func TestAuthorizationErrors(t *testing.T) { } req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( @@ -92,7 +92,7 @@ func TestAuthorizationErrors(t *testing.T) { } req := handler.Request{ - PermissionId: permissionID, // Permission is in the original workspace + Permission: permissionID, // Permission is in the original workspace } // When accessing from wrong workspace, the behavior should be a 404 Not Found diff --git a/go/apps/api/routes/v2_permissions_delete_permission/404_test.go b/go/apps/api/routes/v2_permissions_delete_permission/404_test.go index d2261ede27..8fb7df616d 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/404_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/404_test.go @@ -44,7 +44,7 @@ func TestNotFoundErrors(t *testing.T) { // Test case for non-existent permission ID t.Run("non-existent permission ID", func(t *testing.T) { req := handler.Request{ - PermissionId: "perm_does_not_exist", + Permission: "perm_does_not_exist", } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -65,7 +65,7 @@ func TestNotFoundErrors(t *testing.T) { nonExistentID := uid.New(uid.PermissionPrefix) // Generate a valid ID format that doesn't exist req := handler.Request{ - PermissionId: nonExistentID, + Permission: nonExistentID, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -101,7 +101,7 @@ func TestNotFoundErrors(t *testing.T) { // Try to delete it again req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_delete_permission/handler.go b/go/apps/api/routes/v2_permissions_delete_permission/handler.go index 53a430305c..9792e583d4 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/handler.go @@ -40,19 +40,16 @@ func (h *Handler) Path() string { // Handle processes the HTTP request func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -64,33 +61,28 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Check if permission exists and belongs to authorized workspace - permission, err := db.Query.FindPermissionByID(ctx, h.DB.RO(), req.PermissionId) + permission, err := db.Query.FindPermissionByIdOrSlug(ctx, h.DB.RO(), db.FindPermissionByIdOrSlugParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + ID: req.Permission, + Slug: req.Permission, + }) if err != nil { if db.IsNotFound(err) { return fault.New("permission not found", fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public("The requested permission does not exist."), + fault.Internal("permission not found"), + fault.Public("The requested permission does not exist."), ) } return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission information."), - ) - } - - // Check if permission belongs to authorized workspace - if permission.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public("The requested permission does not exist."), + fault.Internal("database error"), + fault.Public("Failed to retrieve permission information."), ) } - // 5. Delete the permission in a transaction err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - // Delete role-permission relationships - err = db.Query.DeleteManyRolePermissionsByPermissionID(ctx, tx, req.PermissionId) + err = db.Query.DeleteManyRolePermissionsByPermissionID(ctx, tx, permission.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -98,8 +90,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Delete key-permission relationships - err = db.Query.DeleteManyKeyPermissionsByPermissionID(ctx, tx, req.PermissionId) + err = db.Query.DeleteManyKeyPermissionsByPermissionID(ctx, tx, permission.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -107,8 +98,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Delete the permission itself - err = db.Query.DeletePermission(ctx, tx, req.PermissionId) + err = db.Query.DeletePermission(ctx, tx, permission.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -116,7 +106,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Create audit log for permission deletion err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, @@ -125,17 +114,18 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ActorID: auth.Key.ID, ActorName: "root key", ActorMeta: map[string]any{}, - Display: "Deleted " + req.PermissionId, + Display: "Deleted " + permission.ID, RemoteIP: s.Location(), UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { Type: "permission", - ID: req.PermissionId, + ID: permission.ID, Name: permission.Name, DisplayName: permission.Name, Meta: map[string]interface{}{ "name": permission.Name, + "slug": permission.Slug, "description": permission.Description.String, }, }, @@ -152,7 +142,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 6. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_permissions_get_permission/200_test.go b/go/apps/api/routes/v2_permissions_get_permission/200_test.go index e72fd191ce..3c0166f912 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/200_test.go @@ -79,7 +79,6 @@ func TestSuccess(t *testing.T) { require.Equal(t, permissionName, permission.Name) require.NotNil(t, permission.Description) require.Equal(t, permissionDesc, *permission.Description) - require.NotNil(t, permission.CreatedAt) }) // Test case for getting a permission without description diff --git a/go/apps/api/routes/v2_permissions_get_permission/handler.go b/go/apps/api/routes/v2_permissions_get_permission/handler.go index f677d44f54..ef89a7ee50 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_get_permission/handler.go @@ -92,7 +92,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: permission.Name, Slug: permission.Slug, Description: nil, - CreatedAt: permission.CreatedAtM, } // Add description only if it's valid diff --git a/go/apps/api/routes/v2_permissions_get_role/handler.go b/go/apps/api/routes/v2_permissions_get_role/handler.go index 80589d8e40..ec04653147 100644 --- a/go/apps/api/routes/v2_permissions_get_role/handler.go +++ b/go/apps/api/routes/v2_permissions_get_role/handler.go @@ -102,7 +102,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Id: perm.ID, Name: perm.Name, Slug: perm.Slug, - CreatedAt: perm.CreatedAtM, Description: nil, } diff --git a/go/apps/api/routes/v2_permissions_list_permissions/handler.go b/go/apps/api/routes/v2_permissions_list_permissions/handler.go index cef8e67edc..05e9968e5a 100644 --- a/go/apps/api/routes/v2_permissions_list_permissions/handler.go +++ b/go/apps/api/routes/v2_permissions_list_permissions/handler.go @@ -101,7 +101,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm.Name, Slug: perm.Slug, Description: nil, - CreatedAt: perm.CreatedAtM, } // Add description only if it's valid diff --git a/go/apps/api/routes/v2_permissions_list_roles/handler.go b/go/apps/api/routes/v2_permissions_list_roles/handler.go index 4d546b0a98..bea0064be4 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/handler.go +++ b/go/apps/api/routes/v2_permissions_list_roles/handler.go @@ -111,7 +111,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permission := openapi.Permission{ Id: perm.ID, Name: perm.Name, - CreatedAt: perm.CreatedAtM, Slug: perm.Slug, Description: nil, } diff --git a/go/pkg/db/bulk_version_insert.sql.go b/go/pkg/db/bulk_version_insert.sql.go index 32a1e4df87..01eab52911 100644 --- a/go/pkg/db/bulk_version_insert.sql.go +++ b/go/pkg/db/bulk_version_insert.sql.go @@ -9,7 +9,7 @@ import ( ) // bulkInsertVersion is the base query for bulk insert -const bulkInsertVersion = `INSERT INTO ` + "`" + `versions` + "`" + ` ( id, workspace_id, project_id, branch_id, build_id, rootfs_image_id, git_commit_sha, git_branch, config_snapshot, status, created_at, updated_at ) VALUES %s` +const bulkInsertVersion = `INSERT INTO ` + "`" + `versions` + "`" + ` ( id, workspace_id, project_id, branch_id, build_id, rootfs_image_id, git_commit_sha, git_branch, config_snapshot, openapi_spec, status, created_at, updated_at ) VALUES %s` // InsertVersions performs bulk insert in a single query func (q *BulkQueries) InsertVersions(ctx context.Context, db DBTX, args []InsertVersionParams) error { @@ -21,7 +21,7 @@ func (q *BulkQueries) InsertVersions(ctx context.Context, db DBTX, args []Insert // Build the bulk insert query valueClauses := make([]string, len(args)) for i := range args { - valueClauses[i] = "( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )" + valueClauses[i] = "( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )" } bulkQuery := fmt.Sprintf(bulkInsertVersion, strings.Join(valueClauses, ", ")) @@ -38,6 +38,7 @@ func (q *BulkQueries) InsertVersions(ctx context.Context, db DBTX, args []Insert allArgs = append(allArgs, arg.GitCommitSha) allArgs = append(allArgs, arg.GitBranch) allArgs = append(allArgs, arg.ConfigSnapshot) + allArgs = append(allArgs, arg.OpenapiSpec) allArgs = append(allArgs, arg.Status) allArgs = append(allArgs, arg.CreatedAt) allArgs = append(allArgs, arg.UpdatedAt) diff --git a/go/pkg/db/key_permission_delete_many_by_key_and_permission_ids.sql_generated.go b/go/pkg/db/key_permission_delete_many_by_key_and_permission_ids.sql_generated.go new file mode 100644 index 0000000000..557f41c40c --- /dev/null +++ b/go/pkg/db/key_permission_delete_many_by_key_and_permission_ids.sql_generated.go @@ -0,0 +1,41 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: key_permission_delete_many_by_key_and_permission_ids.sql + +package db + +import ( + "context" + "strings" +) + +const deleteManyKeyPermissionByKeyAndPermissionIDs = `-- name: DeleteManyKeyPermissionByKeyAndPermissionIDs :exec +DELETE FROM keys_permissions +WHERE key_id = ? AND permission_id IN (/*SLICE:ids*/?) +` + +type DeleteManyKeyPermissionByKeyAndPermissionIDsParams struct { + KeyID string `db:"key_id"` + Ids []string `db:"ids"` +} + +// DeleteManyKeyPermissionByKeyAndPermissionIDs +// +// DELETE FROM keys_permissions +// WHERE key_id = ? AND permission_id IN (/*SLICE:ids*/?) +func (q *Queries) DeleteManyKeyPermissionByKeyAndPermissionIDs(ctx context.Context, db DBTX, arg DeleteManyKeyPermissionByKeyAndPermissionIDsParams) error { + query := deleteManyKeyPermissionByKeyAndPermissionIDs + var queryParams []interface{} + queryParams = append(queryParams, arg.KeyID) + if len(arg.Ids) > 0 { + for _, v := range arg.Ids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + } + _, err := db.ExecContext(ctx, query, queryParams...) + return err +} diff --git a/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go b/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go new file mode 100644 index 0000000000..8ce9a85383 --- /dev/null +++ b/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: permission_find_by_id_or_slug.sql + +package db + +import ( + "context" +) + +const findPermissionByIdOrSlug = `-- name: FindPermissionByIdOrSlug :one +SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m +FROM permissions +WHERE workspace_id = ? AND (id = ? OR slug = ?) +` + +type FindPermissionByIdOrSlugParams struct { + WorkspaceID string `db:"workspace_id"` + ID string `db:"id"` + Slug string `db:"slug"` +} + +// FindPermissionByIdOrSlug +// +// SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m +// FROM permissions +// WHERE workspace_id = ? AND (id = ? OR slug = ?) +func (q *Queries) FindPermissionByIdOrSlug(ctx context.Context, db DBTX, arg FindPermissionByIdOrSlugParams) (Permission, error) { + row := db.QueryRowContext(ctx, findPermissionByIdOrSlug, arg.WorkspaceID, arg.ID, arg.Slug) + var i Permission + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAtM, + &i.UpdatedAtM, + ) + return i, err +} diff --git a/go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go b/go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go new file mode 100644 index 0000000000..0b1afeac37 --- /dev/null +++ b/go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go @@ -0,0 +1,77 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: permission_find_many_by_id_or_slug.sql + +package db + +import ( + "context" + "strings" +) + +const findManyPermissionsByIdOrSlug = `-- name: FindManyPermissionsByIdOrSlug :many +SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m +FROM permissions +WHERE workspace_id = ? AND (id IN (/*SLICE:ids*/?) OR slug IN (/*SLICE:ids*/?)) +` + +type FindManyPermissionsByIdOrSlugParams struct { + WorkspaceID string `db:"workspace_id"` + Ids []string `db:"ids"` +} + +// FindManyPermissionsByIdOrSlug +// +// SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m +// FROM permissions +// WHERE workspace_id = ? AND (id IN (/*SLICE:ids*/?) OR slug IN (/*SLICE:ids*/?)) +func (q *Queries) FindManyPermissionsByIdOrSlug(ctx context.Context, db DBTX, arg FindManyPermissionsByIdOrSlugParams) ([]Permission, error) { + query := findManyPermissionsByIdOrSlug + var queryParams []interface{} + queryParams = append(queryParams, arg.WorkspaceID) + if len(arg.Ids) > 0 { + for _, v := range arg.Ids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + } + if len(arg.Ids) > 0 { + for _, v := range arg.Ids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + } + rows, err := db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Permission + for rows.Next() { + var i Permission + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAtM, + &i.UpdatedAtM, + ); 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 64e6dcdbf7..6985c3d3c3 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -39,6 +39,11 @@ type Querier interface { // DELETE FROM keys_permissions // WHERE key_id = ? AND permission_id = ? DeleteKeyPermissionByKeyAndPermissionID(ctx context.Context, db DBTX, arg DeleteKeyPermissionByKeyAndPermissionIDParams) error + //DeleteManyKeyPermissionByKeyAndPermissionIDs + // + // DELETE FROM keys_permissions + // WHERE key_id = ? AND permission_id IN (/*SLICE:ids*/?) + DeleteManyKeyPermissionByKeyAndPermissionIDs(ctx context.Context, db DBTX, arg DeleteManyKeyPermissionByKeyAndPermissionIDsParams) error //DeleteManyKeyPermissionsByPermissionID // // DELETE FROM keys_permissions @@ -304,6 +309,12 @@ type Querier interface { // ORDER BY created_at DESC // LIMIT 1 FindLatestBuildByVersionId(ctx context.Context, db DBTX, versionID string) (Build, error) + //FindManyPermissionsByIdOrSlug + // + // SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m + // FROM permissions + // WHERE workspace_id = ? AND (id IN (/*SLICE:ids*/?) OR slug IN (/*SLICE:ids*/?)) + FindManyPermissionsByIdOrSlug(ctx context.Context, db DBTX, arg FindManyPermissionsByIdOrSlugParams) ([]Permission, error) // Finds a permission record by its ID // Returns: The permission record if found // @@ -312,6 +323,12 @@ type Querier interface { // WHERE id = ? // LIMIT 1 FindPermissionByID(ctx context.Context, db DBTX, permissionID string) (Permission, error) + //FindPermissionByIdOrSlug + // + // SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m + // FROM permissions + // WHERE workspace_id = ? AND (id = ? OR slug = ?) + FindPermissionByIdOrSlug(ctx context.Context, db DBTX, arg FindPermissionByIdOrSlugParams) (Permission, error) //FindPermissionByNameAndWorkspaceID // // SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m diff --git a/go/pkg/db/queries/key_permission_delete_many_by_key_and_permission_ids.sql b/go/pkg/db/queries/key_permission_delete_many_by_key_and_permission_ids.sql new file mode 100644 index 0000000000..4d7380f5a7 --- /dev/null +++ b/go/pkg/db/queries/key_permission_delete_many_by_key_and_permission_ids.sql @@ -0,0 +1,3 @@ +-- name: DeleteManyKeyPermissionByKeyAndPermissionIDs :exec +DELETE FROM keys_permissions +WHERE key_id = sqlc.arg(key_id) AND permission_id IN (sqlc.slice(ids)); diff --git a/go/pkg/db/queries/permission_find_by_id_or_slug.sql b/go/pkg/db/queries/permission_find_by_id_or_slug.sql new file mode 100644 index 0000000000..134ad4ebbf --- /dev/null +++ b/go/pkg/db/queries/permission_find_by_id_or_slug.sql @@ -0,0 +1,4 @@ +-- name: FindPermissionByIdOrSlug :one +SELECT * +FROM permissions +WHERE workspace_id = ? AND (id = ? OR slug = ?); diff --git a/go/pkg/db/queries/permission_find_many_by_id_or_slug.sql b/go/pkg/db/queries/permission_find_many_by_id_or_slug.sql new file mode 100644 index 0000000000..01fde32ee1 --- /dev/null +++ b/go/pkg/db/queries/permission_find_many_by_id_or_slug.sql @@ -0,0 +1,4 @@ +-- name: FindManyPermissionsByIdOrSlug :many +SELECT * +FROM permissions +WHERE workspace_id = ? AND (id IN (sqlc.slice('ids')) OR slug IN (sqlc.slice('ids'))); From 66ae61c702bbba0be6ac199d8f8a10e03ff89072 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:30:01 +0200 Subject: [PATCH 02/20] change some endpoints --- .../v2_keys_add_permissions/200_test.go | 51 ++---- .../v2_keys_add_permissions/400_test.go | 42 +---- .../v2_keys_add_permissions/401_test.go | 10 +- .../v2_keys_add_permissions/403_test.go | 30 +--- .../v2_keys_add_permissions/404_test.go | 50 ++---- .../routes/v2_keys_add_permissions/handler.go | 61 +++++-- .../api/routes/v2_keys_delete_key/handler.go | 2 +- .../v2_keys_remove_permissions/200_test.go | 57 ++---- .../v2_keys_set_permissions/200_test.go | 54 ++---- .../v2_keys_set_permissions/400_test.go | 68 +------- .../v2_keys_set_permissions/401_test.go | 10 +- .../v2_keys_set_permissions/403_test.go | 10 +- .../v2_keys_set_permissions/404_test.go | 31 +--- .../routes/v2_keys_set_permissions/handler.go | 162 +++++++++++------- 14 files changed, 214 insertions(+), 424 deletions(-) diff --git a/go/apps/api/routes/v2_keys_add_permissions/200_test.go b/go/apps/api/routes/v2_keys_add_permissions/200_test.go index 6367fb921e..9f896f296d 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/200_test.go @@ -71,14 +71,8 @@ func TestSuccess(t *testing.T) { require.Empty(t, currentPermissions) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -152,14 +146,8 @@ func TestSuccess(t *testing.T) { require.Empty(t, currentPermissions) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &permissionSlug}, - }, + KeyId: keyID, + Permissions: []string{permissionSlug}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -222,15 +210,8 @@ func TestSuccess(t *testing.T) { }) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permission1ID}, - {Slug: &permission2Slug}, - }, + KeyId: keyID, + Permissions: []string{permission1ID, permission2Slug}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -286,14 +267,8 @@ func TestSuccess(t *testing.T) { }) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } // Add permission first time @@ -366,14 +341,8 @@ func TestSuccess(t *testing.T) { keyID := keyResponse.KeyID req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &newPermissionID}, - }, + KeyId: keyID, + Permissions: []string{newPermissionID}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_add_permissions/400_test.go b/go/apps/api/routes/v2_keys_add_permissions/400_test.go index 677752aa67..06b3a171d4 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/400_test.go @@ -184,14 +184,8 @@ func TestValidationErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: validKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {}, // Neither id nor name provided - }, + KeyId: validKeyID, + Permissions: []string{}, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -211,14 +205,8 @@ func TestValidationErrors(t *testing.T) { nonExistentPermissionID := uid.New(uid.TestPrefix) req := handler.Request{ - KeyId: validKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &nonExistentPermissionID}, - }, + KeyId: validKeyID, + Permissions: []string{nonExistentPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -235,17 +223,9 @@ func TestValidationErrors(t *testing.T) { }) t.Run("permission not found by slug", func(t *testing.T) { - nonExistentPermissionSlug := "nonexistent.permission" - req := handler.Request{ - KeyId: validKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &nonExistentPermissionSlug}, - }, + KeyId: validKeyID, + Permissions: []string{"nonexistent.permission"}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -276,14 +256,8 @@ func TestValidationErrors(t *testing.T) { nonExistentKeyID := uid.New(uid.KeyPrefix) req := handler.Request{ - KeyId: nonExistentKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: nonExistentKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_add_permissions/401_test.go b/go/apps/api/routes/v2_keys_add_permissions/401_test.go index 5507eb6991..b1481e90e9 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/401_test.go @@ -76,14 +76,8 @@ func TestAuthenticationErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } t.Run("missing authorization header", func(t *testing.T) { diff --git a/go/apps/api/routes/v2_keys_add_permissions/403_test.go b/go/apps/api/routes/v2_keys_add_permissions/403_test.go index 93bdc96aee..a3f5393fe3 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/403_test.go @@ -76,14 +76,8 @@ func TestAuthorizationErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } t.Run("root key without required permissions", func(t *testing.T) { @@ -175,14 +169,8 @@ func TestAuthorizationErrors(t *testing.T) { authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") reqWithOtherKey := handler.Request{ - KeyId: otherKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: otherKeyID, + Permissions: []string{permissionID}, } headers := http.Header{ @@ -228,14 +216,8 @@ func TestAuthorizationErrors(t *testing.T) { authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") reqWithOtherPermission := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &otherPermissionID}, - }, + KeyId: keyID, + Permissions: []string{otherPermissionID}, } headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_add_permissions/404_test.go b/go/apps/api/routes/v2_keys_add_permissions/404_test.go index d7d8f11f91..328c53e726 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/404_test.go @@ -57,14 +57,8 @@ func TestNotFoundErrors(t *testing.T) { nonExistentKeyID := uid.New(uid.KeyPrefix) req := handler.Request{ - KeyId: nonExistentKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: nonExistentKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -101,14 +95,8 @@ func TestNotFoundErrors(t *testing.T) { nonExistentPermissionID := uid.New(uid.TestPrefix) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &nonExistentPermissionID}, - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -144,14 +132,8 @@ func TestNotFoundErrors(t *testing.T) { nonExistentPermissionSlug := "nonexistent.permission.name" req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &nonExistentPermissionSlug}, - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionSlug}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -206,14 +188,8 @@ func TestNotFoundErrors(t *testing.T) { keyID := keyResponse.KeyID req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &otherPermissionID}, - }, + KeyId: keyID, + Permissions: []string{otherPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -268,14 +244,8 @@ func TestNotFoundErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: otherKeyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: otherKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index 1d62a4bac0..708e79b027 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "database/sql" "fmt" "net/http" "slices" @@ -43,32 +44,23 @@ func (h *Handler) Path() string { // Handle processes the HTTP request func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( - rbac.T(rbac.Tuple{ - ResourceType: rbac.Api, - ResourceID: "*", - Action: rbac.UpdateKey, - }), - ))) - if err != nil { - return err - } - - // 4. Validate key exists and belongs to workspace - key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), req.KeyId) + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) if err != nil { if db.IsNotFound(err) { return fault.New("key not found", @@ -82,7 +74,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Validate key belongs to authorized workspace if key.WorkspaceID != auth.AuthorizedWorkspaceID { return fault.New("key not found", fault.Code(codes.Data.Key.NotFound.URN()), @@ -90,7 +81,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Get current direct permissions for the key + if key.Api.DeletedAtM.Valid { + return fault.New("key not found", + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key belongs to deleted api"), + fault.Public("The specified key was not found."), + ) + } + currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -99,6 +97,33 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } + err = auth.Verify(ctx, keys.WithPermissions( + rbac.And( + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.UpdateKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.UpdateKey, + }), + ), + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.AddPermissionToKey, + }), + ), + ), + )) + if err != nil { + return err + } + // Convert current permissions to a map for efficient lookup currentPermissionIDs := make(map[string]bool) for _, permission := range currentPermissions { diff --git a/go/apps/api/routes/v2_keys_delete_key/handler.go b/go/apps/api/routes/v2_keys_delete_key/handler.go index b297256cfb..2e9a5418ce 100644 --- a/go/apps/api/routes/v2_keys_delete_key/handler.go +++ b/go/apps/api/routes/v2_keys_delete_key/handler.go @@ -163,6 +163,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Meta: openapi.Meta{ RequestId: s.RequestID(), }, - Data: &openapi.KeysDeleteKeyResponseData{}, + Data: openapi.EmptyResponse{}, }) } diff --git a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go index c085c61e5d..10df9aa730 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go @@ -75,13 +75,8 @@ func TestSuccess(t *testing.T) { require.Len(t, currentPermissions, 1) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -147,13 +142,8 @@ func TestSuccess(t *testing.T) { keyID := keyResponse.KeyID req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &permissionName}, - }, + KeyId: keyID, + Permissions: []string{permissionName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -229,14 +219,8 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permission1ID}, - {Slug: &permission2Name}, - }, + KeyId: keyID, + Permissions: []string{permission1ID, permission2Name}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -285,13 +269,8 @@ func TestSuccess(t *testing.T) { }) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } // Remove permission (which isn't assigned) @@ -379,13 +358,8 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &removePermissionID}, // Only remove this one - }, + KeyId: keyID, + Permissions: []string{removePermissionID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -482,13 +456,10 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permission1ID}, - {Id: &permission2ID}, - {Id: &permission3ID}, + Permissions: []string{ + permission1ID, + permission2ID, + permission3ID, }, } diff --git a/go/apps/api/routes/v2_keys_set_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index 5024879782..ab380dd07e 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -106,15 +106,8 @@ func TestSuccess(t *testing.T) { require.Equal(t, permission1ID, currentPermissions[0].ID) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permission2ID}, - {Id: &permission3ID}, - }, + KeyId: keyID, + Permissions: []string{permission2ID, permission3ID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -198,14 +191,8 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &[]string{"documents.write.byname"}[0]}, - }, + KeyId: keyID, + Permissions: []string{"documents.write.byname"}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -290,12 +277,8 @@ func TestSuccess(t *testing.T) { require.Len(t, currentPermissions, 2) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{}, + KeyId: keyID, + Permissions: []string{}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -355,14 +338,8 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -405,20 +382,9 @@ func TestSuccess(t *testing.T) { // Use a slug that doesn't exist yet newPermissionSlug := "documents.create.onthefly" - createFlag := true - req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - { - Slug: &newPermissionSlug, - Create: &createFlag, - }, - }, + KeyId: keyID, + Permissions: []string{newPermissionSlug}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_set_permissions/400_test.go b/go/apps/api/routes/v2_keys_set_permissions/400_test.go index 984be05c24..a71b49dbaf 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/400_test.go @@ -126,48 +126,6 @@ func TestBadRequest(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "The specified key was not found") }) - t.Run("permission with neither id nor name", func(t *testing.T) { - // Create test data using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {}, // Empty permission object - }, - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Each permission must specify either 'id' or 'slug'") - }) - t.Run("permission with empty string id and name", func(t *testing.T) { // Create test data using testutil helpers defaultPrefix := "test" @@ -186,19 +144,9 @@ func TestBadRequest(t *testing.T) { }) keyID := keyResponse.KeyID - emptyString := "" req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - { - Id: &emptyString, - Slug: &emptyString, - }, - }, + KeyId: keyID, + Permissions: []string{""}, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -234,16 +182,8 @@ func TestBadRequest(t *testing.T) { invalidID := "invalid_permission_id" req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - { - Id: &invalidID, - }, - }, + KeyId: keyID, + Permissions: []string{invalidID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_set_permissions/401_test.go b/go/apps/api/routes/v2_keys_set_permissions/401_test.go index 53a9ed80df..355a3ecb08 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/401_test.go @@ -76,14 +76,8 @@ func TestAuthenticationErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } t.Run("missing authorization header", func(t *testing.T) { diff --git a/go/apps/api/routes/v2_keys_set_permissions/403_test.go b/go/apps/api/routes/v2_keys_set_permissions/403_test.go index add27d990b..bf1e596343 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/403_test.go @@ -76,14 +76,8 @@ func TestForbidden(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } t.Run("missing update_key permission", func(t *testing.T) { diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index 3f14ce6914..92f95e2854 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -182,14 +182,8 @@ func TestNotFound(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, // Key from different workspace - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, // Key from different workspace + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -238,14 +232,8 @@ func TestNotFound(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, // Permission from different workspace - }, + KeyId: keyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -294,15 +282,8 @@ func TestNotFound(t *testing.T) { nonExistentPermissionID := uid.New(uid.TestPrefix) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &nonExistentPermissionID}, // This should fail first - {Id: &validPermissionID}, // This should not be processed - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionID, validPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index 33fdf65487..e1c3e6b9d0 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -48,32 +48,23 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.setPermissions") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( - rbac.T(rbac.Tuple{ - ResourceType: rbac.Api, - ResourceID: "*", - Action: rbac.UpdateKey, - }), - ))) - if err != nil { - return err - } - - // 4. Validate key exists and belongs to workspace - key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), req.KeyId) + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) if err != nil { if db.IsNotFound(err) { return fault.New("key not found", @@ -87,7 +78,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Validate key belongs to authorized workspace if key.WorkspaceID != auth.AuthorizedWorkspaceID { return fault.New("key not found", fault.Code(codes.Data.Key.NotFound.URN()), @@ -95,7 +85,43 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Get current direct permissions for the key + if key.Api.DeletedAtM.Valid { + return fault.New("key not found", + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key belongs to deleted api"), + fault.Public("The specified key was not found."), + ) + } + + err = auth.Verify(ctx, keys.WithPermissions( + rbac.And( + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.UpdateKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.UpdateKey, + }), + ), + rbac.And( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.AddPermissionToKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.RemovePermissionFromKey, + }), + ), + ), + )) + currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -104,7 +130,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - permissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ WorkspaceID: auth.AuthorizedWorkspaceID, Ids: req.Permissions, }) @@ -114,20 +140,15 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { missingPermissions[permission] = struct{}{} } - for _, permission := range permissions { - if _, ok := missingPermissions[permission.ID]; ok { - delete(missingPermissions, permission.ID) - } - - if _, ok := missingPermissions[permission.Slug]; ok { - delete(missingPermissions, permission.Slug) - } + for _, permission := range foundPermissions { + delete(missingPermissions, permission.ID) + delete(missingPermissions, permission.Slug) } permissionsToSet := make([]db.Permission, 0) permissionsToInsert := make([]db.InsertPermissionParams, 0) - for _, permission := range permissions { + for _, permission := range foundPermissions { permissionsToSet = append(permissionsToSet, permission) } @@ -135,8 +156,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if strings.HasPrefix(perm, "perm_") { return fault.New("permission not found", fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), - fault.Public(fmt.Sprintf("Permission with ID '%s' was not found.", perm)), + fault.Public(fmt.Sprintf("Permission with ID %q was not found.", perm)), ) } @@ -161,8 +181,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - // 7. Calculate differential update - // Create maps for efficient lookup currentPermissionIDs := make(map[string]bool) for _, permission := range currentPermissions { currentPermissionIDs[permission.ID] = true @@ -170,12 +188,11 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { requestedPermissionIDs := make(map[string]bool) requestedPermissionMap := make(map[string]db.Permission) - for _, permission := range requestedPermissions { + for _, permission := range permissionsToSet { requestedPermissionIDs[permission.ID] = true requestedPermissionMap[permission.ID] = permission } - // Determine permissions to remove and add permissionsToRemove := make([]string, 0) for _, permission := range currentPermissions { if !requestedPermissionIDs[permission.ID] { @@ -184,13 +201,12 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } permissionsToAdd := make([]db.Permission, 0) - for _, permission := range requestedPermissions { + for _, permission := range foundPermissions { if !currentPermissionIDs[permission.ID] { permissionsToAdd = append(permissionsToAdd, permission) } } - // 8. Apply changes in transaction err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog @@ -208,34 +224,36 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - for _, perm := range permissionsToRemove { - // auditLogs = append(auditLogs, auditlog.AuditLog{ - // WorkspaceID: auth.AuthorizedWorkspaceID, - // Event: auditlog.AuthDisconnectPermissionKeyEvent, - // ActorType: auditlog.RootKeyActor, - // ActorID: auth.Key.ID, - // ActorName: "root key", - // ActorMeta: map[string]any{}, - // Display: fmt.Sprintf("Removed permission %s from key %s", permissionName, req.KeyId), - // RemoteIP: s.Location(), - // UserAgent: s.UserAgent(), - // Resources: []auditlog.AuditLogResource{ - // { - // Type: "key", - // ID: req.KeyId, - // Name: key.Name.String, - // DisplayName: key.Name.String, - // Meta: map[string]any{}, - // }, - // { - // Type: "permission", - // ID: permissionID, - // Name: permissionName, - // DisplayName: permissionName, - // Meta: map[string]any{}, - // }, - // }, - // }) + for _, permissionID := range permissionsToRemove { + perm := requestedPermissionMap[permissionID] + + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.AuthDisconnectPermissionKeyEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Removed permission %s from key %s", perm.Name, req.KeyId), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: "key", + ID: req.KeyId, + Name: key.Name.String, + DisplayName: key.Name.String, + Meta: map[string]any{}, + }, + { + Type: "permission", + ID: permissionID, + Name: perm.Slug, + DisplayName: perm.Name, + Meta: map[string]any{}, + }, + }, + }) } } @@ -304,10 +322,22 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) - // Build response data responseData := make(openapi.V2KeysSetPermissionsResponseData, 0) + for _, permission := range foundPermissions { + perm := openapi.Permission{ + Description: nil, + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + } + + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + responseData = append(responseData, perm) + } - // 11. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), From 0cd3cecc598c9e1ffe3e91e9d20bc7898eb90c77 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:33:11 +0200 Subject: [PATCH 03/20] change some endpoints --- go/apps/api/openapi/gen.go | 17 +-- go/apps/api/openapi/openapi-generated.yaml | 101 +++++------------- .../paths/v2/keys/addPermissions/index.yaml | 89 +++++---------- .../V2KeysRemovePermissionsResponseData.yaml | 5 - ...V2PermissionsGetPermissionRequestBody.yaml | 12 +-- .../routes/v2_keys_add_permissions/handler.go | 6 +- .../api/routes/v2_keys_add_roles/handler.go | 4 +- .../api/routes/v2_keys_create_key/handler.go | 12 +-- .../v2_keys_remove_permissions/400_test.go | 61 ++--------- .../v2_keys_remove_permissions/401_test.go | 9 +- .../v2_keys_remove_permissions/403_test.go | 27 ++--- .../v2_keys_remove_permissions/404_test.go | 45 ++------ .../v2_keys_remove_permissions/handler.go | 6 +- .../routes/v2_keys_remove_roles/handler.go | 4 +- .../routes/v2_keys_set_permissions/handler.go | 8 +- .../api/routes/v2_keys_set_roles/handler.go | 8 +- .../api/routes/v2_keys_update_key/handler.go | 4 +- .../handler.go | 6 +- .../v2_permissions_create_role/handler.go | 4 +- .../handler.go | 9 +- .../v2_permissions_delete_role/handler.go | 4 +- .../handler.go | 5 - ...ission_find_by_id_or_slug.sql_generated.go | 5 +- .../queries/permission_find_by_id_or_slug.sql | 2 +- go/pkg/zen/middleware_tracing.go | 3 + 25 files changed, 126 insertions(+), 330 deletions(-) diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 0d818aa5b4..70f0a82070 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -1072,11 +1072,6 @@ type V2KeysRemovePermissionsRequestBody struct { type V2KeysRemovePermissionsResponseBody struct { // Data Complete list of all permissions directly assigned to the key after the removal operation (remaining permissions only). // - // This response includes: - // - All direct permissions still assigned to the key after removal - // - Permissions sorted alphabetically by name for consistent response format - // - Both the permission ID and name for each remaining permission - // // Notes: // - This list does NOT include permissions granted through roles // - For a complete permission picture, use `/v2/keys.getKey` instead @@ -1090,11 +1085,6 @@ type V2KeysRemovePermissionsResponseBody struct { // V2KeysRemovePermissionsResponseData Complete list of all permissions directly assigned to the key after the removal operation (remaining permissions only). // -// This response includes: -// - All direct permissions still assigned to the key after removal -// - Permissions sorted alphabetically by name for consistent response format -// - Both the permission ID and name for each remaining permission -// // Notes: // - This list does NOT include permissions granted through roles // - For a complete permission picture, use `/v2/keys.getKey` instead @@ -1622,11 +1612,8 @@ type V2PermissionsDeleteRoleResponseBody struct { // V2PermissionsGetPermissionRequestBody defines model for V2PermissionsGetPermissionRequestBody. type V2PermissionsGetPermissionRequestBody struct { - // PermissionId Specifies which permission to retrieve by its unique identifier. - // Must be a valid permission ID that begins with 'perm_' and exists within your workspace. - // Use this endpoint to verify permission details, check its current configuration, or retrieve metadata. - // Returns detailed information including name, description, and workspace association. - PermissionId string `json:"permissionId"` + // Permission Specifies which permission to retrieve by either its slug or permission id. + Permission string `json:"permission"` } // V2PermissionsGetPermissionResponseBody defines model for V2PermissionsGetPermissionResponseBody. diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 676f7457e6..6f9cbaad92 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-22T15:25:41Z +# Generated at: 2025-07-23T09:49:03Z # Source: openapi-split.yaml components: @@ -1559,18 +1559,14 @@ components: V2PermissionsGetPermissionRequestBody: type: object required: - - permissionId + - permission properties: - permissionId: + permission: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - Specifies which permission to retrieve by its unique identifier. - Must be a valid permission ID that begins with 'perm_' and exists within your workspace. - Use this endpoint to verify permission details, check its current configuration, or retrieve metadata. - Returns detailed information including name, description, and workspace association. + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specifies which permission to retrieve by either its slug or permission id. example: perm_1234567890abcdef additionalProperties: false V2PermissionsGetPermissionResponseBody: @@ -2537,11 +2533,6 @@ components: description: |- Complete list of all permissions directly assigned to the key after the removal operation (remaining permissions only). - This response includes: - - All direct permissions still assigned to the key after removal - - Permissions sorted alphabetically by name for consistent response format - - Both the permission ID and name for each remaining permission - Notes: - This list does NOT include permissions granted through roles - For a complete permission picture, use `/v2/keys.getKey` instead @@ -3940,9 +3931,12 @@ paths: /v2/keys.addPermissions: post: description: |- - Adds one or more permissions to an existing API key without affecting existing permissions or roles. This is an additive operation that supplements the key's current permission set. + Adds one or more permissions to an existing API key without affecting existing permissions or roles. + + This is an additive operation that supplements the key's current permission set. Use this endpoint when you need to grant additional access rights to a key, such as upgrading user privileges, enabling new features, or implementing progressive permission models. - Only direct permissions are affected - permissions granted through roles remain unchanged. Changes take effect immediately for new verifications, though existing authorized sessions may continue until their cache expires. + + Only direct permissions are affected - permissions granted through roles remain unchanged. operationId: addPermissions requestBody: content: @@ -3954,65 +3948,29 @@ paths: value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - id: perm_2zF4mNyP9BsRj2aQwDxVkT + - perm_2zF4mNyP9BsRj2aQwDxVkT + - perm_1n9McEIBSqy44Qy7hzWyM5 completeBilling: description: Creating a structured set of permissions for a complete subsystem like billing summary: Complete billing system permissions value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - create: true - name: billing.invoices.create - - create: true - name: billing.invoices.view - - create: true - name: billing.invoices.update - - create: true - name: billing.payments.process - - create: true - name: billing.payments.refund - - create: true - name: billing.settings.view + - billing.invoices.create + - billing.invoices.view + - billing.invoices.update + - billing.payments.process + - billing.payments.refund + - billing.settings.view hierarchical: description: Permissions can use hierarchical naming with dots as separators. During verification, a request for 'billing.invoices.*' will match both 'billing.invoices.create' and 'billing.invoices.view'. summary: Using hierarchical permission naming value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - create: true - name: billing.invoices.create - - create: true - name: billing.invoices.view - - create: true - name: billing.payments.process - mixed: - description: You can combine ID references, name references, and creation in a single request. If both id and name are provided for any permission, the id takes precedence. - summary: Mix ID and name references - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - name: documents.publish - - create: true - name: analytics.view - withCreation: - description: Setting create=true dynamically creates permissions if they don't exist yet. This requires the `rbac.*.create_permission` permission on your root key. - summary: Add and create new permissions - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - create: true - name: reports.export - - create: true - name: reports.schedule - withNames: - summary: Add permissions using names - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: documents.write - - name: documents.delete + - billing.invoices.create + - billing.invoices.view + - billing.payments.process schema: $ref: '#/components/schemas/V2KeysAddPermissionsRequestBody' required: true @@ -4033,14 +3991,19 @@ paths: data: - id: perm_1n9McEIBSqy44Qy7hzWyM5 name: billing.invoices.create + slug: billing.invoices.create - id: perm_2zF4mNyP9BsRj2aQwDxVkT name: billing.invoices.view + slug: billing.invoices.view - id: perm_3qRsTu2vWxYzAbCdEfGhIj name: billing.payments.process + slug: billing.payments.process - id: perm_4bVcWdXeYfZgHiJkLmNoPq name: billing.settings.read + slug: billing.settings.read - id: perm_5sTu2vWxYzAbCdEfGhIjKl name: billing.settings.write + slug: billing.settings.write meta: requestId: req_7zF4mNyP9BsRj2aQwDxVkT standard: @@ -4048,16 +4011,10 @@ paths: summary: Complete list of permissions value: data: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 + - description: Some description + id: perm_1n9McEIBSqy44Qy7hzWyM5 name: documents.write - - id: perm_2zF4mNyP9BsRj2aQwDxVkT - name: documents.delete - - id: perm_3qRsTu2vWxYzAbCdEfGhIj - name: documents.read - - id: perm_4bVcWdXeYfZgHiJkLmNoPq - name: reports.export - - id: perm_5sTu2vWxYzAbCdEfGhIjKl - name: reports.schedule + slug: documents.write meta: requestId: req_2cGKbMxRyIzhCxo1Idjz8q schema: diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml index 20817e0729..515b0a9b11 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml @@ -3,9 +3,12 @@ post: - keys summary: Add permissions to an API key description: |- - Adds one or more permissions to an existing API key without affecting existing permissions or roles. This is an additive operation that supplements the key's current permission set. + Adds one or more permissions to an existing API key without affecting existing permissions or roles. + + This is an additive operation that supplements the key's current permission set. Use this endpoint when you need to grant additional access rights to a key, such as upgrading user privileges, enabling new features, or implementing progressive permission models. - Only direct permissions are affected - permissions granted through roles remain unchanged. Changes take effect immediately for new verifications, though existing authorized sessions may continue until their cache expires. + + Only direct permissions are affected - permissions granted through roles remain unchanged. operationId: addPermissions x-speakeasy-name-override: addPermissions security: @@ -26,40 +29,8 @@ post: value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - id: perm_2zF4mNyP9BsRj2aQwDxVkT - withNames: - summary: Add permissions using names - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: documents.write - - name: documents.delete - withCreation: - summary: Add and create new permissions - description: Setting create=true dynamically creates permissions if - they don't exist yet. This requires the `rbac.*.create_permission` - permission on your root key. - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - name: reports.export - create: true - - name: reports.schedule - create: true - mixed: - summary: Mix ID and name references - description: - You can combine ID references, name references, and creation - in a single request. If both id and name are provided for any permission, - the id takes precedence. - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: - - id: perm_1n9McEIBSqy44Qy7hzWyM5 - - name: documents.publish - - name: analytics.view - create: true + - perm_2zF4mNyP9BsRj2aQwDxVkT + - perm_1n9McEIBSqy44Qy7hzWyM5 hierarchical: summary: Using hierarchical permission naming description: Permissions can use hierarchical naming with dots as @@ -68,12 +39,9 @@ post: value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - name: billing.invoices.create - create: true - - name: billing.invoices.view - create: true - - name: billing.payments.process - create: true + - billing.invoices.create + - billing.invoices.view + - billing.payments.process completeBilling: summary: Complete billing system permissions description: Creating a structured set of permissions for a complete @@ -81,18 +49,12 @@ post: value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - - name: billing.invoices.create - create: true - - name: billing.invoices.view - create: true - - name: billing.invoices.update - create: true - - name: billing.payments.process - create: true - - name: billing.payments.refund - create: true - - name: billing.settings.view - create: true + - billing.invoices.create + - billing.invoices.view + - billing.invoices.update + - billing.payments.process + - billing.payments.refund + - billing.settings.view responses: "200": description: Permissions successfully added to the key @@ -112,14 +74,8 @@ post: data: - id: perm_1n9McEIBSqy44Qy7hzWyM5 name: documents.write - - id: perm_2zF4mNyP9BsRj2aQwDxVkT - name: documents.delete - - id: perm_3qRsTu2vWxYzAbCdEfGhIj - name: documents.read - - id: perm_4bVcWdXeYfZgHiJkLmNoPq - name: reports.export - - id: perm_5sTu2vWxYzAbCdEfGhIjKl - name: reports.schedule + slug: documents.write + description: "Some description" hierarchical: summary: Key with hierarchical permissions value: @@ -128,14 +84,19 @@ post: data: - id: perm_1n9McEIBSqy44Qy7hzWyM5 name: billing.invoices.create + slug: billing.invoices.create - id: perm_2zF4mNyP9BsRj2aQwDxVkT name: billing.invoices.view + slug: billing.invoices.view - id: perm_3qRsTu2vWxYzAbCdEfGhIj name: billing.payments.process + slug: billing.payments.process - id: perm_4bVcWdXeYfZgHiJkLmNoPq name: billing.settings.read + slug: billing.settings.read - id: perm_5sTu2vWxYzAbCdEfGhIjKl name: billing.settings.write + slug: billing.settings.write empty: summary: Key with no permissions value: @@ -188,9 +149,7 @@ post: status: 401 type: unauthorized "403": - description: - Forbidden - Insufficient permissions (requires `rbac.*.add_permission_to_key` - and potentially `rbac.*.create_permission`) + description: Forbidden - Insufficient permissions (requires `rbac.*.add_permission_to_key` and potentially `rbac.*.create_permission`) content: application/json: schema: diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml index c7d6129ae4..4bab2baf5c 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml @@ -2,11 +2,6 @@ type: array description: |- Complete list of all permissions directly assigned to the key after the removal operation (remaining permissions only). - This response includes: - - All direct permissions still assigned to the key after removal - - Permissions sorted alphabetically by name for consistent response format - - Both the permission ID and name for each remaining permission - Notes: - This list does NOT include permissions granted through roles - For a complete permission picture, use `/v2/keys.getKey` instead diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/getPermission/V2PermissionsGetPermissionRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/getPermission/V2PermissionsGetPermissionRequestBody.yaml index 1369c48ba5..f9c2c12079 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/getPermission/V2PermissionsGetPermissionRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/getPermission/V2PermissionsGetPermissionRequestBody.yaml @@ -1,16 +1,12 @@ type: object required: - - permissionId + - permission properties: - permissionId: + permission: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - Specifies which permission to retrieve by its unique identifier. - Must be a valid permission ID that begins with 'perm_' and exists within your workspace. - Use this endpoint to verify permission details, check its current configuration, or retrieve metadata. - Returns detailed information including name, description, and workspace association. + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specifies which permission to retrieve by either its slug or permission id. example: perm_1234567890abcdef additionalProperties: false diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index 708e79b027..69f3f894cd 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -226,16 +226,16 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: permission.ID, - Name: permission.Name, + Name: permission.Slug, DisplayName: permission.Name, Meta: map[string]any{}, }, diff --git a/go/apps/api/routes/v2_keys_add_roles/handler.go b/go/apps/api/routes/v2_keys_add_roles/handler.go index 1ff2a5f2c2..d1a3f61c04 100644 --- a/go/apps/api/routes/v2_keys_add_roles/handler.go +++ b/go/apps/api/routes/v2_keys_add_roles/handler.go @@ -209,14 +209,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "role", + Type: auditlog.RoleResourceType, ID: role.ID, Name: role.Name, DisplayName: role.Name, 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 59e55afe2e..abca1fb15c 100644 --- a/go/apps/api/routes/v2_keys_create_key/handler.go +++ b/go/apps/api/routes/v2_keys_create_key/handler.go @@ -426,14 +426,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: keyID, Name: insertKeyParams.Name.String, DisplayName: insertKeyParams.Name.String, Meta: map[string]any{}, }, { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: reqPerm.ID, Name: reqPerm.Slug, DisplayName: reqPerm.Slug, @@ -513,14 +513,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: keyID, DisplayName: insertKeyParams.Name.String, Name: insertKeyParams.Name.String, Meta: map[string]any{}, }, { - Type: "role", + Type: auditlog.RoleResourceType, ID: reqRole.ID, DisplayName: reqRole.Name, Name: reqRole.Name, @@ -555,14 +555,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: keyID, DisplayName: keyID, Name: keyID, Meta: map[string]any{}, }, { - Type: "api", + Type: auditlog.APIResourceType, ID: req.ApiId, DisplayName: api.Name, Name: api.Name, diff --git a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go index a8b88fcbcd..829d53bd48 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go @@ -156,51 +156,12 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - t.Run("permission missing both id and slug", func(t *testing.T) { - // Create a permission for valid structure - permissionID := uid.New(uid.TestPrefix) - err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: permissionID, - WorkspaceID: workspace.ID, - Name: "documents.read.remove.validation", - Slug: "documents.read.remove.validation", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, - }) - require.NoError(t, err) - - req := handler.Request{ - KeyId: validKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {}, // Neither id nor slug provided - }, - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - }) - t.Run("permission not found by id", func(t *testing.T) { nonExistentPermissionID := uid.New(uid.TestPrefix) req := handler.Request{ - KeyId: validKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &nonExistentPermissionID}, - }, + KeyId: validKeyID, + Permissions: []string{nonExistentPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -220,13 +181,8 @@ func TestValidationErrors(t *testing.T) { nonExistentPermissionName := "nonexistent.permission.remove" req := handler.Request{ - KeyId: validKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &nonExistentPermissionName}, - }, + KeyId: validKeyID, + Permissions: []string{nonExistentPermissionName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -257,13 +213,8 @@ func TestValidationErrors(t *testing.T) { nonExistentKeyID := uid.New(uid.KeyPrefix) req := handler.Request{ - KeyId: nonExistentKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: nonExistentKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_remove_permissions/401_test.go b/go/apps/api/routes/v2_keys_remove_permissions/401_test.go index 1568731bc0..b220f75d9c 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/401_test.go @@ -56,13 +56,8 @@ func TestAuthenticationErrors(t *testing.T) { permissionID := keyResponse.PermissionIds[0] req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } t.Run("missing authorization header", func(t *testing.T) { diff --git a/go/apps/api/routes/v2_keys_remove_permissions/403_test.go b/go/apps/api/routes/v2_keys_remove_permissions/403_test.go index 751bc431f1..f332498bec 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/403_test.go @@ -62,13 +62,8 @@ func TestAuthorizationErrors(t *testing.T) { permissionID := keyResponse.PermissionIds[0] req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionID}, } t.Run("root key without required permissions", func(t *testing.T) { @@ -145,13 +140,8 @@ func TestAuthorizationErrors(t *testing.T) { authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") reqWithOtherKey := handler.Request{ - KeyId: otherKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: otherKeyID, + Permissions: []string{permissionID}, } headers := http.Header{ @@ -197,13 +187,8 @@ func TestAuthorizationErrors(t *testing.T) { authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") reqWithOtherPermission := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &otherPermissionID}, - }, + KeyId: keyID, + Permissions: []string{otherPermissionID}, } headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go index 75e77c0270..d1a8ce3eac 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go @@ -56,13 +56,8 @@ func TestNotFoundErrors(t *testing.T) { nonExistentKeyID := uid.New(uid.KeyPrefix) req := handler.Request{ - KeyId: nonExistentKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: nonExistentKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -99,13 +94,8 @@ func TestNotFoundErrors(t *testing.T) { nonExistentPermissionID := uid.New(uid.TestPrefix) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &nonExistentPermissionID}, - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -142,13 +132,8 @@ func TestNotFoundErrors(t *testing.T) { nonExistentPermissionSlug := "nonexistent.permission.remove.name" req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &nonExistentPermissionSlug}, - }, + KeyId: keyID, + Permissions: []string{nonExistentPermissionSlug}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -218,13 +203,8 @@ func TestNotFoundErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &otherPermissionID}, - }, + KeyId: keyID, + Permissions: []string{otherPermissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -294,13 +274,8 @@ func TestNotFoundErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: otherKeyID, - Permissions: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: otherKeyID, + Permissions: []string{permissionID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_remove_permissions/handler.go b/go/apps/api/routes/v2_keys_remove_permissions/handler.go index 5cf3e957d3..7f6322695f 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/handler.go @@ -202,16 +202,16 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: permission.ID, - Name: permission.Name, + Name: permission.Slug, DisplayName: permission.Name, Meta: map[string]any{}, }, diff --git a/go/apps/api/routes/v2_keys_remove_roles/handler.go b/go/apps/api/routes/v2_keys_remove_roles/handler.go index 9478b478bf..fc193f90bd 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/handler.go +++ b/go/apps/api/routes/v2_keys_remove_roles/handler.go @@ -206,14 +206,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "role", + Type: auditlog.RoleResourceType, ID: role.ID, Name: role.Name, DisplayName: role.Name, diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index e1c3e6b9d0..e495ea4d2f 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -239,14 +239,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: permissionID, Name: perm.Slug, DisplayName: perm.Name, @@ -279,14 +279,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: permission.ID, Name: permission.Slug, DisplayName: permission.Name, diff --git a/go/apps/api/routes/v2_keys_set_roles/handler.go b/go/apps/api/routes/v2_keys_set_roles/handler.go index ecab6c2d25..3de69ca5d9 100644 --- a/go/apps/api/routes/v2_keys_set_roles/handler.go +++ b/go/apps/api/routes/v2_keys_set_roles/handler.go @@ -225,14 +225,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "role", + Type: auditlog.RoleResourceType, ID: removedRole.ID, Name: removedRole.Name, DisplayName: removedRole.Name, @@ -269,14 +269,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: req.KeyId, Name: key.Name.String, DisplayName: key.Name.String, Meta: map[string]any{}, }, { - Type: "role", + Type: auditlog.RoleResourceType, ID: role.ID, Name: role.Name, DisplayName: role.Name, diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index 728ef13f69..4f8c83256f 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -537,14 +537,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "key", + Type: auditlog.KeyResourceType, ID: key.ID, DisplayName: key.Name.String, Name: key.Name.String, Meta: map[string]any{}, }, { - Type: "api", + Type: auditlog.APIResourceType, ID: key.Api.ID, DisplayName: key.Api.Name, Name: key.Api.Name, diff --git a/go/apps/api/routes/v2_permissions_create_permission/handler.go b/go/apps/api/routes/v2_permissions_create_permission/handler.go index a4d169b7ac..16d4f5af80 100644 --- a/go/apps/api/routes/v2_permissions_create_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_create_permission/handler.go @@ -96,7 +96,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, - Event: "permission.create", + Event: auditlog.PermissionCreateEvent, ActorType: auditlog.RootKeyActor, ActorID: auth.Key.ID, ActorName: "root key", @@ -106,9 +106,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: permissionID, - Name: req.Name, + Name: req.Slug, DisplayName: req.Name, Meta: map[string]interface{}{ "name": req.Name, diff --git a/go/apps/api/routes/v2_permissions_create_role/handler.go b/go/apps/api/routes/v2_permissions_create_role/handler.go index 4debda1a73..c31e09fb39 100644 --- a/go/apps/api/routes/v2_permissions_create_role/handler.go +++ b/go/apps/api/routes/v2_permissions_create_role/handler.go @@ -106,7 +106,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, - Event: "role.create", + Event: auditlog.RoleCreateEvent, ActorType: auditlog.RootKeyActor, ActorID: auth.Key.ID, ActorName: "root key", @@ -116,7 +116,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "role", + Type: auditlog.RoleResourceType, ID: roleID, Name: req.Name, DisplayName: req.Name, diff --git a/go/apps/api/routes/v2_permissions_delete_permission/handler.go b/go/apps/api/routes/v2_permissions_delete_permission/handler.go index 9792e583d4..55a902fbb6 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/handler.go @@ -63,8 +63,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permission, err := db.Query.FindPermissionByIdOrSlug(ctx, h.DB.RO(), db.FindPermissionByIdOrSlugParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - ID: req.Permission, - Slug: req.Permission, + Search: req.Permission, }) if err != nil { if db.IsNotFound(err) { @@ -109,7 +108,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, - Event: "permission.delete", + Event: auditlog.PermissionDeleteEvent, ActorType: auditlog.RootKeyActor, ActorID: auth.Key.ID, ActorName: "root key", @@ -119,9 +118,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "permission", + Type: auditlog.PermissionResourceType, ID: permission.ID, - Name: permission.Name, + Name: permission.Slug, DisplayName: permission.Name, Meta: map[string]interface{}{ "name": permission.Name, diff --git a/go/apps/api/routes/v2_permissions_delete_role/handler.go b/go/apps/api/routes/v2_permissions_delete_role/handler.go index ac6a1872d5..22dbd40747 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/handler.go +++ b/go/apps/api/routes/v2_permissions_delete_role/handler.go @@ -122,7 +122,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, - Event: "role.delete", + Event: auditlog.RoleDeleteEvent, ActorType: auditlog.RootKeyActor, ActorID: auth.Key.ID, ActorName: "root key", @@ -132,7 +132,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - Type: "role", + Type: auditlog.RoleResourceType, ID: req.RoleId, Name: role.Name, DisplayName: role.Name, diff --git a/go/apps/api/routes/v2_permissions_list_permissions/handler.go b/go/apps/api/routes/v2_permissions_list_permissions/handler.go index 05e9968e5a..29a74cfe1b 100644 --- a/go/apps/api/routes/v2_permissions_list_permissions/handler.go +++ b/go/apps/api/routes/v2_permissions_list_permissions/handler.go @@ -20,7 +20,6 @@ type Response = openapi.V2PermissionsListPermissionsResponseBody // Handler implements zen.Route interface for the v2 permissions list permissions endpoint type Handler struct { - // Services as public fields Logger logging.Logger DB db.Database Keys keys.KeyService @@ -40,7 +39,6 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/permissions.listPermissions") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err @@ -52,10 +50,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // Handle null cursor - use empty string to start from beginning cursor := ptr.SafeDeref(req.Cursor, "") - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -67,7 +63,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Query permissions with pagination permissions, err := db.Query.ListPermissions( ctx, h.DB.RO(), diff --git a/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go b/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go index 8ce9a85383..e4b0edf06b 100644 --- a/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go +++ b/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go @@ -17,8 +17,7 @@ WHERE workspace_id = ? AND (id = ? OR slug = ?) type FindPermissionByIdOrSlugParams struct { WorkspaceID string `db:"workspace_id"` - ID string `db:"id"` - Slug string `db:"slug"` + Search string `db:"search"` } // FindPermissionByIdOrSlug @@ -27,7 +26,7 @@ type FindPermissionByIdOrSlugParams struct { // FROM permissions // WHERE workspace_id = ? AND (id = ? OR slug = ?) func (q *Queries) FindPermissionByIdOrSlug(ctx context.Context, db DBTX, arg FindPermissionByIdOrSlugParams) (Permission, error) { - row := db.QueryRowContext(ctx, findPermissionByIdOrSlug, arg.WorkspaceID, arg.ID, arg.Slug) + row := db.QueryRowContext(ctx, findPermissionByIdOrSlug, arg.WorkspaceID, arg.Search, arg.Search) var i Permission err := row.Scan( &i.ID, diff --git a/go/pkg/db/queries/permission_find_by_id_or_slug.sql b/go/pkg/db/queries/permission_find_by_id_or_slug.sql index 134ad4ebbf..6cc3941d24 100644 --- a/go/pkg/db/queries/permission_find_by_id_or_slug.sql +++ b/go/pkg/db/queries/permission_find_by_id_or_slug.sql @@ -1,4 +1,4 @@ -- name: FindPermissionByIdOrSlug :one SELECT * FROM permissions -WHERE workspace_id = ? AND (id = ? OR slug = ?); +WHERE workspace_id = ? AND (id = sqlc.arg('search') OR slug = sqlc.arg('search')); diff --git a/go/pkg/zen/middleware_tracing.go b/go/pkg/zen/middleware_tracing.go index 09d8fbb5c5..6af2e81da3 100644 --- a/go/pkg/zen/middleware_tracing.go +++ b/go/pkg/zen/middleware_tracing.go @@ -4,6 +4,7 @@ import ( "context" "github.com/unkeyed/unkey/go/pkg/otel/tracing" + "go.opentelemetry.io/otel/attribute" ) // WithTracing returns middleware that adds OpenTelemetry tracing to each request. @@ -21,12 +22,14 @@ func WithTracing() Middleware { return func(next HandleFunc) HandleFunc { return func(ctx context.Context, s *Session) error { ctx, span := tracing.Start(ctx, s.r.Pattern) + span.SetAttributes(attribute.String("request_id", s.RequestID())) defer span.End() err := next(ctx, s) if err != nil { tracing.RecordError(span, err) } + return err } } From 5f4a4ecd2682f319bcc9abc3f0d495539683c7d5 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Wed, 23 Jul 2025 17:26:20 +0200 Subject: [PATCH 04/20] more changes --- go/apps/api/openapi/openapi-generated.yaml | 53 +--- .../paths/v2/keys/addPermissions/index.yaml | 12 - .../v2/keys/removePermissions/index.yaml | 37 +-- .../paths/v2/keys/setPermissions/index.yaml | 12 - .../routes/v2_keys_add_permissions/handler.go | 262 ++++++++++-------- .../v2_keys_remove_permissions/400_test.go | 25 +- .../v2_keys_remove_permissions/handler.go | 204 ++++++-------- .../routes/v2_keys_set_permissions/handler.go | 75 ++++- .../api/routes/v2_keys_update_key/handler.go | 35 ++- .../400_test.go | 20 -- .../v2_permissions_get_permission/200_test.go | 58 ++-- .../v2_permissions_get_permission/400_test.go | 31 +-- .../v2_permissions_get_permission/401_test.go | 2 +- .../v2_permissions_get_permission/403_test.go | 4 +- .../v2_permissions_get_permission/404_test.go | 4 +- .../v2_permissions_get_permission/handler.go | 19 +- go/pkg/db/bulk_key_permission_insert.sql.go | 3 +- .../db/key_permission_insert.sql_generated.go | 15 +- go/pkg/db/querier_generated.go | 2 +- go/pkg/db/queries/key_permission_insert.sql | 2 +- 20 files changed, 402 insertions(+), 473 deletions(-) diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 6f9cbaad92..aa5d4fd13a 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-23T09:49:03Z +# Generated at: 2025-07-23T15:25:07Z # Source: openapi-split.yaml components: @@ -4122,16 +4122,6 @@ paths: content: application/json: examples: - cachingError: - summary: Cache invalidation error - value: - error: - detail: The permissions were successfully added but there was an error invalidating cached keys. Some systems may temporarily see stale data. - status: 500 - title: Internal Server Error - type: internal_server_error - meta: - requestId: req_5zAbCdEfGhIjKlMnOpQrSt databaseError: summary: Database error value: @@ -4516,6 +4506,7 @@ paths: - Selective removal - revoke specific permissions while leaving others intact - Direct permissions only - doesn't affect permissions granted through roles - Idempotent operation - removing permissions multiple times has no additional effect + Use this endpoint when downgrading user access privileges, removing temporary elevated permissions, implementing granular permission adjustments, or revoking access to specific resources. operationId: removePermissions requestBody: @@ -4523,19 +4514,13 @@ paths: application/json: examples: basic: - description: Using permission IDs is the most precise way to remove permissions, guaranteeing you're removing exactly what you intend regardless of name changes. - summary: Remove permissions using IDs + description: You can use either the unkey permission ID or your choosen slug to remove permissions. + summary: Remove permissions using ID or slug value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: - perm_2zF4mNyP9BsRj2aQwDxVkT - documents.delete - removeAll: - description: Setting an empty permissions array removes all direct permissions from the key. This doesn't remove permissions granted through roles. The key remains valid but will have no direct permissions. - summary: Remove all permissions from key - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: [] schema: $ref: '#/components/schemas/V2KeysRemovePermissionsRequestBody' required: true @@ -4578,16 +4563,6 @@ paths: type: bad_request meta: requestId: req_5zAbCdEfGhIjKlMnOpQrSt - missingIdentifier: - summary: Permission missing both id and name - value: - error: - detail: Each permission must include either id or name - status: 400 - title: Bad Request - type: bad_request - meta: - requestId: req_7bCdEfGhIjKlMnOpQrStUv schema: $ref: '#/components/schemas/BadRequestErrorResponse' description: Bad Request - Invalid keyId format, missing required fields, or malformed permission entries @@ -4646,16 +4621,6 @@ paths: content: application/json: examples: - cachingError: - summary: Cache invalidation error - value: - error: - detail: The permissions were successfully removed but there was an error invalidating cached keys. Some systems may temporarily see stale data. - status: 500 - title: Internal Server Error - type: internal_server_error - meta: - requestId: req_5zAbCdEfGhIjKlMnOpQrSt databaseError: summary: Database error value: @@ -4999,16 +4964,6 @@ paths: content: application/json: examples: - cachingError: - summary: Cache invalidation error - value: - error: - detail: The permissions were successfully set but there was an error invalidating cached keys. Some systems may temporarily see stale data. - status: 500 - title: Internal Server Error - type: internal_server_error - meta: - requestId: req_5zAbCdEfGhIjKlMnOpQrSt databaseError: summary: Database error value: diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml index 515b0a9b11..56bae0505e 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/index.yaml @@ -226,15 +226,3 @@ post: Please try again later. status: 500 type: internal_server_error - cachingError: - summary: Cache invalidation error - value: - meta: - requestId: req_5zAbCdEfGhIjKlMnOpQrSt - error: - title: Internal Server Error - detail: The permissions were successfully added but there was - an error invalidating cached keys. Some systems may temporarily - see stale data. - status: 500 - type: internal_server_error diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml index 750f245543..b312285926 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/index.yaml @@ -11,6 +11,7 @@ post: - Selective removal - revoke specific permissions while leaving others intact - Direct permissions only - doesn't affect permissions granted through roles - Idempotent operation - removing permissions multiple times has no additional effect + Use this endpoint when downgrading user access privileges, removing temporary elevated permissions, implementing granular permission adjustments, or revoking access to specific resources. operationId: removePermissions x-speakeasy-name-override: removePermissions @@ -23,19 +24,9 @@ post: schema: "$ref": "./V2KeysRemovePermissionsRequestBody.yaml" examples: - removeAll: - summary: Remove all permissions from key - description: Setting an empty permissions array removes all direct - permissions from the key. This doesn't remove permissions granted - through roles. The key remains valid but will have no direct permissions. - value: - keyId: key_2cGKbMxRyIzhCxo1Idjz8q - permissions: [] basic: - summary: Remove permissions using IDs - description: Using permission IDs is the most precise way to remove - permissions, guaranteeing you're removing exactly what you intend - regardless of name changes. + summary: Remove permissions using ID or slug + description: You can use either the unkey permission ID or your choosen slug to remove permissions. value: keyId: key_2cGKbMxRyIzhCxo1Idjz8q permissions: @@ -83,16 +74,6 @@ post: detail: At least one permission must be specified status: 400 type: bad_request - missingIdentifier: - summary: Permission missing both id and name - value: - meta: - requestId: req_7bCdEfGhIjKlMnOpQrStUv - error: - title: Bad Request - detail: Each permission must include either id or name - status: 400 - type: bad_request "401": description: Unauthorized - Missing or invalid authentication credentials content: @@ -163,15 +144,3 @@ post: Please try again later. status: 500 type: internal_server_error - cachingError: - summary: Cache invalidation error - value: - meta: - requestId: req_5zAbCdEfGhIjKlMnOpQrSt - error: - title: Internal Server Error - detail: The permissions were successfully removed but there - was an error invalidating cached keys. Some systems may temporarily - see stale data. - status: 500 - type: internal_server_error diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/index.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/index.yaml index f88c27c1aa..98c5729ce1 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/index.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/index.yaml @@ -257,15 +257,3 @@ post: Please try again later. status: 500 type: internal_server_error - cachingError: - summary: Cache invalidation error - value: - meta: - requestId: req_5zAbCdEfGhIjKlMnOpQrSt - error: - title: Internal Server Error - detail: The permissions were successfully set but there was - an error invalidating cached keys. Some systems may temporarily - see stale data. - status: 500 - type: internal_server_error diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index 69f3f894cd..608bf4f508 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -5,7 +5,7 @@ import ( "database/sql" "fmt" "net/http" - "slices" + "strings" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -18,6 +18,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" + "github.com/unkeyed/unkey/go/pkg/uid" "github.com/unkeyed/unkey/go/pkg/zen" ) @@ -89,14 +90,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve current permissions."), - ) - } - err = auth.Verify(ctx, keys.WithPermissions( rbac.And( rbac.Or( @@ -124,94 +117,132 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // Convert current permissions to a map for efficient lookup - currentPermissionIDs := make(map[string]bool) + currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to retrieve current permissions."), + ) + } + + currentPermissionIDs := make(map[string]db.Permission) for _, permission := range currentPermissions { - currentPermissionIDs[permission.ID] = true + currentPermissionIDs[permission.ID] = permission } - // 6. Resolve and validate requested permissions - requestedPermissions := make([]db.Permission, 0, len(req.Permissions)) - for _, permRef := range req.Permissions { - var permission db.Permission + foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Ids: req.Permissions, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to lookup permissions to add."), + ) + } - if permRef.Id != nil { - // Find by ID - permission, err = db.Query.FindPermissionByID(ctx, h.DB.RO(), *permRef.Id) - if err != nil { - if db.IsNotFound(err) { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission with ID '%s' was not found.", *permRef.Id)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - } else if permRef.Slug != nil { - // Find by name - permission, err = db.Query.FindPermissionBySlugAndWorkspaceID(ctx, h.DB.RO(), db.FindPermissionBySlugAndWorkspaceIDParams{ - Slug: *permRef.Slug, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if err != nil { - if db.IsNotFound(err) { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission with slug '%s' was not found.", *permRef.Slug)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - } else { - return fault.New("invalid permission reference", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("permission missing id and name"), fault.Public("Each permission must specify either 'id' or 'slug'."), - ) + missingPermissions := make(map[string]struct{}) + for _, permission := range req.Permissions { + missingPermissions[permission] = struct{}{} + } + + for _, permission := range foundPermissions { + delete(missingPermissions, permission.ID) + delete(missingPermissions, permission.Slug) + } + + permissionsToSet := make([]db.Permission, 0) + permissionsToInsert := make([]db.InsertPermissionParams, 0) + + for _, permission := range foundPermissions { + _, ok := currentPermissionIDs[permission.ID] + if ok { + continue } - // Validate permission belongs to the same workspace - if permission.WorkspaceID != auth.AuthorizedWorkspaceID { + permissionsToSet = append(permissionsToSet, permission) + } + + for perm := range missingPermissions { + if strings.HasPrefix(perm, "perm_") { return fault.New("permission not found", fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission belongs to different workspace"), fault.Public(fmt.Sprintf("Permission '%s' was not found.", permission.Name)), + fault.Public(fmt.Sprintf("Permission with ID %q was not found.", perm)), ) } - requestedPermissions = append(requestedPermissions, permission) + permissionID := uid.New(uid.PermissionPrefix) + now := time.Now().UnixMilli() + permissionsToInsert = append(permissionsToInsert, db.InsertPermissionParams{ + PermissionID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: sql.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) + + permissionsToSet = append(permissionsToSet, db.Permission{ + ID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: sql.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) } - // 7. Determine which permissions to add (only add permissions that aren't already assigned) - permissionsToAdd := make([]db.Permission, 0) - for _, permission := range requestedPermissions { - if !currentPermissionIDs[permission.ID] { - permissionsToAdd = append(permissionsToAdd, permission) + err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { + var auditLogs []auditlog.AuditLog + + if len(permissionsToInsert) > 0 { + for _, toCreate := range permissionsToInsert { + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.PermissionCreateEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Created %s (%s)", toCreate.Slug, toCreate.PermissionID), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.PermissionResourceType, + ID: toCreate.PermissionID, + Name: toCreate.Slug, + DisplayName: toCreate.Name, + Meta: map[string]interface{}{ + "name": toCreate.Name, + "slug": toCreate.Slug, + }, + }, + }, + }) + } + + err = db.BulkQuery.InsertPermissions(ctx, tx, permissionsToInsert) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to insert permissions."), + ) + } } - } - // 8. Apply changes in transaction (only if there are permissions to add) - if len(permissionsToAdd) > 0 { - err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - var auditLogs []auditlog.AuditLog + if len(permissionsToSet) > 0 { + toAdd := make([]db.InsertKeyPermissionParams, len(permissionsToSet)) + now := time.Now().UnixMilli() - // Add new permissions - for _, permission := range permissionsToAdd { - err = db.Query.InsertKeyPermission(ctx, tx, db.InsertKeyPermissionParams{ + for idx, permission := range permissionsToSet { + toAdd[idx] = db.InsertKeyPermissionParams{ KeyID: req.KeyId, PermissionID: permission.ID, WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAt: time.Now().UnixMilli(), - }) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to add permission assignment."), - ) + CreatedAt: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, } auditLogs = append(auditLogs, auditlog.AuditLog{ @@ -243,57 +274,60 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - // Insert audit logs - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = db.BulkQuery.InsertKeyPermissions(ctx, tx, toAdd) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to add key permissions."), + ) } + } - return nil - }) + err = h.Auditlogs.Insert(ctx, tx, auditLogs) if err != nil { return err } - h.KeyCache.Remove(ctx, key.Hash) + return nil + }) + if err != nil { + return err } - // 9. Get final state of direct permissions and build response - finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RW(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final permission state."), - ) + h.KeyCache.Remove(ctx, key.Hash) + + responseData := make(openapi.V2KeysAddPermissionsResponseData, 0) + + for _, permission := range currentPermissions { + perm := openapi.Permission{ + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + Description: nil, + } + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + responseData = append(responseData, perm) } - // Sort permissions alphabetically by name for consistent response - slices.SortFunc(finalPermissions, func(a, b db.Permission) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 + for _, permission := range permissionsToSet { + perm := openapi.Permission{ + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + Description: nil, } - return 0 - }) - // Build response data - responseData := make(openapi.V2KeysAddPermissionsResponseData, len(finalPermissions)) - for i, permission := range finalPermissions { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - }{ - Id: permission.ID, - Name: permission.Name, - Slug: permission.Slug, + if permission.Description.Valid { + perm.Description = &permission.Description.String } + + responseData = append(responseData, perm) } - // 10. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go index 829d53bd48..efeec38d2f 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go @@ -157,32 +157,9 @@ func TestValidationErrors(t *testing.T) { }) t.Run("permission not found by id", func(t *testing.T) { - nonExistentPermissionID := uid.New(uid.TestPrefix) - - req := handler.Request{ - KeyId: validKeyID, - Permissions: []string{nonExistentPermissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - - t.Run("permission not found by name", func(t *testing.T) { - nonExistentPermissionName := "nonexistent.permission.remove" - req := handler.Request{ KeyId: validKeyID, - Permissions: []string{nonExistentPermissionName}, + Permissions: []string{uid.New(uid.TestPrefix)}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_remove_permissions/handler.go b/go/apps/api/routes/v2_keys_remove_permissions/handler.go index 7f6322695f..df047338eb 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/handler.go @@ -2,9 +2,9 @@ package handler import ( "context" + "database/sql" "fmt" "net/http" - "slices" "github.com/unkeyed/unkey/go/apps/api/openapi" "github.com/unkeyed/unkey/go/internal/services/auditlogs" @@ -58,20 +58,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 3. Permission check - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( - rbac.T(rbac.Tuple{ - ResourceType: rbac.Api, - ResourceID: "*", - Action: rbac.UpdateKey, - }), - ))) - if err != nil { - return err - } - - // 4. Validate key exists and belongs to workspace - key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), req.KeyId) + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) if err != nil { if db.IsNotFound(err) { return fault.New("key not found", @@ -89,11 +82,38 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if key.WorkspaceID != auth.AuthorizedWorkspaceID { return fault.New("key not found", fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to different workspace"), fault.Public("The specified key was not found."), + fault.Internal("key belongs to different workspace"), + fault.Public("The specified key was not found."), ) } - // 5. Get current direct permissions for the key + err = auth.Verify(ctx, keys.WithPermissions( + rbac.And( + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.UpdateKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.UpdateKey, + }), + ), + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.RemovePermissionFromKey, + }), + ), + ), + )) + if err != nil { + return err + } + currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -108,88 +128,37 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { currentPermissionIDs[permission.ID] = permission } - // 6. Resolve and validate requested permissions to remove - requestedPermissions := make([]db.Permission, 0, len(req.Permissions)) - for _, permRef := range req.Permissions { - var permission db.Permission - - if permRef.Id != nil { - // Find by ID - permission, err = db.Query.FindPermissionByID(ctx, h.DB.RO(), *permRef.Id) - if err != nil { - if db.IsNotFound(err) { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission with ID '%s' was not found.", *permRef.Id)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - } else if permRef.Slug != nil { - // Find by slug - permission, err = db.Query.FindPermissionBySlugAndWorkspaceID(ctx, h.DB.RO(), db.FindPermissionBySlugAndWorkspaceIDParams{ - Slug: *permRef.Slug, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if err != nil { - if db.IsNotFound(err) { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission not found"), fault.Public(fmt.Sprintf("Permission with slug '%s' was not found.", *permRef.Slug)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve permission."), - ) - } - } else { - return fault.New("invalid permission reference", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("permission missing id and name"), fault.Public("Each permission must specify either 'id' or 'name'."), - ) - } - - // Validate permission belongs to the same workspace - if permission.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Internal("permission belongs to different workspace"), fault.Public(fmt.Sprintf("Permission '%s' was not found.", permission.Name)), - ) - } - - requestedPermissions = append(requestedPermissions, permission) + foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Ids: req.Permissions, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to lookup permissions to add."), + ) } - // 7. Determine which permissions to remove (only remove permissions that are currently assigned) permissionsToRemove := make([]db.Permission, 0) - for _, permission := range requestedPermissions { - if _, exists := currentPermissionIDs[permission.ID]; exists { - permissionsToRemove = append(permissionsToRemove, permission) + for _, permission := range foundPermissions { + _, exists := currentPermissionIDs[permission.ID] + if !exists { + continue } + + delete(currentPermissionIDs, permission.ID) + permissionsToRemove = append(permissionsToRemove, permission) } - // 8. Apply changes in transaction (only if there are permissions to remove) if len(permissionsToRemove) > 0 { err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog + idsToRemove := make([]string, 0) + // Remove permissions for _, permission := range permissionsToRemove { - err = db.Query.DeleteKeyPermissionByKeyAndPermissionID(ctx, tx, db.DeleteKeyPermissionByKeyAndPermissionIDParams{ - KeyID: req.KeyId, - PermissionID: permission.ID, - }) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to remove permission assignment."), - ) - } - + idsToRemove = append(idsToRemove, permission.ID) auditLogs = append(auditLogs, auditlog.AuditLog{ WorkspaceID: auth.AuthorizedWorkspaceID, Event: auditlog.AuthDisconnectPermissionKeyEvent, @@ -219,12 +188,21 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - // Insert audit logs - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = db.Query.DeleteManyKeyPermissionByKeyAndPermissionIDs(ctx, tx, db.DeleteManyKeyPermissionByKeyAndPermissionIDsParams{ + KeyID: req.KeyId, + Ids: idsToRemove, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to remove permission assignment."), + ) + } + + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err } return nil @@ -236,40 +214,22 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) } - // 9. Get final state of direct permissions and build response - finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RW(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final permission state."), - ) - } - - // Sort permissions alphabetically by name for consistent response - slices.SortFunc(finalPermissions, func(a, b db.Permission) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 + responseData := make(openapi.V2KeysRemovePermissionsResponseData, 0) + for _, permission := range currentPermissionIDs { + perm := openapi.Permission{ + Id: permission.ID, + Slug: permission.Slug, + Name: permission.Name, + Description: nil, } - return 0 - }) - // Build response data - responseData := make(openapi.V2KeysRemovePermissionsResponseData, len(finalPermissions)) - for i, permission := range finalPermissions { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - }{ - Id: permission.ID, - Name: permission.Name, - Slug: permission.Slug, + if permission.Description.Valid { + perm.Description = &permission.Description.String } + + responseData = append(responseData, perm) } - // 10. Return success response with remaining permissions return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index e495ea4d2f..9d180d388d 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -134,6 +134,12 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Ids: req.Permissions, }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to lookup permissions to set."), + ) + } missingPermissions := make(map[string]struct{}) for _, permission := range req.Permissions { @@ -226,7 +232,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { for _, permissionID := range permissionsToRemove { perm := requestedPermissionMap[permissionID] - auditLogs = append(auditLogs, auditlog.AuditLog{ WorkspaceID: auth.AuthorizedWorkspaceID, Event: auditlog.AuthDisconnectPermissionKeyEvent, @@ -257,14 +262,54 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } + if len(permissionsToInsert) > 0 { + for _, toCreate := range permissionsToInsert { + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.PermissionCreateEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Created %s (%s)", toCreate.Slug, toCreate.PermissionID), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.PermissionResourceType, + ID: toCreate.PermissionID, + Name: toCreate.Slug, + DisplayName: toCreate.Name, + Meta: map[string]interface{}{ + "name": toCreate.Name, + "slug": toCreate.Slug, + }, + }, + }, + }) + } + + err = db.BulkQuery.InsertPermissions(ctx, tx, permissionsToInsert) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to insert permissions."), + ) + } + } + if len(permissionsToAdd) > 0 { toAdd := make([]db.InsertKeyPermissionParams, len(permissionsToAdd)) + now := time.Now().UnixMilli() + for idx, permission := range permissionsToAdd { toAdd[idx] = db.InsertKeyPermissionParams{ KeyID: req.KeyId, PermissionID: permission.ID, WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAt: time.Now().UnixMilli(), + CreatedAt: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, } auditLogs = append(auditLogs, auditlog.AuditLog{ @@ -296,7 +341,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - err = db.BulkQuery.InsertKeyPermissions(ctx, h.DB.RW(), toAdd) + err = db.BulkQuery.InsertKeyPermissions(ctx, tx, toAdd) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -306,12 +351,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - // Insert audit logs - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err } return nil @@ -338,6 +380,21 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { responseData = append(responseData, perm) } + for _, permission := range permissionsToAdd { + perm := openapi.Permission{ + Description: nil, + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + } + + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + responseData = append(responseData, perm) + } + return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index 4f8c83256f..e11754d254 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -112,9 +112,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - auditLogs := []auditlog.AuditLog{} err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - // Prepare update parameters with three-state handling + auditLogs := []auditlog.AuditLog{} + update := db.UpdateKeyParams{ ID: key.ID, Now: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, @@ -419,6 +419,32 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } if len(permissionsToCreate) > 0 { + for _, toCreate := range permissionsToCreate { + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.PermissionCreateEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Created %s (%s)", toCreate.Slug, toCreate.PermissionID), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.PermissionResourceType, + ID: toCreate.PermissionID, + Name: toCreate.Slug, + DisplayName: toCreate.Name, + Meta: map[string]interface{}{ + "name": toCreate.Name, + "slug": toCreate.Slug, + }, + }, + }, + }) + } + err = db.BulkQuery.InsertPermissions(ctx, tx, permissionsToCreate) if err != nil { return fault.Wrap(err, @@ -429,7 +455,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - // Clear all existing permissions for this key err = db.Query.DeleteAllKeyPermissionsByKeyID(ctx, tx, key.ID) if err != nil { return fault.Wrap(err, @@ -439,12 +464,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } permissionsToInsert := []db.InsertKeyPermissionParams{} + now := time.Now().UnixMilli() for _, reqPerm := range requestedPermissions { permissionsToInsert = append(permissionsToInsert, db.InsertKeyPermissionParams{ KeyID: key.ID, PermissionID: reqPerm.ID, WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAt: time.Now().UnixMilli(), + CreatedAt: now, + UpdatedAt: sql.NullInt64{Int64: now, Valid: true}, }) } diff --git a/go/apps/api/routes/v2_permissions_delete_permission/400_test.go b/go/apps/api/routes/v2_permissions_delete_permission/400_test.go index 1518eea308..fb932958ff 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/400_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/400_test.go @@ -91,24 +91,4 @@ func TestValidationErrors(t *testing.T) { require.NotNil(t, res.Body.Error) require.Contains(t, res.Body.Error.Detail, "validate schema") }) - - // Test case for invalid permissionId format - t.Run("invalid permissionId format", func(t *testing.T) { - req := handler.Request{ - Permission: "not_a_valid_permission_id_format", - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "does not exist") - // Note: The handler does database lookup first, so invalid formats return 404, not 400 - }) } diff --git a/go/apps/api/routes/v2_permissions_get_permission/200_test.go b/go/apps/api/routes/v2_permissions_get_permission/200_test.go index 3c0166f912..88e9554767 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/200_test.go @@ -39,26 +39,28 @@ func TestSuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - // Test case for getting a permission - t.Run("get permission with all fields", func(t *testing.T) { - // First, create a permission to retrieve - permissionID := uid.New(uid.PermissionPrefix) - permissionName := "test.get.permission" - permissionDesc := "Test permission for get endpoint" + // First, create a permission to retrieve + permissionID := uid.New(uid.PermissionPrefix) + permissionName := "test.get.permission" + permissionDesc := "Test permission for get endpoint" + permissionSlug := "test-get-permission" + + err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ + PermissionID: permissionID, + WorkspaceID: workspace.ID, + Name: permissionName, + Slug: permissionSlug, + Description: sql.NullString{Valid: true, String: permissionDesc}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) - err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: permissionID, - WorkspaceID: workspace.ID, - Name: permissionName, - Slug: "test-get-permission", - Description: sql.NullString{Valid: true, String: permissionDesc}, - CreatedAtM: time.Now().UnixMilli(), - }) - require.NoError(t, err) + // Test case for getting a permission + t.Run("get permission with all fields by ID", func(t *testing.T) { // Now retrieve the permission req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -81,6 +83,28 @@ func TestSuccess(t *testing.T) { require.Equal(t, permissionDesc, *permission.Description) }) + t.Run("get permission with all fields by slug", func(t *testing.T) { + req := handler.Request{Permission: permissionSlug} + res := testutil.CallRoute[handler.Request, handler.Response]( + h, + route, + headers, + req, + ) + + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Data) + require.NotNil(t, res.Body.Data.Permission) + + // Verify permission data + permission := res.Body.Data.Permission + require.Equal(t, permissionID, permission.Id) + require.Equal(t, permissionName, permission.Name) + require.NotNil(t, permission.Description) + require.Equal(t, permissionDesc, *permission.Description) + }) + // Test case for getting a permission without description t.Run("get permission without description", func(t *testing.T) { // First, create a permission to retrieve, without a description @@ -99,7 +123,7 @@ func TestSuccess(t *testing.T) { // Now retrieve the permission req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_permissions_get_permission/400_test.go b/go/apps/api/routes/v2_permissions_get_permission/400_test.go index 9f800872c2..035d80e6e8 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/400_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/400_test.go @@ -34,11 +34,8 @@ func TestValidationErrors(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - // Test case for missing required permissionId - t.Run("missing permissionId", func(t *testing.T) { - req := handler.Request{ - // PermissionId is missing - } + t.Run("missing permission id / slug", func(t *testing.T) { + req := handler.Request{} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( h, @@ -53,11 +50,8 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - // Test case for empty permissionId - t.Run("empty permissionId", func(t *testing.T) { - req := handler.Request{ - PermissionId: "", // Empty string is invalid - } + t.Run("empty permission id / slug", func(t *testing.T) { + req := handler.Request{Permission: ""} res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( h, @@ -91,21 +85,4 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - // Test case for invalid permissionId format - t.Run("invalid permissionId format", func(t *testing.T) { - req := handler.Request{ - PermissionId: "not_a.valid_permission_id_format", - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - }) } diff --git a/go/apps/api/routes/v2_permissions_get_permission/401_test.go b/go/apps/api/routes/v2_permissions_get_permission/401_test.go index ebdc113343..53c0328a21 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/401_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/401_test.go @@ -23,7 +23,7 @@ func TestAuthenticationErrors(t *testing.T) { // Create a valid request req := handler.Request{ - PermissionId: "perm_test123", + Permission: "perm_test123", } // Test case for missing authorization header diff --git a/go/apps/api/routes/v2_permissions_get_permission/403_test.go b/go/apps/api/routes/v2_permissions_get_permission/403_test.go index 5d5c0fd4a8..b79a999550 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/403_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/403_test.go @@ -56,7 +56,7 @@ func TestPermissionErrors(t *testing.T) { } req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( @@ -83,7 +83,7 @@ func TestPermissionErrors(t *testing.T) { } req := handler.Request{ - PermissionId: permissionID, + Permission: permissionID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_get_permission/404_test.go b/go/apps/api/routes/v2_permissions_get_permission/404_test.go index 9612ed6fb0..d8c45ab854 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/404_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/404_test.go @@ -38,7 +38,7 @@ func TestNotFoundErrors(t *testing.T) { // Test case for non-existent permission ID t.Run("non-existent permission ID", func(t *testing.T) { req := handler.Request{ - PermissionId: "perm_does_not_exist", + Permission: "perm_does_not_exist", } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -59,7 +59,7 @@ func TestNotFoundErrors(t *testing.T) { nonExistentID := uid.New(uid.PermissionPrefix) // Generate a valid ID format that doesn't exist req := handler.Request{ - PermissionId: nonExistentID, + Permission: nonExistentID, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_get_permission/handler.go b/go/apps/api/routes/v2_permissions_get_permission/handler.go index ef89a7ee50..774a3610fd 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_get_permission/handler.go @@ -39,19 +39,16 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/permissions.getPermission") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -63,8 +60,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Get permission by ID - permission, err := db.Query.FindPermissionByID(ctx, h.DB.RO(), req.PermissionId) + permission, err := db.Query.FindPermissionByIdOrSlug(ctx, h.DB.RO(), db.FindPermissionByIdOrSlugParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Permission, + }) if err != nil { if db.IsNotFound(err) { return fault.New("permission not found", @@ -78,15 +77,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Check if permission belongs to authorized workspace - if permission.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("permission does not belong to authorized workspace", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Public("The requested permission does not exist."), - ) - } - - // 6. Return success response permissionResponse := openapi.Permission{ Id: permission.ID, Name: permission.Name, @@ -94,7 +84,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Description: nil, } - // Add description only if it's valid if permission.Description.Valid { permissionResponse.Description = &permission.Description.String } diff --git a/go/pkg/db/bulk_key_permission_insert.sql.go b/go/pkg/db/bulk_key_permission_insert.sql.go index f796f78dc7..9808eb94e6 100644 --- a/go/pkg/db/bulk_key_permission_insert.sql.go +++ b/go/pkg/db/bulk_key_permission_insert.sql.go @@ -9,7 +9,7 @@ import ( ) // bulkInsertKeyPermission is the base query for bulk insert -const bulkInsertKeyPermission = `INSERT INTO ` + "`" + `keys_permissions` + "`" + ` ( key_id, permission_id, workspace_id, created_at_m ) VALUES %s` +const bulkInsertKeyPermission = `INSERT INTO ` + "`" + `keys_permissions` + "`" + ` ( key_id, permission_id, workspace_id, created_at_m ) VALUES %s ON DUPLICATE KEY UPDATE updated_at_m = ?` // InsertKeyPermissions performs bulk insert in a single query func (q *BulkQueries) InsertKeyPermissions(ctx context.Context, db DBTX, args []InsertKeyPermissionParams) error { @@ -33,6 +33,7 @@ func (q *BulkQueries) InsertKeyPermissions(ctx context.Context, db DBTX, args [] allArgs = append(allArgs, arg.PermissionID) allArgs = append(allArgs, arg.WorkspaceID) allArgs = append(allArgs, arg.CreatedAt) + allArgs = append(allArgs, arg.UpdatedAt) } // Execute the bulk insert diff --git a/go/pkg/db/key_permission_insert.sql_generated.go b/go/pkg/db/key_permission_insert.sql_generated.go index 4547e7cb10..bafe79643b 100644 --- a/go/pkg/db/key_permission_insert.sql_generated.go +++ b/go/pkg/db/key_permission_insert.sql_generated.go @@ -7,6 +7,7 @@ package db import ( "context" + "database/sql" ) const insertKeyPermission = `-- name: InsertKeyPermission :exec @@ -20,14 +21,15 @@ INSERT INTO ` + "`" + `keys_permissions` + "`" + ` ( ?, ?, ? -) +) ON DUPLICATE KEY UPDATE updated_at_m = ? ` type InsertKeyPermissionParams struct { - KeyID string `db:"key_id"` - PermissionID string `db:"permission_id"` - WorkspaceID string `db:"workspace_id"` - CreatedAt int64 `db:"created_at"` + KeyID string `db:"key_id"` + PermissionID string `db:"permission_id"` + WorkspaceID string `db:"workspace_id"` + CreatedAt int64 `db:"created_at"` + UpdatedAt sql.NullInt64 `db:"updated_at"` } // InsertKeyPermission @@ -42,13 +44,14 @@ type InsertKeyPermissionParams struct { // ?, // ?, // ? -// ) +// ) ON DUPLICATE KEY UPDATE updated_at_m = ? func (q *Queries) InsertKeyPermission(ctx context.Context, db DBTX, arg InsertKeyPermissionParams) error { _, err := db.ExecContext(ctx, insertKeyPermission, arg.KeyID, arg.PermissionID, arg.WorkspaceID, arg.CreatedAt, + arg.UpdatedAt, ) return err } diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 6985c3d3c3..ba41d65130 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -743,7 +743,7 @@ type Querier interface { // ?, // ?, // ? - // ) + // ) ON DUPLICATE KEY UPDATE updated_at_m = ? InsertKeyPermission(ctx context.Context, db DBTX, arg InsertKeyPermissionParams) error //InsertKeyRatelimit // diff --git a/go/pkg/db/queries/key_permission_insert.sql b/go/pkg/db/queries/key_permission_insert.sql index aa560663c5..2f8bffebdc 100644 --- a/go/pkg/db/queries/key_permission_insert.sql +++ b/go/pkg/db/queries/key_permission_insert.sql @@ -9,4 +9,4 @@ INSERT INTO `keys_permissions` ( sqlc.arg(permission_id), sqlc.arg(workspace_id), sqlc.arg(created_at) -); +) ON DUPLICATE KEY UPDATE updated_at_m = sqlc.arg(updated_at); From a1753541b2837268b760e2be9642d29e552e566a Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:06:59 +0200 Subject: [PATCH 05/20] more changes --- go/apps/api/openapi/openapi-generated.yaml | 2 +- .../v2_keys_add_permissions/200_test.go | 2 +- .../v2_keys_add_permissions/400_test.go | 4 +- .../v2_keys_add_permissions/401_test.go | 49 ++--- .../v2_keys_add_permissions/403_test.go | 171 ++++++------------ .../v2_keys_add_permissions/404_test.go | 95 +--------- .../routes/v2_keys_add_permissions/handler.go | 8 - .../v2_keys_remove_permissions/200_test.go | 2 +- .../v2_keys_remove_permissions/400_test.go | 19 -- .../v2_keys_remove_permissions/403_test.go | 48 ----- .../v2_keys_remove_permissions/404_test.go | 111 +----------- .../v2_keys_remove_permissions/handler.go | 27 ++- .../v2_keys_set_permissions/200_test.go | 2 +- .../v2_keys_set_permissions/404_test.go | 89 +-------- .../routes/v2_keys_set_permissions/handler.go | 9 +- .../routes/v2_keys_update_credits/handler.go | 9 - .../api/routes/v2_keys_update_key/handler.go | 9 +- go/pkg/db/bulk_key_permission_insert.sql.go | 6 +- .../db/bulk_ratelimit_override_insert.sql.go | 6 +- .../plugins/bulk-insert/bulk_insert.go.tmpl | 11 +- go/pkg/db/plugins/bulk-insert/generator.go | 21 ++- go/pkg/db/plugins/bulk-insert/parser.go | 26 ++- go/pkg/db/plugins/bulk-insert/template.go | 2 + 23 files changed, 163 insertions(+), 565 deletions(-) diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index aa5d4fd13a..f54860eab6 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-23T15:25:07Z +# Generated at: 2025-07-24T13:06:34Z # Source: openapi-split.yaml components: diff --git a/go/apps/api/routes/v2_keys_add_permissions/200_test.go b/go/apps/api/routes/v2_keys_add_permissions/200_test.go index 9f896f296d..3f1ede261c 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/200_test.go @@ -29,7 +29,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_permission_to_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_add_permissions/400_test.go b/go/apps/api/routes/v2_keys_add_permissions/400_test.go index 06b3a171d4..c432728ffb 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/400_test.go @@ -33,7 +33,7 @@ func TestValidationErrors(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_permission_to_key") // Set up request headers headers := http.Header{ @@ -198,7 +198,7 @@ func TestValidationErrors(t *testing.T) { require.Equal(t, 400, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "must specify either 'id' or 'slug'") + require.Contains(t, res.Body.Error.Detail, "failed to validate schema") }) t.Run("permission not found by id", func(t *testing.T) { diff --git a/go/apps/api/routes/v2_keys_add_permissions/401_test.go b/go/apps/api/routes/v2_keys_add_permissions/401_test.go index b1481e90e9..8621dd304b 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/401_test.go @@ -6,14 +6,13 @@ 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_keys_add_permissions" "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" ) @@ -34,39 +33,27 @@ func TestAuthenticationErrors(t *testing.T) { // Create a workspace workspace := h.Resources().UserWorkspace - // Create test data - keyAuthID := uid.New(uid.KeyAuthPrefix) - err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ - ID: keyAuthID, - WorkspaceID: workspace.ID, - StoreEncryptedKeys: false, - DefaultPrefix: sql.NullString{Valid: true, String: "test"}, - DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, - CreatedAtM: time.Now().UnixMilli(), + api := h.CreateApi(seed.CreateApiRequest{ + WorkspaceID: workspace.ID, + IpWhitelist: "", + EncryptedKeys: false, + Name: nil, + CreatedAt: nil, + DefaultPrefix: nil, + DefaultBytes: nil, }) - require.NoError(t, err) - keyID := uid.New(uid.KeyPrefix) - keyString := "test_" + uid.New("") - err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ - ID: keyID, - KeyringID: keyAuthID, - Hash: hash.Sha256(keyString), - Start: keyString[:4], - WorkspaceID: workspace.ID, - ForWorkspaceID: sql.NullString{Valid: false}, - Name: sql.NullString{Valid: true, String: "Test Key"}, - CreatedAtM: time.Now().UnixMilli(), - Enabled: true, - IdentityID: sql.NullString{Valid: false}, - Meta: sql.NullString{Valid: false}, - Expires: sql.NullTime{Valid: false}, - RemainingRequests: sql.NullInt32{Valid: false}, + key := h.CreateKey(seed.CreateKeyRequest{ + Disabled: false, + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Remaining: nil, + IdentityID: nil, + Meta: nil, }) - require.NoError(t, err) permissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ + err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permissionID, WorkspaceID: workspace.ID, Name: "documents.read.auth", @@ -76,7 +63,7 @@ func TestAuthenticationErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, + KeyId: key.KeyID, Permissions: []string{permissionID}, } diff --git a/go/apps/api/routes/v2_keys_add_permissions/403_test.go b/go/apps/api/routes/v2_keys_add_permissions/403_test.go index a3f5393fe3..226c58c59e 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/403_test.go @@ -6,14 +6,13 @@ 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_keys_add_permissions" "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" ) @@ -34,39 +33,35 @@ func TestAuthorizationErrors(t *testing.T) { // Create a workspace workspace := h.Resources().UserWorkspace - // Create test data - keyAuthID := uid.New(uid.KeyAuthPrefix) - err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ - ID: keyAuthID, - WorkspaceID: workspace.ID, - StoreEncryptedKeys: false, - DefaultPrefix: sql.NullString{Valid: true, String: "test"}, - DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, - CreatedAtM: time.Now().UnixMilli(), + api := h.CreateApi(seed.CreateApiRequest{ + WorkspaceID: workspace.ID, + IpWhitelist: "", + EncryptedKeys: false, + Name: nil, + CreatedAt: nil, + DefaultPrefix: nil, + DefaultBytes: nil, }) - require.NoError(t, err) - keyID := uid.New(uid.KeyPrefix) - keyString := "test_" + uid.New("") - err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ - ID: keyID, - KeyringID: keyAuthID, - Hash: hash.Sha256(keyString), - Start: keyString[:4], - WorkspaceID: workspace.ID, - ForWorkspaceID: sql.NullString{Valid: false}, - Name: sql.NullString{Valid: true, String: "Test Key"}, - CreatedAtM: time.Now().UnixMilli(), - Enabled: true, - IdentityID: sql.NullString{Valid: false}, - Meta: sql.NullString{Valid: false}, - Expires: sql.NullTime{Valid: false}, - RemainingRequests: sql.NullInt32{Valid: false}, + key := h.CreateKey(seed.CreateKeyRequest{ + Disabled: false, + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Remaining: nil, + IdentityID: nil, + Meta: nil, + Expires: nil, + Name: nil, + Deleted: false, + RefillAmount: nil, + RefillDay: nil, + Permissions: nil, + Roles: nil, + Ratelimits: nil, }) - require.NoError(t, err) permissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ + err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permissionID, WorkspaceID: workspace.ID, Name: "documents.read.auth403", @@ -76,7 +71,7 @@ func TestAuthorizationErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, + KeyId: key.KeyID, Permissions: []string{permissionID}, } @@ -123,53 +118,40 @@ func TestAuthorizationErrors(t *testing.T) { }) t.Run("key belongs to different workspace", func(t *testing.T) { - // Create another workspace - otherWorkspaceID := uid.New(uid.WorkspacePrefix) - err := db.Query.InsertWorkspace(ctx, h.DB.RW(), db.InsertWorkspaceParams{ - ID: otherWorkspaceID, - OrgID: uid.New("test_org"), - Name: "Other Workspace", - CreatedAt: time.Now().UnixMilli(), + diffWorkspace := h.CreateWorkspace() + + diffApi := h.CreateApi(seed.CreateApiRequest{ + WorkspaceID: diffWorkspace.ID, + IpWhitelist: "", + EncryptedKeys: false, + Name: nil, + CreatedAt: nil, + DefaultPrefix: nil, + DefaultBytes: nil, }) - require.NoError(t, err) - // Create keyring in other workspace - otherKeyAuthID := uid.New(uid.KeyAuthPrefix) - err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ - ID: otherKeyAuthID, - WorkspaceID: otherWorkspaceID, - StoreEncryptedKeys: false, - DefaultPrefix: sql.NullString{Valid: true, String: "test"}, - DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, - CreatedAtM: time.Now().UnixMilli(), + diffKey := h.CreateKey(seed.CreateKeyRequest{ + Disabled: false, + WorkspaceID: diffWorkspace.ID, + KeyAuthID: diffApi.KeyAuthID.String, + Remaining: nil, + IdentityID: nil, + Meta: nil, + Expires: nil, + Name: nil, + Deleted: false, + RefillAmount: nil, + RefillDay: nil, + Permissions: nil, + Roles: nil, + Ratelimits: nil, }) - require.NoError(t, err) - - // Create key in other workspace - otherKeyID := uid.New(uid.KeyPrefix) - otherKeyString := "test_" + uid.New("") - err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ - ID: otherKeyID, - KeyringID: otherKeyAuthID, - Hash: hash.Sha256(otherKeyString), - Start: otherKeyString[:4], - WorkspaceID: otherWorkspaceID, - ForWorkspaceID: sql.NullString{Valid: false}, - Name: sql.NullString{Valid: true, String: "Other Workspace Key"}, - CreatedAtM: time.Now().UnixMilli(), - Enabled: true, - IdentityID: sql.NullString{Valid: false}, - Meta: sql.NullString{Valid: false}, - Expires: sql.NullTime{Valid: false}, - RemainingRequests: sql.NullInt32{Valid: false}, - }) - require.NoError(t, err) // Create root key for original workspace (authorized for workspace.ID, not otherWorkspaceID) - authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_permission_to_key") reqWithOtherKey := handler.Request{ - KeyId: otherKeyID, + KeyId: diffKey.KeyID, Permissions: []string{permissionID}, } @@ -190,53 +172,6 @@ func TestAuthorizationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "key was not found") }) - t.Run("permission belongs to different workspace", func(t *testing.T) { - // Create another workspace - otherWorkspaceID := uid.New(uid.WorkspacePrefix) - err := db.Query.InsertWorkspace(ctx, h.DB.RW(), db.InsertWorkspaceParams{ - ID: otherWorkspaceID, - OrgID: uid.New("test_org"), - Name: "Other Workspace", - CreatedAt: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Create permission in other workspace - otherPermissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: otherPermissionID, - WorkspaceID: otherWorkspaceID, - Name: "other.permission", - Slug: "other.permission", - Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, - }) - require.NoError(t, err) - - // Create root key for original workspace - authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") - - reqWithOtherPermission := handler.Request{ - KeyId: keyID, - Permissions: []string{otherPermissionID}, - } - - headers := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", authorizedRootKey)}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - reqWithOtherPermission, - ) - - require.Equal(t, 404, res.Status) // Permission not found (because it belongs to different workspace) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - t.Run("root key with no permissions", func(t *testing.T) { // Create root key with no permissions noPermissionsRootKey := h.CreateRootKey(workspace.ID) diff --git a/go/apps/api/routes/v2_keys_add_permissions/404_test.go b/go/apps/api/routes/v2_keys_add_permissions/404_test.go index 328c53e726..372c7bf58a 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/404_test.go @@ -33,7 +33,7 @@ func TestNotFoundErrors(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_permission_to_key") // Set up request headers headers := http.Header{ @@ -111,99 +111,6 @@ func TestNotFoundErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "was not found") }) - t.Run("permission not found by name", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - nonExistentPermissionSlug := "nonexistent.permission.name" - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{nonExistentPermissionSlug}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status, "Wanted status code 404, received: %s", res.RawBody) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - - t.Run("permission from different workspace by ID", func(t *testing.T) { - // Create another workspace - otherWorkspaceID := uid.New(uid.WorkspacePrefix) - err := db.Query.InsertWorkspace(ctx, h.DB.RW(), db.InsertWorkspaceParams{ - ID: otherWorkspaceID, - OrgID: uid.New("test_org"), - Name: "Other Workspace", - CreatedAt: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Create a permission in the other workspace - otherPermissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: otherPermissionID, - WorkspaceID: otherWorkspaceID, - Name: "other.workspace.permission.404", - Slug: "other.workspace.permission.404", - Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, - }) - require.NoError(t, err) - - // Create API and key in our workspace using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{otherPermissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - t.Run("key from different workspace", func(t *testing.T) { // Create another workspace otherWorkspaceID := uid.New(uid.WorkspacePrefix) diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index 608bf4f508..d04b6d0362 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -82,14 +82,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - if key.Api.DeletedAtM.Valid { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to deleted api"), - fault.Public("The specified key was not found."), - ) - } - err = auth.Verify(ctx, keys.WithPermissions( rbac.And( rbac.Or( diff --git a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go index 10df9aa730..897dd67cf3 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go @@ -32,7 +32,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_permission_from_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go index efeec38d2f..343b3a8ac9 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go @@ -156,25 +156,6 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - t.Run("permission not found by id", func(t *testing.T) { - req := handler.Request{ - KeyId: validKeyID, - Permissions: []string{uid.New(uid.TestPrefix)}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - t.Run("key not found", func(t *testing.T) { // Create a permission that exists permissionID := uid.New(uid.TestPrefix) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/403_test.go b/go/apps/api/routes/v2_keys_remove_permissions/403_test.go index f332498bec..214a5f8f30 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -161,53 +160,6 @@ func TestAuthorizationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "key was not found") }) - t.Run("permission belongs to different workspace", func(t *testing.T) { - // Create another workspace - otherWorkspaceID := uid.New(uid.WorkspacePrefix) - err := db.Query.InsertWorkspace(ctx, h.DB.RW(), db.InsertWorkspaceParams{ - ID: otherWorkspaceID, - OrgID: uid.New("test_org"), - Name: "Other Workspace", - CreatedAt: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Create permission in other workspace - otherPermissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: otherPermissionID, - WorkspaceID: otherWorkspaceID, - Name: "other.permission.remove", - Slug: "other.permission.remove", - Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, - }) - require.NoError(t, err) - - // Create root key for original workspace - authorizedRootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") - - reqWithOtherPermission := handler.Request{ - KeyId: keyID, - Permissions: []string{otherPermissionID}, - } - - headers := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", authorizedRootKey)}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - reqWithOtherPermission, - ) - - require.Equal(t, 404, res.Status) // Permission not found (because it belongs to different workspace) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - t.Run("root key with no permissions", func(t *testing.T) { // Create root key with no permissions noPermissionsRootKey := h.CreateRootKey(workspace.ID) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go index d1a8ce3eac..20947e6de4 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go @@ -34,7 +34,7 @@ func TestNotFoundErrors(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_permission_from_key") // Set up request headers headers := http.Header{ @@ -110,115 +110,6 @@ func TestNotFoundErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "was not found") }) - t.Run("permission not found by slug", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Use a non-existent permission name - nonExistentPermissionSlug := "nonexistent.permission.remove.name" - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{nonExistentPermissionSlug}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status, "Expected status code 404, got: %s", res.RawBody) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - - t.Run("permission from different workspace by ID", func(t *testing.T) { - // Create another workspace - otherWorkspaceID := uid.New(uid.WorkspacePrefix) - err := db.Query.InsertWorkspace(ctx, h.DB.RW(), db.InsertWorkspaceParams{ - ID: otherWorkspaceID, - OrgID: uid.New("test_org"), - Name: "Other Workspace", - CreatedAt: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Create a permission in the other workspace - otherPermissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: otherPermissionID, - WorkspaceID: otherWorkspaceID, - Name: "other.workspace.permission.remove.404", - Slug: "other.workspace.permission.remove.404", - Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, - }) - require.NoError(t, err) - - // Create a test keyring in our workspace - keyAuthID := uid.New(uid.KeyAuthPrefix) - err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ - ID: keyAuthID, - WorkspaceID: workspace.ID, - StoreEncryptedKeys: false, - DefaultPrefix: sql.NullString{Valid: true, String: "test"}, - DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, - CreatedAtM: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Create a test key in our workspace - keyID := uid.New(uid.KeyPrefix) - keyString := "test_" + uid.New("") - err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ - ID: keyID, - KeyringID: keyAuthID, - Hash: hash.Sha256(keyString), - Start: keyString[:4], - WorkspaceID: workspace.ID, - ForWorkspaceID: sql.NullString{Valid: false}, - Name: sql.NullString{Valid: true, String: "Test Key"}, - CreatedAtM: time.Now().UnixMilli(), - Enabled: true, - IdentityID: sql.NullString{Valid: false}, - Meta: sql.NullString{Valid: false}, - Expires: sql.NullTime{Valid: false}, - RemainingRequests: sql.NullInt32{Valid: false}, - }) - require.NoError(t, err) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{otherPermissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - t.Run("key from different workspace", func(t *testing.T) { // Create another workspace otherWorkspaceID := uid.New(uid.WorkspacePrefix) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/handler.go b/go/apps/api/routes/v2_keys_remove_permissions/handler.go index df047338eb..a99a357e00 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/handler.go @@ -46,13 +46,11 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.removePermissions") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err @@ -118,11 +116,11 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve current permissions."), + fault.Internal("database error"), + fault.Public("Failed to retrieve current permissions."), ) } - // Convert current permissions to a map for efficient lookup currentPermissionIDs := make(map[string]db.Permission) for _, permission := range currentPermissions { currentPermissionIDs[permission.ID] = permission @@ -139,6 +137,23 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } + existingPermissions := make(map[string]db.Permission) + for _, permission := range foundPermissions { + existingPermissions[permission.ID] = permission + existingPermissions[permission.Slug] = permission + } + + for _, toRemove := range req.Permissions { + _, exists := existingPermissions[toRemove] + + if !exists { + return fault.New("permission not found", + fault.Code(codes.Data.Permission.NotFound.URN()), + fault.Public(fmt.Sprintf("Permission %q was not found.", toRemove)), + ) + } + } + permissionsToRemove := make([]db.Permission, 0) for _, permission := range foundPermissions { _, exists := currentPermissionIDs[permission.ID] @@ -153,10 +168,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if len(permissionsToRemove) > 0 { err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog + var idsToRemove []string - idsToRemove := make([]string, 0) - - // Remove permissions for _, permission := range permissionsToRemove { idsToRemove = append(idsToRemove, permission.ID) auditLogs = append(auditLogs, auditlog.AuditLog{ diff --git a/go/apps/api/routes/v2_keys_set_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index ab380dd07e..27f41beaf8 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -32,7 +32,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_permission_from_key", "rbac.*.add_permission_to_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index 92f95e2854..32f7cbc31e 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -32,7 +32,7 @@ func TestNotFound(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_permission_from_key", "rbac.*.add_permission_to_key") // Set up request headers headers := http.Header{ @@ -112,43 +112,6 @@ func TestNotFound(t *testing.T) { require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID '%s' was not found", nonExistentPermissionID)) }) - t.Run("non-existent permission name", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - nonExistentPermissionName := "nonexistent.permission.name" - req := handler.Request{ - KeyId: keyID, - Permissions: []string{nonExistentPermissionName}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with slug '%s' was not found", nonExistentPermissionName)) - }) - t.Run("key from different workspace (isolation)", func(t *testing.T) { // Create another workspace otherWorkspace := h.CreateWorkspace() @@ -199,56 +162,6 @@ func TestNotFound(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "The specified key was not found") }) - t.Run("permission from different workspace (isolation)", func(t *testing.T) { - // Create another workspace - otherWorkspace := h.CreateWorkspace() - - // Create API and key in the authorized workspace using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create permission in the other workspace - permissionID := uid.New(uid.TestPrefix) - err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: permissionID, - WorkspaceID: otherWorkspace.ID, // Different workspace - Name: "documents.read.otherworkspace", - Slug: "documents.read.otherworkspace", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, - }) - require.NoError(t, err) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{permissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Permission 'documents.read.otherworkspace' was not found") - }) - t.Run("multiple permissions with early failure", func(t *testing.T) { // Create API and key using testutil helpers defaultPrefix := "test" diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index 9d180d388d..e2a60b639f 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -85,14 +85,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - if key.Api.DeletedAtM.Valid { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to deleted api"), - fault.Public("The specified key was not found."), - ) - } - err = auth.Verify(ctx, keys.WithPermissions( rbac.And( rbac.Or( @@ -107,6 +99,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Action: rbac.UpdateKey, }), ), + // TODO: Only check this if we are actually setting permissions rbac.And( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, diff --git a/go/apps/api/routes/v2_keys_update_credits/handler.go b/go/apps/api/routes/v2_keys_update_credits/handler.go index 8c46e9bd6b..b60cd9568a 100644 --- a/go/apps/api/routes/v2_keys_update_credits/handler.go +++ b/go/apps/api/routes/v2_keys_update_credits/handler.go @@ -91,15 +91,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Check if API is deleted - if key.Api.DeletedAtM.Valid { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to deleted api"), - fault.Public("The specified key was not found."), - ) - } - // Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index e11754d254..b34cb459ac 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -88,14 +88,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - if key.Api.DeletedAtM.Valid { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to deleted api"), - fault.Public("The specified key was not found."), - ) - } - + // TODO: We should actually check if the user has permission to set/remove roles. err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Api, diff --git a/go/pkg/db/bulk_key_permission_insert.sql.go b/go/pkg/db/bulk_key_permission_insert.sql.go index 9808eb94e6..f12060f1ea 100644 --- a/go/pkg/db/bulk_key_permission_insert.sql.go +++ b/go/pkg/db/bulk_key_permission_insert.sql.go @@ -33,7 +33,11 @@ func (q *BulkQueries) InsertKeyPermissions(ctx context.Context, db DBTX, args [] allArgs = append(allArgs, arg.PermissionID) allArgs = append(allArgs, arg.WorkspaceID) allArgs = append(allArgs, arg.CreatedAt) - allArgs = append(allArgs, arg.UpdatedAt) + } + + // Add ON DUPLICATE KEY UPDATE parameters (only once, not per row) + if len(args) > 0 { + allArgs = append(allArgs, args[0].UpdatedAt) } // Execute the bulk insert diff --git a/go/pkg/db/bulk_ratelimit_override_insert.sql.go b/go/pkg/db/bulk_ratelimit_override_insert.sql.go index b3a7202896..f6172160ca 100644 --- a/go/pkg/db/bulk_ratelimit_override_insert.sql.go +++ b/go/pkg/db/bulk_ratelimit_override_insert.sql.go @@ -40,7 +40,11 @@ func (q *BulkQueries) InsertRatelimitOverrides(ctx context.Context, db DBTX, arg allArgs = append(allArgs, arg.Limit) allArgs = append(allArgs, arg.Duration) allArgs = append(allArgs, arg.CreatedAt) - allArgs = append(allArgs, arg.UpdatedAt) + } + + // Add ON DUPLICATE KEY UPDATE parameters (only once, not per row) + if len(args) > 0 { + allArgs = append(allArgs, args[0].UpdatedAt) } // Execute the bulk insert diff --git a/go/pkg/db/plugins/bulk-insert/bulk_insert.go.tmpl b/go/pkg/db/plugins/bulk-insert/bulk_insert.go.tmpl index 28bad735f4..9013ced05a 100644 --- a/go/pkg/db/plugins/bulk-insert/bulk_insert.go.tmpl +++ b/go/pkg/db/plugins/bulk-insert/bulk_insert.go.tmpl @@ -32,10 +32,19 @@ func (q *BulkQueries) {{.BulkFunctionName}}(ctx context.Context, args []{{.Param // Collect all arguments var allArgs []any for _, arg := range args { - {{- range .Fields}} + {{- range .ValuesFields}} allArgs = append(allArgs, arg.{{.}}) {{- end}} } + {{- if .UpdateFields}} + + // Add ON DUPLICATE KEY UPDATE parameters (only once, not per row) + if len(args) > 0 { + {{- range .UpdateFields}} + allArgs = append(allArgs, args[0].{{.}}) + {{- end}} + } + {{- end}} // Execute the bulk insert {{if .EmitMethodsWithDBArgument -}} diff --git a/go/pkg/db/plugins/bulk-insert/generator.go b/go/pkg/db/plugins/bulk-insert/generator.go index 952f7ba000..9f6c519fea 100644 --- a/go/pkg/db/plugins/bulk-insert/generator.go +++ b/go/pkg/db/plugins/bulk-insert/generator.go @@ -54,8 +54,8 @@ func (g *Generator) Generate(ctx context.Context, req *plugin.GenerateRequest) ( // Generate pluralized function name for interface bulkFunctionName := query.Name - if strings.HasPrefix(query.Name, "Insert") { - entityName := strings.TrimPrefix(query.Name, "Insert") + entityName, isInsert := strings.CutPrefix(query.Name, "Insert") + if isInsert { pluralizedEntity := pluralize(entityName) bulkFunctionName = "Insert" + pluralizedEntity } @@ -110,6 +110,21 @@ func (g *Generator) generateBulkInsertFunction(query *plugin.Query) *plugin.File // Extract field names from query parameters fields := g.extractFieldNames(query.Params) + // Determine which fields are used in VALUES clause vs ON DUPLICATE KEY UPDATE + // sqlc guarantees that parameters are ordered as they appear in the SQL query. + // Since VALUES clause always comes before ON DUPLICATE KEY UPDATE in SQL, + // we can safely use the placeholder count from the parsed VALUES clause + // to determine which parameters belong to which part. + valuesFields := fields + var updateFields []string + + if parsedQuery.ValuesPlaceholderCount > 0 && parsedQuery.ValuesPlaceholderCount < len(fields) { + // Split fields based on the actual number of placeholders in the VALUES clause + valuesFields = fields[:parsedQuery.ValuesPlaceholderCount] + // The remaining fields are for ON DUPLICATE KEY UPDATE + updateFields = fields[parsedQuery.ValuesPlaceholderCount:] + } + // Generate the bulk insert function content renderer := NewTemplateRenderer() content, err := renderer.Render(TemplateData{ @@ -122,6 +137,8 @@ func (g *Generator) generateBulkInsertFunction(query *plugin.Query) *plugin.File OnDuplicateKeyUpdate: parsedQuery.OnDuplicateKeyUpdate, EmitMethodsWithDBArgument: g.options.EmitMethodsWithDBArgument, Fields: fields, + ValuesFields: valuesFields, + UpdateFields: updateFields, }) if err != nil { return nil diff --git a/go/pkg/db/plugins/bulk-insert/parser.go b/go/pkg/db/plugins/bulk-insert/parser.go index b1a1e6b3f1..cf005e093b 100644 --- a/go/pkg/db/plugins/bulk-insert/parser.go +++ b/go/pkg/db/plugins/bulk-insert/parser.go @@ -11,9 +11,10 @@ type SQLParser struct{} // ParsedQuery represents a parsed INSERT query. type ParsedQuery struct { - InsertPart string - ValuesPart string - OnDuplicateKeyUpdate string + InsertPart string + ValuesPart string + OnDuplicateKeyUpdate string + ValuesPlaceholderCount int } // NewSQLParser creates a new SQL parser. @@ -31,11 +32,13 @@ func (p *SQLParser) Parse(query *plugin.Query) (*ParsedQuery, error) { insertPart, valuesPart := p.parseInsertQuery(originalSQL) onDuplicateKeyUpdate := p.extractOnDuplicateKeyUpdate(originalSQL) + placeholderCount := p.countPlaceholders(valuesPart) return &ParsedQuery{ - InsertPart: insertPart, - ValuesPart: valuesPart, - OnDuplicateKeyUpdate: onDuplicateKeyUpdate, + InsertPart: insertPart, + ValuesPart: valuesPart, + OnDuplicateKeyUpdate: onDuplicateKeyUpdate, + ValuesPlaceholderCount: placeholderCount, }, nil } @@ -117,3 +120,14 @@ func (p *SQLParser) extractOnDuplicateKeyUpdate(originalSQL string) string { } return "" } + +// countPlaceholders counts the number of ? placeholders in the values clause. +func (p *SQLParser) countPlaceholders(valuesPart string) int { + count := 0 + for _, ch := range valuesPart { + if ch == '?' { + count++ + } + } + return count +} diff --git a/go/pkg/db/plugins/bulk-insert/template.go b/go/pkg/db/plugins/bulk-insert/template.go index 9682cde545..dd659e30cd 100644 --- a/go/pkg/db/plugins/bulk-insert/template.go +++ b/go/pkg/db/plugins/bulk-insert/template.go @@ -24,6 +24,8 @@ type TemplateData struct { OnDuplicateKeyUpdate string EmitMethodsWithDBArgument bool Fields []string + ValuesFields []string // Fields used in VALUES clause + UpdateFields []string // Fields used in ON DUPLICATE KEY UPDATE clause } // NewTemplateRenderer creates a new template renderer. From 5d582ff2b936b95aaf02f85809d05073f161718c Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:24:41 +0200 Subject: [PATCH 06/20] more changes --- .../v2_keys_add_permissions/200_test.go | 17 +++-- .../v2_keys_add_permissions/404_test.go | 2 +- .../routes/v2_keys_add_permissions/handler.go | 16 ++--- .../v2_keys_remove_permissions/404_test.go | 71 +++++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/go/apps/api/routes/v2_keys_add_permissions/200_test.go b/go/apps/api/routes/v2_keys_add_permissions/200_test.go index 3f1ede261c..b1e1e7aa09 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/200_test.go @@ -226,11 +226,18 @@ func TestSuccess(t *testing.T) { require.NotNil(t, res.Body.Data) require.Len(t, res.Body.Data, 2) - // Verify both permissions are in response (sorted alphabetically) - require.Equal(t, permission1ID, res.Body.Data[0].Id) - require.Equal(t, "documents.read.multiple", res.Body.Data[0].Name) - require.Equal(t, permission2ID, res.Body.Data[1].Id) - require.Equal(t, "documents.write.multiple", res.Body.Data[1].Name) + contains := func(id string) bool { + for _, p := range res.Body.Data { + if p.Id == id { + return true + } + } + return false + } + + // Verify both permissions are in response + require.True(t, contains(permission1ID)) + require.True(t, contains(permission2ID)) // Verify permissions were added to key finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), keyID) diff --git a/go/apps/api/routes/v2_keys_add_permissions/404_test.go b/go/apps/api/routes/v2_keys_add_permissions/404_test.go index 372c7bf58a..4ed861aa77 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/404_test.go @@ -92,7 +92,7 @@ func TestNotFoundErrors(t *testing.T) { keyID := keyResponse.KeyID // Use a non-existent permission ID - nonExistentPermissionID := uid.New(uid.TestPrefix) + nonExistentPermissionID := uid.New(uid.PermissionPrefix) req := handler.Request{ KeyId: keyID, diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index d04b6d0362..cfc352a035 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -117,11 +117,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - currentPermissionIDs := make(map[string]db.Permission) - for _, permission := range currentPermissions { - currentPermissionIDs[permission.ID] = permission - } - foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ WorkspaceID: auth.AuthorizedWorkspaceID, Ids: req.Permissions, @@ -134,6 +129,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } missingPermissions := make(map[string]struct{}) + permissionsToSet := make([]db.Permission, 0) + permissionsToInsert := make([]db.InsertPermissionParams, 0) + currentPermissionIDs := make(map[string]db.Permission) + + for _, permission := range currentPermissions { + currentPermissionIDs[permission.ID] = permission + } + for _, permission := range req.Permissions { missingPermissions[permission] = struct{}{} } @@ -143,9 +146,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { delete(missingPermissions, permission.Slug) } - permissionsToSet := make([]db.Permission, 0) - permissionsToInsert := make([]db.InsertPermissionParams, 0) - for _, permission := range foundPermissions { _, ok := currentPermissionIDs[permission.ID] if ok { diff --git a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go index 20947e6de4..18c2078a5d 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go @@ -110,6 +110,77 @@ func TestNotFoundErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "was not found") }) + t.Run("permission from different workspace by ID", func(t *testing.T) { + // Create another workspace + otherWorkspaceID := uid.New(uid.WorkspacePrefix) + err := db.Query.InsertWorkspace(ctx, h.DB.RW(), db.InsertWorkspaceParams{ + ID: otherWorkspaceID, + OrgID: uid.New("test_org"), + Name: "Other Workspace", + CreatedAt: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create a permission in the other workspace + otherPermissionID := uid.New(uid.TestPrefix) + err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ + PermissionID: otherPermissionID, + WorkspaceID: otherWorkspaceID, + Name: "other.workspace.permission.remove.404", + Slug: "other.workspace.permission.remove.404", + Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, + }) + require.NoError(t, err) + + // Create a test keyring in our workspace + keyAuthID := uid.New(uid.KeyAuthPrefix) + err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: keyAuthID, + WorkspaceID: workspace.ID, + StoreEncryptedKeys: false, + DefaultPrefix: sql.NullString{Valid: true, String: "test"}, + DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create a test key in our workspace + keyID := uid.New(uid.KeyPrefix) + keyString := "test_" + uid.New("") + err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ + ID: keyID, + KeyringID: keyAuthID, + Hash: hash.Sha256(keyString), + Start: keyString[:4], + WorkspaceID: workspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "Test Key"}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false}, + Meta: sql.NullString{Valid: false}, + Expires: sql.NullTime{Valid: false}, + RemainingRequests: sql.NullInt32{Valid: false}, + }) + require.NoError(t, err) + + req := handler.Request{ + KeyId: keyID, + Permissions: []string{otherPermissionID}, + } + + res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( + h, + route, + headers, + req, + ) + + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + require.Contains(t, res.Body.Error.Detail, "was not found") + }) + t.Run("key from different workspace", func(t *testing.T) { // Create another workspace otherWorkspaceID := uid.New(uid.WorkspacePrefix) From 7488c5ea0b20d1b3eba00b719bb85e37caa46521 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:00:34 +0200 Subject: [PATCH 07/20] more changes --- .../v2_keys_set_permissions/200_test.go | 17 ++-- .../v2_keys_set_permissions/400_test.go | 37 --------- .../v2_keys_set_permissions/403_test.go | 81 +++++++++++-------- .../v2_keys_set_permissions/404_test.go | 8 +- .../routes/v2_keys_set_permissions/handler.go | 38 +++------ 5 files changed, 78 insertions(+), 103 deletions(-) diff --git a/go/apps/api/routes/v2_keys_set_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index 27f41beaf8..c02259a7cc 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -122,11 +122,18 @@ func TestSuccess(t *testing.T) { require.NotNil(t, res.Body.Data) require.Len(t, res.Body.Data, 2) - // Verify response contains new permissions (should be sorted alphabetically by name) - require.Equal(t, permission3ID, res.Body.Data[0].Id) // documents.delete.new - require.Equal(t, "documents.delete.new", res.Body.Data[0].Name) - require.Equal(t, permission2ID, res.Body.Data[1].Id) // documents.write.new - require.Equal(t, "documents.write.new", res.Body.Data[1].Name) + contains := func(id string) bool { + for _, p := range res.Body.Data { + if p.Id == id { + return true + } + } + return false + } + + // Verify response contains new permissions + require.True(t, contains(permission3ID)) + require.True(t, contains(permission2ID)) // Verify permissions in database finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), keyID) diff --git a/go/apps/api/routes/v2_keys_set_permissions/400_test.go b/go/apps/api/routes/v2_keys_set_permissions/400_test.go index a71b49dbaf..d003c5ee1f 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/400_test.go @@ -162,43 +162,6 @@ func TestBadRequest(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - t.Run("permission not found - invalid format", func(t *testing.T) { - // Create test data using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - invalidID := "invalid_permission_id" - req := handler.Request{ - KeyId: keyID, - Permissions: []string{invalidID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Permission with ID 'invalid_permission_id' was not found") - }) - t.Run("malformed JSON", func(t *testing.T) { // Test invalid JSON structure - using incomplete object req := map[string]interface{}{ diff --git a/go/apps/api/routes/v2_keys_set_permissions/403_test.go b/go/apps/api/routes/v2_keys_set_permissions/403_test.go index bf1e596343..e32fcf6580 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/403_test.go @@ -6,14 +6,13 @@ 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_keys_set_permissions" "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" ) @@ -34,39 +33,35 @@ func TestForbidden(t *testing.T) { // Create a workspace workspace := h.Resources().UserWorkspace - // Create test data - keyAuthID := uid.New(uid.KeyAuthPrefix) - err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ - ID: keyAuthID, - WorkspaceID: workspace.ID, - StoreEncryptedKeys: false, - DefaultPrefix: sql.NullString{Valid: true, String: "test"}, - DefaultBytes: sql.NullInt32{Valid: true, Int32: 16}, - CreatedAtM: time.Now().UnixMilli(), + api := h.CreateApi(seed.CreateApiRequest{ + WorkspaceID: workspace.ID, + IpWhitelist: "", + EncryptedKeys: false, + Name: nil, + CreatedAt: nil, + DefaultPrefix: nil, + DefaultBytes: nil, }) - require.NoError(t, err) - keyID := uid.New(uid.KeyPrefix) - keyString := "test_" + uid.New("") - err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ - ID: keyID, - KeyringID: keyAuthID, - Hash: hash.Sha256(keyString), - Start: keyString[:4], - WorkspaceID: workspace.ID, - ForWorkspaceID: sql.NullString{Valid: false}, - Name: sql.NullString{Valid: true, String: "Test Key"}, - CreatedAtM: time.Now().UnixMilli(), - Enabled: true, - IdentityID: sql.NullString{Valid: false}, - Meta: sql.NullString{Valid: false}, - Expires: sql.NullTime{Valid: false}, - RemainingRequests: sql.NullInt32{Valid: false}, + key := h.CreateKey(seed.CreateKeyRequest{ + Disabled: false, + WorkspaceID: workspace.ID, + KeyAuthID: api.KeyAuthID.String, + Remaining: nil, + IdentityID: nil, + Meta: nil, + Expires: nil, + Name: nil, + Deleted: false, + RefillAmount: nil, + RefillDay: nil, + Permissions: nil, + Roles: nil, + Ratelimits: nil, }) - require.NoError(t, err) - permissionID := uid.New(uid.TestPrefix) - err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ + permissionID := uid.New(uid.PermissionPrefix) + err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permissionID, WorkspaceID: workspace.ID, Name: "documents.read.forbidden", @@ -76,7 +71,7 @@ func TestForbidden(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: keyID, + KeyId: key.KeyID, Permissions: []string{permissionID}, } @@ -167,4 +162,26 @@ func TestForbidden(t *testing.T) { require.NotNil(t, res.Body.Error) require.Contains(t, res.Body.Error.Detail, "permission") }) + + t.Run("permission for different resource", func(t *testing.T) { + // Create root key with permission for different resource + rootKey := h.CreateRootKey(workspace.ID, "identity.*.update_identity") // Wrong resource type + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( + h, + route, + headers, + req, + ) + + require.Equal(t, 403, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + require.Contains(t, res.Body.Error.Detail, "permission") + }) } diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index 32f7cbc31e..a8ff5f7550 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -92,7 +92,7 @@ func TestNotFound(t *testing.T) { keyID := keyResponse.KeyID // Use non-existent permission ID - nonExistentPermissionID := uid.New(uid.TestPrefix) + nonExistentPermissionID := uid.New(uid.PermissionPrefix) req := handler.Request{ KeyId: keyID, @@ -109,7 +109,7 @@ func TestNotFound(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID '%s' was not found", nonExistentPermissionID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID %q was not found", nonExistentPermissionID)) }) t.Run("key from different workspace (isolation)", func(t *testing.T) { @@ -192,7 +192,7 @@ func TestNotFound(t *testing.T) { require.NoError(t, err) // Use non-existent permission ID as first item - nonExistentPermissionID := uid.New(uid.TestPrefix) + nonExistentPermissionID := uid.New(uid.PermissionPrefix) req := handler.Request{ KeyId: keyID, @@ -209,6 +209,6 @@ func TestNotFound(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID '%s' was not found", nonExistentPermissionID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID %q was not found", nonExistentPermissionID)) }) } diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index e2a60b639f..ec039ff21d 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -99,7 +99,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Action: rbac.UpdateKey, }), ), - // TODO: Only check this if we are actually setting permissions rbac.And( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -114,6 +113,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ), ), )) + if err != nil { + return err + } currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { @@ -135,6 +137,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } missingPermissions := make(map[string]struct{}) + permissionsToSet := make([]db.Permission, 0) + permissionsToInsert := make([]db.InsertPermissionParams, 0) + for _, permission := range req.Permissions { missingPermissions[permission] = struct{}{} } @@ -144,9 +149,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { delete(missingPermissions, permission.Slug) } - permissionsToSet := make([]db.Permission, 0) - permissionsToInsert := make([]db.InsertPermissionParams, 0) - for _, permission := range foundPermissions { permissionsToSet = append(permissionsToSet, permission) } @@ -180,9 +182,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - currentPermissionIDs := make(map[string]bool) + currentPermissionMap := make(map[string]db.Permission) for _, permission := range currentPermissions { - currentPermissionIDs[permission.ID] = true + currentPermissionMap[permission.ID] = permission } requestedPermissionIDs := make(map[string]bool) @@ -200,8 +202,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } permissionsToAdd := make([]db.Permission, 0) - for _, permission := range foundPermissions { - if !currentPermissionIDs[permission.ID] { + for _, permission := range permissionsToSet { + _, ok := currentPermissionMap[permission.ID] + if !ok { permissionsToAdd = append(permissionsToAdd, permission) } } @@ -224,7 +227,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } for _, permissionID := range permissionsToRemove { - perm := requestedPermissionMap[permissionID] + perm := currentPermissionMap[permissionID] auditLogs = append(auditLogs, auditlog.AuditLog{ WorkspaceID: auth.AuthorizedWorkspaceID, Event: auditlog.AuthDisconnectPermissionKeyEvent, @@ -358,22 +361,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) responseData := make(openapi.V2KeysSetPermissionsResponseData, 0) - for _, permission := range foundPermissions { - perm := openapi.Permission{ - Description: nil, - Id: permission.ID, - Name: permission.Name, - Slug: permission.Slug, - } - - if permission.Description.Valid { - perm.Description = &permission.Description.String - } - - responseData = append(responseData, perm) - } - - for _, permission := range permissionsToAdd { + for _, permission := range permissionsToSet { perm := openapi.Permission{ Description: nil, Id: permission.ID, From 5d29a5f20bf24d2f3e83913f49af205f75e33682 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Sun, 27 Jul 2025 10:02:48 +0200 Subject: [PATCH 08/20] add more changes --- go/apps/api/openapi/gen.go | 7 +- go/apps/api/openapi/openapi-generated.yaml | 12 ++- .../V2KeysAddPermissionsRequestBody.yaml | 6 +- .../V2KeysRemovePermissionsRequestBody.yaml | 1 + .../V2KeysSetPermissionsRequestBody.yaml | 3 + .../v2_keys_add_permissions/200_test.go | 2 +- .../routes/v2_keys_add_permissions/handler.go | 19 +++-- .../api/routes/v2_keys_create_key/handler.go | 17 ++-- .../v2_keys_remove_permissions/handler.go | 4 +- .../v2_keys_set_permissions/200_test.go | 2 +- .../v2_keys_set_permissions/403_test.go | 22 ------ .../routes/v2_keys_set_permissions/handler.go | 19 +++-- .../api/routes/v2_keys_update_key/handler.go | 6 +- .../permission_find_by_slugs.sql_generated.go | 25 +++--- ...n_find_many_by_id_or_slug.sql_generated.go | 77 ------------------- go/pkg/db/querier_generated.go | 10 +-- .../db/queries/permission_find_by_slugs.sql | 2 +- .../permission_find_many_by_id_or_slug.sql | 4 - 18 files changed, 76 insertions(+), 162 deletions(-) delete mode 100644 go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go delete mode 100644 go/pkg/db/queries/permission_find_many_by_id_or_slug.sql diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index d66008b69b..8d711f50de 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -812,10 +812,9 @@ type V2KeysAddPermissionsRequestBody struct { // Permissions Grants additional permissions to the key through direct assignment or automatic creation. // Duplicate permissions are ignored automatically, making this operation idempotent. // - // You can either use a permission slug, or the permission ID. - // - // If slugs are used, the permission will be auto created IF the root key has the given permissions, otherwise this operation will fail with a 404 error. // Adding permissions never removes existing permissions or role-based permissions. + // + // Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. Permissions []string `json:"permissions"` } @@ -1208,6 +1207,8 @@ type V2KeysSetPermissionsRequestBody struct { // - Providing an empty array removes all direct permissions from the key // - This only affects direct permissions - permissions granted through roles are not affected // - All existing direct permissions not included in this list will be removed + // + // Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. Permissions []string `json:"permissions"` } diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index fc291a8040..9b4ae41ed6 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-24T14:07:41Z +# Generated at: 2025-07-25T16:04:33Z # Source: openapi-split.yaml components: @@ -562,15 +562,15 @@ components: Grants additional permissions to the key through direct assignment or automatic creation. Duplicate permissions are ignored automatically, making this operation idempotent. - You can either use a permission slug, or the permission ID. - - If slugs are used, the permission will be auto created IF the root key has the given permissions, otherwise this operation will fail with a 404 error. Adding permissions never removes existing permissions or role-based permissions. + + Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string minLength: 1 maxLength: 100 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specify the permission by its slug. additionalProperties: false V2KeysAddPermissionsResponseBody: type: object @@ -944,6 +944,7 @@ components: minLength: 1 maxLength: 100 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specify the permission by its slug. additionalProperties: false V2KeysRemovePermissionsResponseBody: type: object @@ -1047,11 +1048,14 @@ components: - Providing an empty array removes all direct permissions from the key - This only affects direct permissions - permissions granted through roles are not affected - All existing direct permissions not included in this list will be removed + + Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string minLength: 1 maxLength: 100 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specify the permission by its slug. additionalProperties: false V2KeysSetPermissionsResponseBody: type: object diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml index d205640abc..bc462c10cd 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml @@ -20,13 +20,13 @@ properties: Grants additional permissions to the key through direct assignment or automatic creation. Duplicate permissions are ignored automatically, making this operation idempotent. - You can either use a permission slug, or the permission ID. - - If slugs are used, the permission will be auto created IF the root key has the given permissions, otherwise this operation will fail with a 404 error. Adding permissions never removes existing permissions or role-based permissions. + + Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string minLength: 1 maxLength: 100 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specify the permission by its slug. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml index 492b5fd587..bac8178c44 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml @@ -27,4 +27,5 @@ properties: minLength: 1 maxLength: 100 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specify the permission by its slug. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml index 0e036ce4de..91f651ab74 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml @@ -23,9 +23,12 @@ properties: - Providing an empty array removes all direct permissions from the key - This only affects direct permissions - permissions granted through roles are not affected - All existing direct permissions not included in this list will be removed + + Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string minLength: 1 maxLength: 100 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: Specify the permission by its slug. additionalProperties: false diff --git a/go/apps/api/routes/v2_keys_add_permissions/200_test.go b/go/apps/api/routes/v2_keys_add_permissions/200_test.go index b1e1e7aa09..fed81f9237 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/200_test.go @@ -29,7 +29,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_permission_to_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_permission_to_key", "rbac.*.create_permission") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index cfc352a035..7e05480518 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -5,7 +5,6 @@ import ( "database/sql" "fmt" "net/http" - "strings" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -117,9 +116,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + foundPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Ids: req.Permissions, + Slugs: req.Permissions, }) if err != nil { return fault.Wrap(err, @@ -156,11 +155,15 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } for perm := range missingPermissions { - if strings.HasPrefix(perm, "perm_") { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Public(fmt.Sprintf("Permission with ID %q was not found.", perm)), - ) + err = auth.Verify(ctx, keys.WithPermissions( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.CreatePermission, + }), + )) + if err != nil { + return err } permissionID := uid.New(uid.PermissionPrefix) 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 abca1fb15c..8202094981 100644 --- a/go/apps/api/routes/v2_keys_create_key/handler.go +++ b/go/apps/api/routes/v2_keys_create_key/handler.go @@ -363,13 +363,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - existingPermMap := make(map[string]db.FindPermissionsBySlugsRow) + existingPermMap := make(map[string]db.Permission) for _, p := range existingPermissions { existingPermMap[p.Slug] = p } permissionsToCreate := []db.InsertPermissionParams{} - requestedPermissions := []db.FindPermissionsBySlugsRow{} + requestedPermissions := []db.Permission{} for _, requestedSlug := range *req.Permissions { existingPerm, exists := existingPermMap[requestedSlug] @@ -384,13 +384,18 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: requestedSlug, Slug: requestedSlug, - Description: sql.NullString{String: fmt.Sprintf("Auto-created permission: %s", requestedSlug), Valid: true}, + Description: sql.NullString{String: "", Valid: false}, CreatedAtM: now, }) - requestedPermissions = append(requestedPermissions, db.FindPermissionsBySlugsRow{ - ID: newPermID, - Slug: requestedSlug, + requestedPermissions = append(requestedPermissions, db.Permission{ + ID: newPermID, + Name: requestedSlug, + Slug: requestedSlug, + CreatedAtM: now, + WorkspaceID: auth.AuthorizedWorkspaceID, + Description: sql.NullString{String: "", Valid: false}, + UpdatedAtM: sql.NullInt64{Int64: 0, Valid: false}, }) } diff --git a/go/apps/api/routes/v2_keys_remove_permissions/handler.go b/go/apps/api/routes/v2_keys_remove_permissions/handler.go index a99a357e00..ab918d8e6d 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/handler.go @@ -126,9 +126,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { currentPermissionIDs[permission.ID] = permission } - foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + foundPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Ids: req.Permissions, + Slugs: req.Permissions, }) if err != nil { return fault.Wrap(err, diff --git a/go/apps/api/routes/v2_keys_set_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index c02259a7cc..b69a04a36a 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -32,7 +32,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_permission_from_key", "rbac.*.add_permission_to_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_permission_from_key", "rbac.*.add_permission_to_key", "rbac.*.create_permission") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_set_permissions/403_test.go b/go/apps/api/routes/v2_keys_set_permissions/403_test.go index e32fcf6580..8a4b929795 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/403_test.go @@ -162,26 +162,4 @@ func TestForbidden(t *testing.T) { require.NotNil(t, res.Body.Error) require.Contains(t, res.Body.Error.Detail, "permission") }) - - t.Run("permission for different resource", func(t *testing.T) { - // Create root key with permission for different resource - rootKey := h.CreateRootKey(workspace.ID, "identity.*.update_identity") // Wrong resource type - - headers := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, - } - - res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 403, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "permission") - }) } diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index ec039ff21d..b267ff5023 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -5,7 +5,6 @@ import ( "database/sql" "fmt" "net/http" - "strings" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -125,9 +124,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - foundPermissions, err := db.Query.FindManyPermissionsByIdOrSlug(ctx, h.DB.RO(), db.FindManyPermissionsByIdOrSlugParams{ + foundPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Ids: req.Permissions, + Slugs: req.Permissions, }) if err != nil { return fault.Wrap(err, @@ -154,11 +153,15 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } for perm := range missingPermissions { - if strings.HasPrefix(perm, "perm_") { - return fault.New("permission not found", - fault.Code(codes.Data.Permission.NotFound.URN()), - fault.Public(fmt.Sprintf("Permission with ID %q was not found.", perm)), - ) + err = auth.Verify(ctx, keys.WithPermissions( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.CreatePermission, + }), + )) + if err != nil { + return err } permissionID := uid.New(uid.PermissionPrefix) diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index b34cb459ac..4c387bc427 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -380,13 +380,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - existingPermMap := make(map[string]db.FindPermissionsBySlugsRow) + existingPermMap := make(map[string]db.Permission) for _, p := range existingPermissions { existingPermMap[p.Slug] = p } permissionsToCreate := []db.InsertPermissionParams{} - requestedPermissions := []db.FindPermissionsBySlugsRow{} + requestedPermissions := []db.Permission{} for _, requestedSlug := range *req.Permissions { existingPerm, exists := existingPermMap[requestedSlug] @@ -405,7 +405,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { CreatedAtM: time.Now().UnixMilli(), }) - requestedPermissions = append(requestedPermissions, db.FindPermissionsBySlugsRow{ + requestedPermissions = append(requestedPermissions, db.Permission{ ID: newPermID, Slug: requestedSlug, }) diff --git a/go/pkg/db/permission_find_by_slugs.sql_generated.go b/go/pkg/db/permission_find_by_slugs.sql_generated.go index 10d4b3b2af..a5168bc89e 100644 --- a/go/pkg/db/permission_find_by_slugs.sql_generated.go +++ b/go/pkg/db/permission_find_by_slugs.sql_generated.go @@ -11,7 +11,7 @@ import ( ) const findPermissionsBySlugs = `-- name: FindPermissionsBySlugs :many -SELECT id, slug FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) +SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) ` type FindPermissionsBySlugsParams struct { @@ -19,15 +19,10 @@ type FindPermissionsBySlugsParams struct { 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) { +// SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) +func (q *Queries) FindPermissionsBySlugs(ctx context.Context, db DBTX, arg FindPermissionsBySlugsParams) ([]Permission, error) { query := findPermissionsBySlugs var queryParams []interface{} queryParams = append(queryParams, arg.WorkspaceID) @@ -44,10 +39,18 @@ func (q *Queries) FindPermissionsBySlugs(ctx context.Context, db DBTX, arg FindP return nil, err } defer rows.Close() - var items []FindPermissionsBySlugsRow + var items []Permission for rows.Next() { - var i FindPermissionsBySlugsRow - if err := rows.Scan(&i.ID, &i.Slug); err != nil { + var i Permission + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAtM, + &i.UpdatedAtM, + ); err != nil { return nil, err } items = append(items, i) diff --git a/go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go b/go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go deleted file mode 100644 index 0b1afeac37..0000000000 --- a/go/pkg/db/permission_find_many_by_id_or_slug.sql_generated.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: permission_find_many_by_id_or_slug.sql - -package db - -import ( - "context" - "strings" -) - -const findManyPermissionsByIdOrSlug = `-- name: FindManyPermissionsByIdOrSlug :many -SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m -FROM permissions -WHERE workspace_id = ? AND (id IN (/*SLICE:ids*/?) OR slug IN (/*SLICE:ids*/?)) -` - -type FindManyPermissionsByIdOrSlugParams struct { - WorkspaceID string `db:"workspace_id"` - Ids []string `db:"ids"` -} - -// FindManyPermissionsByIdOrSlug -// -// SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m -// FROM permissions -// WHERE workspace_id = ? AND (id IN (/*SLICE:ids*/?) OR slug IN (/*SLICE:ids*/?)) -func (q *Queries) FindManyPermissionsByIdOrSlug(ctx context.Context, db DBTX, arg FindManyPermissionsByIdOrSlugParams) ([]Permission, error) { - query := findManyPermissionsByIdOrSlug - var queryParams []interface{} - queryParams = append(queryParams, arg.WorkspaceID) - if len(arg.Ids) > 0 { - for _, v := range arg.Ids { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) - } - if len(arg.Ids) > 0 { - for _, v := range arg.Ids { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) - } - rows, err := db.QueryContext(ctx, query, queryParams...) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Permission - for rows.Next() { - var i Permission - if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Name, - &i.Slug, - &i.Description, - &i.CreatedAtM, - &i.UpdatedAtM, - ); 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 ba41d65130..f951692e17 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -309,12 +309,6 @@ type Querier interface { // ORDER BY created_at DESC // LIMIT 1 FindLatestBuildByVersionId(ctx context.Context, db DBTX, versionID string) (Build, error) - //FindManyPermissionsByIdOrSlug - // - // SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m - // FROM permissions - // WHERE workspace_id = ? AND (id IN (/*SLICE:ids*/?) OR slug IN (/*SLICE:ids*/?)) - FindManyPermissionsByIdOrSlug(ctx context.Context, db DBTX, arg FindManyPermissionsByIdOrSlugParams) ([]Permission, error) // Finds a permission record by its ID // Returns: The permission record if found // @@ -347,8 +341,8 @@ type Querier interface { 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) + // SELECT id, workspace_id, name, slug, description, created_at_m, updated_at_m FROM permissions WHERE workspace_id = ? AND slug IN (/*SLICE:slugs*/?) + FindPermissionsBySlugs(ctx context.Context, db DBTX, arg FindPermissionsBySlugsParams) ([]Permission, error) //FindProjectById // // SELECT diff --git a/go/pkg/db/queries/permission_find_by_slugs.sql b/go/pkg/db/queries/permission_find_by_slugs.sql index 9c316fec47..cd63965ae3 100644 --- a/go/pkg/db/queries/permission_find_by_slugs.sql +++ b/go/pkg/db/queries/permission_find_by_slugs.sql @@ -1,2 +1,2 @@ -- name: FindPermissionsBySlugs :many -SELECT id, slug FROM permissions WHERE workspace_id = ? AND slug IN (sqlc.slice('slugs')); +SELECT * FROM permissions WHERE workspace_id = ? AND slug IN (sqlc.slice('slugs')); diff --git a/go/pkg/db/queries/permission_find_many_by_id_or_slug.sql b/go/pkg/db/queries/permission_find_many_by_id_or_slug.sql deleted file mode 100644 index 01fde32ee1..0000000000 --- a/go/pkg/db/queries/permission_find_many_by_id_or_slug.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: FindManyPermissionsByIdOrSlug :many -SELECT * -FROM permissions -WHERE workspace_id = ? AND (id IN (sqlc.slice('ids')) OR slug IN (sqlc.slice('ids'))); From 8c763652e7ba120533d7ecb58c8feae9ddc70208 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:11:23 +0200 Subject: [PATCH 09/20] fix tests --- .../api/openapi/spec/common/permission.yaml | 1 + .../V2KeysAddPermissionsRequestBody.yaml | 5 +- .../V2KeysRemovePermissionsRequestBody.yaml | 5 +- .../V2KeysSetPermissionsRequestBody.yaml | 5 +- .../updateKey/V2KeysUpdateKeyRequestBody.yaml | 6 +- .../verifyKey/V2KeysVerifyKeyRequestBody.yaml | 2 +- .../v2_keys_add_permissions/200_test.go | 92 ++------------ .../v2_keys_add_permissions/404_test.go | 47 +------ .../v2_keys_remove_permissions/200_test.go | 116 ++++-------------- .../v2_keys_set_permissions/200_test.go | 26 ++-- .../v2_keys_set_permissions/404_test.go | 89 -------------- 11 files changed, 66 insertions(+), 328 deletions(-) diff --git a/go/apps/api/openapi/spec/common/permission.yaml b/go/apps/api/openapi/spec/common/permission.yaml index 93e2425a89..e315734956 100644 --- a/go/apps/api/openapi/spec/common/permission.yaml +++ b/go/apps/api/openapi/spec/common/permission.yaml @@ -21,6 +21,7 @@ properties: Names must be unique within your workspace to avoid confusion and conflicts. example: "users.read" slug: + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ type: string minLength: 1 maxLength: 512 diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml index bc462c10cd..caaddc121f 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml @@ -25,8 +25,7 @@ properties: Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml index bac8178c44..8a8c641bee 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml @@ -24,8 +24,7 @@ properties: After removal, verification checks for these permissions will fail unless granted through roles. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml index 91f651ab74..57693be67a 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml @@ -27,8 +27,7 @@ properties: Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml index 3fe5b2b130..a6c5063ae0 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml @@ -118,9 +118,9 @@ properties: maxItems: 1000 # Allow extensive permission sets for complex applications items: type: string - minLength: 1 - maxLength: 100 # Keep permission names concise and readable - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + maxLength: 100 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: | Grants specific permissions directly to this key without requiring role membership. Wildcard permissions like `documents.*` grant access to all sub-permissions including `documents.read` and `documents.write`. diff --git a/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml index b921a62102..1226d0bd48 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml @@ -8,7 +8,7 @@ examples: apiId: api_1234abcd key: sk_1234abcdef withPermissions: - summary: Check permissions + summary: Check permissions description: Verify key has required permissions value: apiId: api_1234abcd diff --git a/go/apps/api/routes/v2_keys_add_permissions/200_test.go b/go/apps/api/routes/v2_keys_add_permissions/200_test.go index fed81f9237..32581a8990 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/200_test.go @@ -37,80 +37,6 @@ func TestSuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("add single permission by ID", func(t *testing.T) { - // Create API with keyring using testutil helper - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - // Create a test key using testutil helper - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create a permission using testutil helper - permissionDescription := "Read documents permission" - permissionID := h.CreatePermission(seed.CreatePermissionRequest{ - WorkspaceID: workspace.ID, - Name: "documents.read.single.id", - Slug: "documents.read.single.id", - Description: &permissionDescription, - }) - - // Verify key has no permissions initially - currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Empty(t, currentPermissions) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{permissionID}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 1) - require.Equal(t, permissionID, res.Body.Data[0].Id) - require.Equal(t, "documents.read.single.id", res.Body.Data[0].Slug) - - // Verify permission was added to key - finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, finalPermissions, 1) - require.Equal(t, permissionID, finalPermissions[0].ID) - - // Verify audit log was created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - foundConnectEvent := false - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.connect_permission_and_key" { - foundConnectEvent = true - require.Contains(t, log.AuditLog.Display, "Added permission documents.read.single.id to key") - break - } - } - require.True(t, foundConnectEvent, "Should find a permission connect audit log event") - }) - t.Run("add single permission by name", func(t *testing.T) { // Create API with keyring using testutil helper defaultPrefix := "test" @@ -211,7 +137,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []string{permission1ID, permission2Slug}, + Permissions: []string{permission1Name, permission2Slug}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -266,16 +192,17 @@ func TestSuccess(t *testing.T) { // Create a permission using testutil helper permissionDescription := "Read documents permission" - permissionID := h.CreatePermission(seed.CreatePermissionRequest{ + permissionName := "documents.read.idempotent" + h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.read.idempotent", - Slug: "documents.read.idempotent", + Name: permissionName, + Slug: permissionName, Description: &permissionDescription, }) req := handler.Request{ KeyId: keyID, - Permissions: []string{permissionID}, + Permissions: []string{permissionName}, } // Add permission first time @@ -323,10 +250,11 @@ func TestSuccess(t *testing.T) { // Create permissions using testutil helper existingPermissionDescription := "Read documents permission" newPermissionDescription := "Write documents permission" + newPermissionSlug := "documents.write.existing" newPermissionID := h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.write.existing", - Slug: "documents.write.existing", + Name: newPermissionSlug, + Slug: newPermissionSlug, Description: &newPermissionDescription, }) @@ -349,7 +277,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []string{newPermissionID}, + Permissions: []string{newPermissionSlug}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_add_permissions/404_test.go b/go/apps/api/routes/v2_keys_add_permissions/404_test.go index 4ed861aa77..1c9838fa66 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/404_test.go @@ -43,12 +43,13 @@ func TestNotFoundErrors(t *testing.T) { t.Run("key not found", func(t *testing.T) { // Create a permission that exists - permissionID := uid.New(uid.TestPrefix) + permissionID := uid.New(uid.PermissionPrefix) + permissionSlug := "documents.read.404keynotfound" err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permissionID, WorkspaceID: workspace.ID, - Name: "documents.read.404keynotfound", - Slug: "documents.read.404keynotfound", + Name: permissionSlug, + Slug: permissionSlug, Description: sql.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -58,7 +59,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: nonExistentKeyID, - Permissions: []string{permissionID}, + Permissions: []string{permissionSlug}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -73,44 +74,6 @@ func TestNotFoundErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "key was not found") }) - t.Run("permission not found by ID", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Use a non-existent permission ID - nonExistentPermissionID := uid.New(uid.PermissionPrefix) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{nonExistentPermissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "was not found") - }) - t.Run("key from different workspace", func(t *testing.T) { // Create another workspace otherWorkspaceID := uid.New(uid.WorkspacePrefix) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go index 897dd67cf3..50b9ab80ad 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go @@ -40,78 +40,6 @@ func TestSuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("remove single permission by ID", func(t *testing.T) { - // Create API with keyring using testutil helper - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - // Create a test key with permission using testutil helper - keyName := "Test Key" - permissionDescription := "Read documents permission" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - Permissions: []seed.CreatePermissionRequest{ - { - WorkspaceID: workspace.ID, - Name: "documents.read.remove.single.id", - Slug: "documents.read.remove.single.id", - Description: &permissionDescription, - }, - }, - }) - keyID := keyResponse.KeyID - permissionID := keyResponse.PermissionIds[0] - - // Verify key has the permission initially - currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, currentPermissions, 1) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{permissionID}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotEmpty(t, res.Body.Meta.RequestId) - require.NotNil(t, res.Body.Data) - - // Verify permission was removed from key - finalPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, finalPermissions, 0) - - // Verify audit log was created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - foundDisconnectEvent := false - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.disconnect_permission_and_key" { - foundDisconnectEvent = true - require.Contains(t, log.AuditLog.Display, "Removed permission documents.read.remove.single.id from key") - break - } - } - require.True(t, foundDisconnectEvent, "Should find a permission disconnect audit log event") - }) - t.Run("remove single permission by name", func(t *testing.T) { // Create API with keyring using testutil helper defaultPrefix := "test" @@ -185,10 +113,11 @@ func TestSuccess(t *testing.T) { // Create permissions using testutil helpers permission1Description := "Read documents permission" + permission1Name := "documents.read.remove.multiple" permission1ID := h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.read.remove.multiple", - Slug: "documents.read.remove.multiple", + Name: permission1Name, + Slug: permission1Name, Description: &permission1Description, }) @@ -220,7 +149,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []string{permission1ID, permission2Name}, + Permissions: []string{permission1Name, permission2Name}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -261,16 +190,17 @@ func TestSuccess(t *testing.T) { // Create a permission but don't assign it to the key permissionDescription := "Read documents permission" - permissionID := h.CreatePermission(seed.CreatePermissionRequest{ + permission1Name := "documents.read.remove.idempotent" + h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.read.remove.idempotent", - Slug: "documents.read.remove.idempotent", + Name: permission1Name, + Slug: permission1Name, Description: &permissionDescription, }) req := handler.Request{ KeyId: keyID, - Permissions: []string{permissionID}, + Permissions: []string{permission1Name}, } // Remove permission (which isn't assigned) @@ -331,11 +261,12 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) removePermissionID := uid.New(uid.TestPrefix) + removePermissionName := "documents.write.remove.partial.remove" err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: removePermissionID, WorkspaceID: workspace.ID, - Name: "documents.write.remove.partial.remove", - Slug: "documents.write.remove.partial.remove", + Name: removePermissionName, + Slug: removePermissionName, Description: sql.NullString{Valid: true, String: "Write documents permission"}, }) require.NoError(t, err) @@ -359,7 +290,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []string{removePermissionID}, + Permissions: []string{removePermissionName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -401,26 +332,29 @@ func TestSuccess(t *testing.T) { // Create multiple permissions using testutil helpers permission1Description := "Read documents permission" + permission1Name := "documents.read.remove.all.1" permission1ID := h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.read.remove.all.1", - Slug: "documents.read.remove.all.1", + Name: permission1Name, + Slug: permission1Name, Description: &permission1Description, }) permission2Description := "Write documents permission" + permission2Name := "documents.write.remove.all.2" permission2ID := h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.write.remove.all.2", - Slug: "documents.write.remove.all.2", + Name: permission2Name, + Slug: permission2Name, Description: &permission2Description, }) permission3Description := "Delete documents permission" + permission3Name := "documents.delete.remove.all.3" permission3ID := h.CreatePermission(seed.CreatePermissionRequest{ WorkspaceID: workspace.ID, - Name: "documents.delete.remove.all.3", - Slug: "documents.delete.remove.all.3", + Name: permission3Name, + Slug: permission3Name, Description: &permission3Description, }) @@ -457,9 +391,9 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, Permissions: []string{ - permission1ID, - permission2ID, - permission3ID, + permission1Name, + permission2Name, + permission3Name, }, } diff --git a/go/apps/api/routes/v2_keys_set_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index b69a04a36a..634593baec 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -61,31 +61,34 @@ func TestSuccess(t *testing.T) { // Create permissions permission1ID := uid.New(uid.TestPrefix) + permission1Slug := "documents.read.initial" err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permission1ID, WorkspaceID: workspace.ID, - Name: "documents.read.initial", - Slug: "documents.read.initial", + Name: permission1Slug, + Slug: permission1Slug, Description: sql.NullString{Valid: true, String: "Initial permission"}, }) require.NoError(t, err) permission2ID := uid.New(uid.TestPrefix) + permission2Slug := "documents.write.new" err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permission2ID, WorkspaceID: workspace.ID, - Name: "documents.write.new", - Slug: "documents.write.new", + Name: permission2Slug, + Slug: permission2Slug, Description: sql.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) permission3ID := uid.New(uid.TestPrefix) + permission3Slug := "documents.delete.new" err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permission3ID, WorkspaceID: workspace.ID, - Name: "documents.delete.new", - Slug: "documents.delete.new", + Name: permission3Slug, + Slug: permission3Slug, Description: sql.NullString{Valid: true, String: "Delete permission"}, }) require.NoError(t, err) @@ -107,7 +110,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []string{permission2ID, permission3ID}, + Permissions: []string{permission2Slug, permission3Slug}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -325,12 +328,13 @@ func TestSuccess(t *testing.T) { keyID := keyResponse.KeyID // Create permission - permissionID := uid.New(uid.TestPrefix) + permissionID := uid.New(uid.PermissionPrefix) + permissionSlugAndName := "documents.read.idempotent" err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ PermissionID: permissionID, WorkspaceID: workspace.ID, - Name: "documents.read.idempotent", - Slug: "documents.read.idempotent", + Name: permissionSlugAndName, + Slug: permissionSlugAndName, Description: sql.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) @@ -346,7 +350,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Permissions: []string{permissionID}, + Permissions: []string{permissionSlugAndName}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index a8ff5f7550..5944dff6f8 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -73,45 +73,6 @@ func TestNotFound(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "The specified key was not found") }) - t.Run("non-existent permission ID", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Use non-existent permission ID - nonExistentPermissionID := uid.New(uid.PermissionPrefix) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{nonExistentPermissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID %q was not found", nonExistentPermissionID)) - }) - t.Run("key from different workspace (isolation)", func(t *testing.T) { // Create another workspace otherWorkspace := h.CreateWorkspace() @@ -161,54 +122,4 @@ func TestNotFound(t *testing.T) { require.NotNil(t, res.Body.Error) require.Contains(t, res.Body.Error.Detail, "The specified key was not found") }) - - t.Run("multiple permissions with early failure", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create a valid permission for the second item - validPermissionID := uid.New(uid.TestPrefix) - err := db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ - PermissionID: validPermissionID, - WorkspaceID: workspace.ID, - Name: "documents.read.valid", - Slug: "documents.read.valid", - Description: sql.NullString{Valid: true, String: "Valid permission"}, - }) - require.NoError(t, err) - - // Use non-existent permission ID as first item - nonExistentPermissionID := uid.New(uid.PermissionPrefix) - - req := handler.Request{ - KeyId: keyID, - Permissions: []string{nonExistentPermissionID, validPermissionID}, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Permission with ID %q was not found", nonExistentPermissionID)) - }) } From 176e1033a19cc7628581c3e7049e7771fda4da39 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:49:55 +0200 Subject: [PATCH 10/20] remove roleId stuff --- go/apps/api/openapi/gen.go | 64 ++------ go/apps/api/openapi/openapi-generated.yaml | 142 ++++++------------ .../addRoles/V2KeysAddRolesRequestBody.yaml | 31 +--- .../addRoles/V2KeysAddRolesResponseData.yaml | 6 + .../V2KeysRemoveRolesRequestBody.yaml | 32 +--- .../setRoles/V2KeysSetRolesRequestBody.yaml | 31 +--- .../setRoles/V2KeysSetRolesResponseData.yaml | 6 + .../V2PermissionsDeleteRoleRequestBody.yaml | 9 +- .../V2PermissionsGetRoleRequestBody.yaml | 11 +- .../api/routes/v2_keys_add_roles/200_test.go | 41 +---- .../api/routes/v2_keys_add_roles/400_test.go | 85 +---------- .../api/routes/v2_keys_add_roles/401_test.go | 7 +- .../api/routes/v2_keys_add_roles/403_test.go | 28 +--- .../api/routes/v2_keys_add_roles/404_test.go | 50 +----- 14 files changed, 117 insertions(+), 426 deletions(-) diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 8d711f50de..497b7ae436 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -865,20 +865,7 @@ type V2KeysAddRolesRequestBody struct { // All roles must already exist in the workspace - roles cannot be created automatically. // Invalid roles cause the entire operation to fail atomically, ensuring consistent state. // Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. - Roles []struct { - // Id References an existing role by its database identifier. - // Use when you know the exact role ID and want to ensure you're referencing a specific role. - // Takes precedence over name when both are provided in the same object. - // Essential for automation scripts where role names might change but IDs remain stable. - Id *string `json:"id,omitempty"` - - // Name Identifies the role by its human-readable name within the workspace. - // Role names must start with a letter and contain only letters, numbers, underscores, or hyphens. - // Names must be unique within the workspace and are case-sensitive. - // More readable than IDs but vulnerable to integration breaks if roles are renamed. - // Use IDs for automation and names for human-configured integrations. - Name *string `json:"name,omitempty"` - } `json:"roles"` + Roles []string `json:"roles"` } // V2KeysAddRolesResponseBody defines model for V2KeysAddRolesResponseBody. @@ -914,6 +901,9 @@ type V2KeysAddRolesResponseBody struct { // - This only shows direct role assignments, not inherited or nested roles // - Role permissions are not expanded in this response - use keys.getKey for full details type V2KeysAddRolesResponseData = []struct { + // Description A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. + Description *string `json:"description,omitempty"` + // Id The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. Role IDs are immutable and guaranteed to be unique within your Unkey workspace, making them reliable reference points for integration and automation systems. Id string `json:"id"` @@ -1136,19 +1126,7 @@ type V2KeysRemoveRolesRequestBody struct { // After removal, the key loses access to permissions that were only granted through these roles. // Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. // Invalid role references cause the entire operation to fail atomically, ensuring consistent state. - Roles []struct { - // Id References the role to remove by its database identifier. - // Use when you know the exact role ID and want to ensure you're removing a specific role. - // Takes precedence over name when both are provided in the same object. - // Essential for automation scripts where role names might change but IDs remain stable. - Id *string `json:"id,omitempty"` - - // Name Identifies the role to remove by its exact name with case-sensitive matching. - // Must match the complete role name as currently defined in the workspace, starting with a letter and using only letters, numbers, underscores, or hyphens. - // More readable than IDs but vulnerable to integration breaks if roles are renamed. - // Use IDs for automation and names for human-configured integrations. - Name *string `json:"name,omitempty"` - } `json:"roles"` + Roles []string `json:"roles"` } // V2KeysRemoveRolesResponseBody defines model for V2KeysRemoveRolesResponseBody. @@ -1260,20 +1238,7 @@ type V2KeysSetRolesRequestBody struct { // All roles must already exist in the workspace - roles cannot be created automatically. // Invalid role references cause the entire operation to fail atomically, ensuring consistent state. // Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. - Roles []struct { - // Id References an existing role by its database identifier. - // Use when you know the exact role ID and want to ensure you're referencing a specific role. - // Takes precedence over name when both are provided in the same object. - // Essential for automation scripts where role names might change but IDs remain stable. - Id *string `json:"id,omitempty"` - - // Name Identifies the role by its human-readable name within the workspace. - // Role names must start with a letter and contain only letters, numbers, underscores, or hyphens. - // Names must be unique within the workspace and are case-sensitive. - // More readable than IDs but vulnerable to integration breaks if roles are renamed. - // Use IDs for automation and names for human-configured integrations. - Name *string `json:"name,omitempty"` - } `json:"roles"` + Roles []string `json:"roles"` } // V2KeysSetRolesResponseBody defines model for V2KeysSetRolesResponseBody. @@ -1311,6 +1276,9 @@ type V2KeysSetRolesResponseBody struct { // - Role permissions are not expanded in this response - use keys.getKey for complete details // - An empty array indicates the key now has no roles assigned at all type V2KeysSetRolesResponseData = []struct { + // Description A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. + Description *string `json:"description,omitempty"` + // Id The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. Role IDs are immutable and guaranteed to be unique, making them reliable reference points for integration and automation systems. Id string `json:"id"` @@ -1582,8 +1550,8 @@ type V2PermissionsDeletePermissionResponseBody struct { // V2PermissionsDeleteRoleRequestBody defines model for V2PermissionsDeleteRoleRequestBody. type V2PermissionsDeleteRoleRequestBody struct { - // RoleId Unique identifier of the role to permanently delete from your workspace. - // Must be a valid role ID that begins with 'role_' and exists within your workspace. + // Role Unique identifier of the role to permanently delete from your workspace. + // Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. // // WARNING: Deletion is immediate and irreversible with significant consequences: // - All API keys assigned this role will lose the associated permissions @@ -1592,11 +1560,10 @@ type V2PermissionsDeleteRoleRequestBody struct { // - Historical analytics referencing this role remain intact // // Before deletion, ensure: - // - You have the correct role ID (verify the role name and permissions) // - You've updated any dependent authorization logic or code // - You've migrated any keys to use alternative roles or direct permissions // - You've notified relevant team members of the access changes - RoleId string `json:"roleId"` + Role *string `json:"role,omitempty"` } // V2PermissionsDeleteRoleResponseBody defines model for V2PermissionsDeleteRoleResponseBody. @@ -1630,11 +1597,12 @@ type V2PermissionsGetPermissionResponseData struct { // V2PermissionsGetRoleRequestBody defines model for V2PermissionsGetRoleRequestBody. type V2PermissionsGetRoleRequestBody struct { - // RoleId Specifies which role to retrieve by its unique identifier. - // Must be a valid role ID that begins with 'role_' and exists within your workspace. + // Role Unique identifier of the role to permanently delete from your workspace. + // Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. + // // Use this endpoint to verify role details, check its current permissions, or retrieve metadata. // Returns complete role information including all assigned permissions for comprehensive access review. - RoleId string `json:"roleId"` + Role string `json:"role"` } // V2PermissionsGetRoleResponseBody defines model for V2PermissionsGetRoleResponseBody. diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 9b4ae41ed6..2ce9017586 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-25T16:04:33Z +# Generated at: 2025-07-28T08:14:30Z # Source: openapi-split.yaml components: @@ -567,9 +567,8 @@ components: Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false V2KeysAddPermissionsResponseBody: @@ -612,32 +611,11 @@ components: Invalid roles cause the entire operation to fail atomically, ensuring consistent state. Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - References an existing role by its database identifier. - Use when you know the exact role ID and want to ensure you're referencing a specific role. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where role names might change but IDs remain stable. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" - description: | - Identifies the role by its human-readable name within the workspace. - Role names must start with a letter and contain only letters, numbers, underscores, or hyphens. - Names must be unique within the workspace and are case-sensitive. - More readable than IDs but vulnerable to integration breaks if roles are renamed. - Use IDs for automation and names for human-configured integrations. - example: admin - additionalProperties: false + type: string + minLength: 3 + maxLength: 255 + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: References an existing role by its name or id identifier. additionalProperties: false V2KeysAddRolesResponseBody: type: object @@ -941,9 +919,8 @@ components: After removal, verification checks for these permissions will fail unless granted through roles. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false V2KeysRemovePermissionsResponseBody: @@ -986,31 +963,11 @@ components: Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - References the role to remove by its database identifier. - Use when you know the exact role ID and want to ensure you're removing a specific role. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where role names might change but IDs remain stable. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" - description: | - Identifies the role to remove by its exact name with case-sensitive matching. - Must match the complete role name as currently defined in the workspace, starting with a letter and using only letters, numbers, underscores, or hyphens. - More readable than IDs but vulnerable to integration breaks if roles are renamed. - Use IDs for automation and names for human-configured integrations. - example: admin - additionalProperties: false + type: string + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" + minLength: 3 + maxLength: 255 + description: Use either ID for existing roles or name for exact string matching. additionalProperties: false V2KeysRemoveRolesResponseBody: type: object @@ -1052,9 +1009,8 @@ components: Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false V2KeysSetPermissionsResponseBody: @@ -1097,32 +1053,11 @@ components: Invalid role references cause the entire operation to fail atomically, ensuring consistent state. Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" - description: | - References an existing role by its database identifier. - Use when you know the exact role ID and want to ensure you're referencing a specific role. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where role names might change but IDs remain stable. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" - description: | - Identifies the role by its human-readable name within the workspace. - Role names must start with a letter and contain only letters, numbers, underscores, or hyphens. - Names must be unique within the workspace and are case-sensitive. - More readable than IDs but vulnerable to integration breaks if roles are renamed. - Use IDs for automation and names for human-configured integrations. - example: admin - additionalProperties: false + type: string + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" + minLength: 3 + maxLength: 255 + description: Use either ID for existing roles or name for exact string matching. additionalProperties: false V2KeysSetRolesResponseBody: type: object @@ -1300,9 +1235,9 @@ components: maxItems: 1000 items: type: string - minLength: 1 + minLength: 3 maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: | Grants specific permissions directly to this key without requiring role membership. Wildcard permissions like `documents.*` grant access to all sub-permissions including `documents.read` and `documents.write`. @@ -1557,14 +1492,14 @@ components: required: - roleId properties: - roleId: + role: type: string - minLength: 8 + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" + minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" description: | Unique identifier of the role to permanently delete from your workspace. - Must be a valid role ID that begins with 'role_' and exists within your workspace. + Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. WARNING: Deletion is immediate and irreversible with significant consequences: - All API keys assigned this role will lose the associated permissions @@ -1573,7 +1508,6 @@ components: - Historical analytics referencing this role remain intact Before deletion, ensure: - - You have the correct role ID (verify the role name and permissions) - You've updated any dependent authorization logic or code - You've migrated any keys to use alternative roles or direct permissions - You've notified relevant team members of the access changes @@ -1618,16 +1552,17 @@ components: V2PermissionsGetRoleRequestBody: type: object required: - - roleId + - role properties: - roleId: + role: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" description: | - Specifies which role to retrieve by its unique identifier. - Must be a valid role ID that begins with 'role_' and exists within your workspace. + Unique identifier of the role to permanently delete from your workspace. + Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. + Use this endpoint to verify role details, check its current permissions, or retrieve metadata. Returns complete role information including all assigned permissions for comprehensive access review. example: role_1234567890abcdef @@ -2502,6 +2437,7 @@ components: Names must be unique within your workspace to avoid confusion and conflicts. example: "users.read" slug: + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ type: string minLength: 1 maxLength: 512 @@ -2549,6 +2485,10 @@ components: type: string description: The name of the role. This is a human-readable identifier that's unique within your workspace. Role names help identify what access level or function a role provides. Common patterns include naming by access level (`admin`, `editor`, `viewer`), by department (`billing_manager`, `support_agent`), or by feature area (`analytics_user`, `dashboard_admin`). example: admin + description: + type: string + description: A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. + example: Full access to all resources and features V2KeysCreateKeyResponseData: type: object properties: @@ -2652,6 +2592,10 @@ components: type: string description: The name of the role. This is a human-readable identifier that's unique within your workspace. Role names are descriptive labels that help identify what access level or function a role provides. Good naming practices include naming by access level ('admin', 'editor'), by department ('billing_team', 'support_staff'), or by feature area ('reporting_user', 'settings_manager'). example: admin + description: + type: string + description: A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. + example: Full access to all resources and features KeysVerifyKeyCredits: type: object required: diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml index 196162b2e6..2bfb5ef372 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml @@ -27,30 +27,9 @@ properties: Invalid roles cause the entire operation to fail atomically, ensuring consistent state. Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 # Reasonable upper bound for database identifiers - pattern: "^[a-zA-Z0-9_]+$" - description: | - References an existing role by its database identifier. - Use when you know the exact role ID and want to ensure you're referencing a specific role. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where role names might change but IDs remain stable. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - minLength: 1 - maxLength: 100 # Keep role names concise and readable - pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" - description: | - Identifies the role by its human-readable name within the workspace. - Role names must start with a letter and contain only letters, numbers, underscores, or hyphens. - Names must be unique within the workspace and are case-sensitive. - More readable than IDs but vulnerable to integration breaks if roles are renamed. - Use IDs for automation and names for human-configured integrations. - example: admin - additionalProperties: false + type: string + minLength: 3 + maxLength: 255 # Reasonable upper bound for database identifiers + pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + description: References an existing role by its name or id identifier. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml index 225e72a07c..6c0c1aca11 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml @@ -34,3 +34,9 @@ items: access level (`admin`, `editor`, `viewer`), by department (`billing_manager`, `support_agent`), or by feature area (`analytics_user`, `dashboard_admin`). example: admin + description: + type: string + description: + A brief description of the role's purpose and responsibilities. + This helps users understand the role's scope and expected behavior. + example: Full access to all resources and features diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml index 48fc67e54a..9ef83a9c54 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml @@ -17,7 +17,7 @@ properties: roles: type: array minItems: 1 - maxItems: 100 # Reasonable limit for role assignments per key + maxItems: 100 description: | Removes direct role assignments from the key without affecting other role sources or permissions. Operations are idempotent - removing non-assigned roles has no effect and causes no errors. @@ -27,29 +27,9 @@ properties: Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 # Reasonable upper bound for database identifiers - pattern: "^[a-zA-Z0-9_]+$" - description: | - References the role to remove by its database identifier. - Use when you know the exact role ID and want to ensure you're removing a specific role. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where role names might change but IDs remain stable. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - minLength: 1 - maxLength: 100 # Keep role names concise and readable - pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" - description: | - Identifies the role to remove by its exact name with case-sensitive matching. - Must match the complete role name as currently defined in the workspace, starting with a letter and using only letters, numbers, underscores, or hyphens. - More readable than IDs but vulnerable to integration breaks if roles are renamed. - Use IDs for automation and names for human-configured integrations. - example: admin - additionalProperties: false + type: string + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" + minLength: 3 + maxLength: 255 + description: Use either ID for existing roles or name for exact string matching. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml index 7146ef9871..c43f06963d 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml @@ -27,30 +27,9 @@ properties: Invalid role references cause the entire operation to fail atomically, ensuring consistent state. Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. items: - type: object - properties: - id: - type: string - minLength: 3 - maxLength: 255 # Reasonable upper bound for database identifiers - pattern: "^[a-zA-Z0-9_]+$" - description: | - References an existing role by its database identifier. - Use when you know the exact role ID and want to ensure you're referencing a specific role. - Takes precedence over name when both are provided in the same object. - Essential for automation scripts where role names might change but IDs remain stable. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - minLength: 1 - maxLength: 100 # Keep role names concise and readable - pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" - description: | - Identifies the role by its human-readable name within the workspace. - Role names must start with a letter and contain only letters, numbers, underscores, or hyphens. - Names must be unique within the workspace and are case-sensitive. - More readable than IDs but vulnerable to integration breaks if roles are renamed. - Use IDs for automation and names for human-configured integrations. - example: admin - additionalProperties: false + type: string + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" + minLength: 3 + maxLength: 255 + description: Use either ID for existing roles or name for exact string matching. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml index 87e813e16c..c9951ee0fc 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml @@ -35,3 +35,9 @@ items: by department ('billing_team', 'support_staff'), or by feature area ('reporting_user', 'settings_manager'). example: admin + description: + type: string + description: + A brief description of the role's purpose and responsibilities. + This helps users understand the role's scope and expected behavior. + example: Full access to all resources and features diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml index f431e64687..c179297b24 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml @@ -2,14 +2,14 @@ type: object required: - roleId properties: - roleId: + role: type: string - minLength: 8 + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" + minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" description: | Unique identifier of the role to permanently delete from your workspace. - Must be a valid role ID that begins with 'role_' and exists within your workspace. + Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. WARNING: Deletion is immediate and irreversible with significant consequences: - All API keys assigned this role will lose the associated permissions @@ -18,7 +18,6 @@ properties: - Historical analytics referencing this role remain intact Before deletion, ensure: - - You have the correct role ID (verify the role name and permissions) - You've updated any dependent authorization logic or code - You've migrated any keys to use alternative roles or direct permissions - You've notified relevant team members of the access changes diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/getRole/V2PermissionsGetRoleRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/getRole/V2PermissionsGetRoleRequestBody.yaml index ee29f1ae5c..3e51da844a 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/getRole/V2PermissionsGetRoleRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/getRole/V2PermissionsGetRoleRequestBody.yaml @@ -1,15 +1,16 @@ type: object required: - - roleId + - role properties: - roleId: + role: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" description: | - Specifies which role to retrieve by its unique identifier. - Must be a valid role ID that begins with 'role_' and exists within your workspace. + Unique identifier of the role to permanently delete from your workspace. + Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. + Use this endpoint to verify role details, check its current permissions, or retrieve metadata. Returns complete role information including all assigned permissions for comprehensive access review. example: role_1234567890abcdef diff --git a/go/apps/api/routes/v2_keys_add_roles/200_test.go b/go/apps/api/routes/v2_keys_add_roles/200_test.go index 776ebd887a..236346d24f 100644 --- a/go/apps/api/routes/v2_keys_add_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/200_test.go @@ -64,12 +64,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: key.KeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleId}, - }, + Roles: []string{roleId}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -129,12 +124,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: key.KeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &roleName}, - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -187,14 +177,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: key.KeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &adminRole}, // By ID - {Name: &editorRoleName}, // By name - {Id: &viewerMultiRole}, // By ID - }, + Roles: []string{adminRole, editorRoleName, viewerMultiRole}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -267,13 +250,7 @@ func TestSuccess(t *testing.T) { // Now try to add both admin (existing) and editor (new) roles req := handler.Request{ KeyId: key.KeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &adminId}, // Already exists - {Id: &editorId}, // New role - }, + Roles: []string{adminId, editorId}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -332,15 +309,7 @@ func TestSuccess(t *testing.T) { // ID should take precedence req := handler.Request{ KeyId: key.KeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - { - Id: &adminID, - Name: &editorRoleName, // This should be ignored, ID takes precedence - }, - }, + Roles: []string{adminID, editorRoleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_add_roles/400_test.go b/go/apps/api/routes/v2_keys_add_roles/400_test.go index b498325444..b3a9782ab2 100644 --- a/go/apps/api/routes/v2_keys_add_roles/400_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/400_test.go @@ -116,34 +116,9 @@ func TestValidationErrors(t *testing.T) { // Test case for empty roles array t.Run("empty roles array", func(t *testing.T) { - req := map[string]interface{}{ - "keyId": validKeyID, - "roles": []map[string]interface{}{}, // empty array - } - - res := testutil.CallRoute[map[string]interface{}, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "validate schema") - }) - - // Test case for role with neither id nor name - t.Run("role with neither id nor name", func(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {}, // empty role reference - }, + Roles: []string{}, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -153,28 +128,6 @@ func TestValidationErrors(t *testing.T) { req, ) - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "must specify either 'id' or 'name'") - }) - - // Test case for invalid role ID format - t.Run("invalid role ID format", func(t *testing.T) { - req := map[string]interface{}{ - "keyId": validKeyID, - "roles": []map[string]interface{}{ - {"id": "ab"}, // too short - }, - } - - res := testutil.CallRoute[map[string]interface{}, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - require.Equal(t, 400, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) @@ -202,42 +155,10 @@ func TestValidationErrors(t *testing.T) { }) // Test case for role with empty string id - t.Run("role with empty string id", func(t *testing.T) { - emptyId := "" + t.Run("role with empty string", func(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &emptyId}, - }, - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "validate schema") - }) - - // Test case for role with empty string name - t.Run("role with empty string name", func(t *testing.T) { - emptyName := "" - req := handler.Request{ - KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &emptyName}, - }, + Roles: []string{}, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_add_roles/401_test.go b/go/apps/api/routes/v2_keys_add_roles/401_test.go index 372fccc6a5..d73e4830ad 100644 --- a/go/apps/api/routes/v2_keys_add_roles/401_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/401_test.go @@ -48,12 +48,7 @@ func TestAuthenticationErrors(t *testing.T) { // Create a valid request req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } // Test case for missing authorization header diff --git a/go/apps/api/routes/v2_keys_add_roles/403_test.go b/go/apps/api/routes/v2_keys_add_roles/403_test.go index 2dd8a7f76c..df50d530ff 100644 --- a/go/apps/api/routes/v2_keys_add_roles/403_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/403_test.go @@ -57,12 +57,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } headers := http.Header{ @@ -126,12 +121,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } headers := http.Header{ @@ -177,12 +167,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } headers := http.Header{ @@ -228,12 +213,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_add_roles/404_test.go b/go/apps/api/routes/v2_keys_add_roles/404_test.go index 77992c9a4d..86576600e0 100644 --- a/go/apps/api/routes/v2_keys_add_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/404_test.go @@ -45,12 +45,7 @@ func TestNotFoundErrors(t *testing.T) { t.Run("key not found", func(t *testing.T) { req := handler.Request{ KeyId: "key_nonexistent123456789", // Non-existent key ID - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -90,12 +85,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, // Key from workspace2, but accessed with workspace1 root key - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -133,12 +123,7 @@ func TestNotFoundErrors(t *testing.T) { nonExistentRoleId := "role_nonexistent123456789" req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &nonExistentRoleId}, // Non-existent role ID - }, + Roles: []string{nonExistentRoleId}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -177,12 +162,7 @@ func TestNotFoundErrors(t *testing.T) { nonExistentRoleName := "nonexistent_role" req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &nonExistentRoleName}, // Non-existent role name - }, + Roles: []string{nonExistentRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -231,12 +211,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, // Role from different workspace - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -285,12 +260,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &roleName}, // Role by name from different workspace - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -339,13 +309,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, // Valid role - {Id: &invalidRoleId}, // Invalid role - should cause 404 - }, + Roles: []string{validRoleID, invalidRoleId}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( From 164a06f5ec0a5ca19fa111841ee3ede24fbd0fc1 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:31:07 +0200 Subject: [PATCH 11/20] more changes --- go/apps/api/openapi/gen.go | 37 +- go/apps/api/openapi/openapi-generated.yaml | 113 +++--- go/apps/api/openapi/spec/common/role.yaml | 17 - .../addRoles/V2KeysAddRolesResponseData.yaml | 28 +- .../V2KeysRemoveRolesResponseData.yaml | 16 +- .../setRoles/V2KeysSetRolesResponseData.yaml | 28 +- .../V2PermissionsDeleteRoleRequestBody.yaml | 2 +- .../api/routes/v2_apis_list_keys/handler.go | 1 + .../api/routes/v2_keys_add_roles/404_test.go | 14 +- .../api/routes/v2_keys_add_roles/handler.go | 228 ++++++----- .../routes/v2_keys_remove_roles/200_test.go | 21 +- .../routes/v2_keys_remove_roles/400_test.go | 95 +---- .../routes/v2_keys_remove_roles/401_test.go | 42 +-- .../routes/v2_keys_remove_roles/403_test.go | 49 +-- .../routes/v2_keys_remove_roles/404_test.go | 107 +----- .../routes/v2_keys_remove_roles/handler.go | 176 +++------ .../api/routes/v2_keys_set_roles/200_test.go | 52 +-- .../api/routes/v2_keys_set_roles/400_test.go | 43 --- .../api/routes/v2_keys_set_roles/401_test.go | 7 +- .../api/routes/v2_keys_set_roles/403_test.go | 14 +- .../api/routes/v2_keys_set_roles/404_test.go | 66 +--- .../api/routes/v2_keys_set_roles/handler.go | 353 ++++++++---------- .../v2_permissions_delete_role/200_test.go | 4 +- .../v2_permissions_delete_role/400_test.go | 28 +- .../v2_permissions_delete_role/401_test.go | 2 +- .../v2_permissions_delete_role/403_test.go | 4 +- .../v2_permissions_delete_role/404_test.go | 4 +- .../v2_permissions_delete_role/handler.go | 32 +- .../v2_permissions_get_role/200_test.go | 5 +- .../v2_permissions_get_role/400_test.go | 22 +- .../v2_permissions_get_role/401_test.go | 2 +- .../v2_permissions_get_role/403_test.go | 4 +- .../v2_permissions_get_role/404_test.go | 24 +- .../routes/v2_permissions_get_role/handler.go | 32 +- .../v2_permissions_list_roles/200_test.go | 1 - .../v2_permissions_list_roles/handler.go | 27 +- ..._many_by_key_and_role_ids.sql_generated.go | 41 ++ go/pkg/db/querier_generated.go | 93 ++++- ...y_role_delete_many_by_key_and_role_ids.sql | 3 + .../role_find_by_id_or_name_with_perms.sql | 21 ++ ...ole_find_many_by_id_or_name_with_perms.sql | 21 ++ go/pkg/db/queries/role_list.sql | 20 +- go/pkg/db/queries/role_list_by_key_id.sql | 20 +- ..._by_id_or_name_with_perms.sql_generated.go | 86 +++++ ..._by_id_or_name_with_perms.sql_generated.go | 122 ++++++ go/pkg/db/role_list.sql_generated.go | 54 ++- .../db/role_list_by_key_id.sql_generated.go | 58 ++- 47 files changed, 990 insertions(+), 1249 deletions(-) create mode 100644 go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go create mode 100644 go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql create mode 100644 go/pkg/db/queries/role_find_by_id_or_name_with_perms.sql create mode 100644 go/pkg/db/queries/role_find_many_by_id_or_name_with_perms.sql create mode 100644 go/pkg/db/role_find_by_id_or_name_with_perms.sql_generated.go create mode 100644 go/pkg/db/role_find_many_by_id_or_name_with_perms.sql_generated.go diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 497b7ae436..4380fc98ea 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -493,11 +493,6 @@ type RatelimitResponse struct { // Role defines model for Role. type Role struct { - // CreatedAt Unix timestamp in milliseconds indicating when this role was first created. - // Useful for auditing and understanding the evolution of your access control structure. - // Automatically set by the system and cannot be modified. - CreatedAt int64 `json:"createdAt"` - // Description Optional detailed explanation of what this role encompasses and what access it provides. // Helps team members understand the role's scope, intended use cases, and security implications. // Include information about what types of users should receive this role and what they can accomplish. @@ -900,16 +895,7 @@ type V2KeysAddRolesResponseBody struct { // - An empty array means the key has no roles assigned (unlikely after an add operation) // - This only shows direct role assignments, not inherited or nested roles // - Role permissions are not expanded in this response - use keys.getKey for full details -type V2KeysAddRolesResponseData = []struct { - // Description A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. - Description *string `json:"description,omitempty"` - - // Id The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. Role IDs are immutable and guaranteed to be unique within your Unkey workspace, making them reliable reference points for integration and automation systems. - Id string `json:"id"` - - // Name The name of the role. This is a human-readable identifier that's unique within your workspace. Role names help identify what access level or function a role provides. Common patterns include naming by access level (`admin`, `editor`, `viewer`), by department (`billing_manager`, `support_agent`), or by feature area (`analytics_user`, `dashboard_admin`). - Name string `json:"name"` -} +type V2KeysAddRolesResponseData = []Role // V2KeysCreateKeyRequestBody defines model for V2KeysCreateKeyRequestBody. type V2KeysCreateKeyRequestBody struct { @@ -1163,13 +1149,7 @@ type V2KeysRemoveRolesResponseBody struct { // - This only shows direct role assignments // - Role permissions are not expanded in this response - use keys.getKey for full details // - Changes take effect immediately for new verifications but cached sessions may retain old permissions briefly -type V2KeysRemoveRolesResponseData = []struct { - // Id The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. - Id string `json:"id"` - - // Name The name of the role. This is a human-readable identifier that's unique within your workspace. - Name string `json:"name"` -} +type V2KeysRemoveRolesResponseData = []Role // V2KeysSetPermissionsRequestBody defines model for V2KeysSetPermissionsRequestBody. type V2KeysSetPermissionsRequestBody struct { @@ -1275,16 +1255,7 @@ type V2KeysSetRolesResponseBody struct { // - This only shows direct role assignments on the key // - Role permissions are not expanded in this response - use keys.getKey for complete details // - An empty array indicates the key now has no roles assigned at all -type V2KeysSetRolesResponseData = []struct { - // Description A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. - Description *string `json:"description,omitempty"` - - // Id The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. Role IDs are immutable and guaranteed to be unique, making them reliable reference points for integration and automation systems. - Id string `json:"id"` - - // Name The name of the role. This is a human-readable identifier that's unique within your workspace. Role names are descriptive labels that help identify what access level or function a role provides. Good naming practices include naming by access level ('admin', 'editor'), by department ('billing_team', 'support_staff'), or by feature area ('reporting_user', 'settings_manager'). - Name string `json:"name"` -} +type V2KeysSetRolesResponseData = []Role // V2KeysUpdateCreditsRequestBody defines model for V2KeysUpdateCreditsRequestBody. type V2KeysUpdateCreditsRequestBody struct { @@ -1563,7 +1534,7 @@ type V2PermissionsDeleteRoleRequestBody struct { // - You've updated any dependent authorization logic or code // - You've migrated any keys to use alternative roles or direct permissions // - You've notified relevant team members of the access changes - Role *string `json:"role,omitempty"` + Role string `json:"role"` } // V2PermissionsDeleteRoleResponseBody defines model for V2PermissionsDeleteRoleResponseBody. diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 2ce9017586..85f70edcfc 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-28T08:14:30Z +# Generated at: 2025-07-28T09:03:45Z # Source: openapi-split.yaml components: @@ -1490,7 +1490,7 @@ components: V2PermissionsDeleteRoleRequestBody: type: object required: - - roleId + - role properties: role: type: string @@ -2472,23 +2472,49 @@ components: - This only shows direct role assignments, not inherited or nested roles - Role permissions are not expanded in this response - use keys.getKey for full details items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. Role IDs are immutable and guaranteed to be unique within your Unkey workspace, making them reliable reference points for integration and automation systems. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the role. This is a human-readable identifier that's unique within your workspace. Role names help identify what access level or function a role provides. Common patterns include naming by access level (`admin`, `editor`, `viewer`), by department (`billing_manager`, `support_agent`), or by feature area (`analytics_user`, `dashboard_admin`). - example: admin - description: - type: string - description: A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. - example: Full access to all resources and features + "$ref": "#/components/schemas/role" + role: + type: object + properties: + id: + type: string + description: | + The unique identifier for this role within Unkey's system. + Generated automatically when the role is created and used to reference this role in API operations. + Always begins with 'role_' followed by alphanumeric characters and underscores. + example: role_1234567890abcdef + name: + type: string + description: | + The human-readable name for this role that describes its function. + Should be descriptive enough for administrators to understand what access this role provides. + Use clear, semantic names that reflect the job function or responsibility level. + Names must be unique within your workspace to avoid confusion during role assignment. + example: "support.readonly" + description: + type: string + description: | + Optional detailed explanation of what this role encompasses and what access it provides. + Helps team members understand the role's scope, intended use cases, and security implications. + Include information about what types of users should receive this role and what they can accomplish. + Not visible to end users - this is for internal documentation and access control audits. + example: "Provides read-only access for customer support representatives to view user accounts and support tickets" + permissions: + type: array + items: + "$ref": "#/components/schemas/Permission" + maxItems: 100 + description: | + Complete list of permissions currently assigned to this role. + Each permission grants specific access rights that will be inherited by any keys or users assigned this role. + Use this list to understand the full scope of access provided by this role. + Permissions can be added or removed from roles without affecting the role's identity or other properties. + Empty array indicates a role with no permissions currently assigned. + required: + - id + - name + - permissions + additionalProperties: false V2KeysCreateKeyResponseData: type: object properties: @@ -2533,19 +2559,7 @@ components: - Role permissions are not expanded in this response - use keys.getKey for full details - Changes take effect immediately for new verifications but cached sessions may retain old permissions briefly items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the role. This is a human-readable identifier that's unique within your workspace. - example: admin + "$ref": "#/components/schemas/role" V2KeysSetPermissionsResponseData: type: array description: |- @@ -2579,23 +2593,7 @@ components: - Role permissions are not expanded in this response - use keys.getKey for complete details - An empty array indicates the key now has no roles assigned at all items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. Role IDs are immutable and guaranteed to be unique, making them reliable reference points for integration and automation systems. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the role. This is a human-readable identifier that's unique within your workspace. Role names are descriptive labels that help identify what access level or function a role provides. Good naming practices include naming by access level ('admin', 'editor'), by department ('billing_team', 'support_staff'), or by feature area ('reporting_user', 'settings_manager'). - example: admin - description: - type: string - description: A brief description of the role's purpose and responsibilities. This helps users understand the role's scope and expected behavior. - example: Full access to all resources and features + "$ref": "#/components/schemas/role" KeysVerifyKeyCredits: type: object required: @@ -2876,9 +2874,6 @@ components: properties: id: type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" description: | The unique identifier for this role within Unkey's system. Generated automatically when the role is created and used to reference this role in API operations. @@ -2886,8 +2881,6 @@ components: example: role_1234567890abcdef name: type: string - minLength: 1 - maxLength: 512 description: | The human-readable name for this role that describes its function. Should be descriptive enough for administrators to understand what access this role provides. @@ -2896,23 +2889,12 @@ components: example: "support.readonly" description: type: string - maxLength: 2048 description: | Optional detailed explanation of what this role encompasses and what access it provides. Helps team members understand the role's scope, intended use cases, and security implications. Include information about what types of users should receive this role and what they can accomplish. Not visible to end users - this is for internal documentation and access control audits. example: "Provides read-only access for customer support representatives to view user accounts and support tickets" - createdAt: - type: integer - format: int64 - minimum: 0 - maximum: 9223372036854776000 - description: | - Unix timestamp in milliseconds indicating when this role was first created. - Useful for auditing and understanding the evolution of your access control structure. - Automatically set by the system and cannot be modified. - example: 1701425400000 permissions: type: array items: @@ -2928,7 +2910,6 @@ components: - id - name - permissions - - createdAt additionalProperties: false V2PermissionsListPermissionsResponseData: type: array diff --git a/go/apps/api/openapi/spec/common/role.yaml b/go/apps/api/openapi/spec/common/role.yaml index a146dbd32b..b096c075ae 100644 --- a/go/apps/api/openapi/spec/common/role.yaml +++ b/go/apps/api/openapi/spec/common/role.yaml @@ -2,9 +2,6 @@ type: object properties: id: type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" description: | The unique identifier for this role within Unkey's system. Generated automatically when the role is created and used to reference this role in API operations. @@ -12,8 +9,6 @@ properties: example: role_1234567890abcdef name: type: string - minLength: 1 - maxLength: 512 description: | The human-readable name for this role that describes its function. Should be descriptive enough for administrators to understand what access this role provides. @@ -22,23 +17,12 @@ properties: example: "support.readonly" description: type: string - maxLength: 2048 description: | Optional detailed explanation of what this role encompasses and what access it provides. Helps team members understand the role's scope, intended use cases, and security implications. Include information about what types of users should receive this role and what they can accomplish. Not visible to end users - this is for internal documentation and access control audits. example: "Provides read-only access for customer support representatives to view user accounts and support tickets" - createdAt: - type: integer - format: int64 - minimum: 0 - maximum: 9223372036854775807 # Max int64 value for future-proofing - description: | - Unix timestamp in milliseconds indicating when this role was first created. - Useful for auditing and understanding the evolution of your access control structure. - Automatically set by the system and cannot be modified. - example: 1701425400000 permissions: type: array items: @@ -54,5 +38,4 @@ required: - id - name - permissions - - createdAt additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml index 6c0c1aca11..69376d4153 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesResponseData.yaml @@ -13,30 +13,4 @@ description: |- - This only shows direct role assignments, not inherited or nested roles - Role permissions are not expanded in this response - use keys.getKey for full details items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the role (begins with `role_`). - This ID can be used in other API calls to reference this specific role. - Role IDs are immutable and guaranteed to be unique within your Unkey - workspace, making them reliable reference points for integration and - automation systems. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the role. This is a human-readable identifier - that's unique within your workspace. Role names help identify what access - level or function a role provides. Common patterns include naming by - access level (`admin`, `editor`, `viewer`), by department (`billing_manager`, - `support_agent`), or by feature area (`analytics_user`, `dashboard_admin`). - example: admin - description: - type: string - description: - A brief description of the role's purpose and responsibilities. - This helps users understand the role's scope and expected behavior. - example: Full access to all resources and features + "$ref": "../../../../common/role.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesResponseData.yaml index 6bfaf9e940..71f11c2aa6 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesResponseData.yaml @@ -14,18 +14,4 @@ description: |- - Role permissions are not expanded in this response - use keys.getKey for full details - Changes take effect immediately for new verifications but cached sessions may retain old permissions briefly items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the role (begins with `role_`). - This ID can be used in other API calls to reference this specific role. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the role. This is a human-readable identifier - that's unique within your workspace. - example: admin + "$ref": "../../../../common/role.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml index c9951ee0fc..27454ac54f 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesResponseData.yaml @@ -14,30 +14,4 @@ description: |- - Role permissions are not expanded in this response - use keys.getKey for complete details - An empty array indicates the key now has no roles assigned at all items: - type: object - required: - - id - - name - properties: - id: - type: string - description: The unique identifier of the role (begins with `role_`). - This ID can be used in other API calls to reference this specific role. - Role IDs are immutable and guaranteed to be unique, making them reliable - reference points for integration and automation systems. - example: role_1n9McEIBSqy44Qy7hzWyM5 - name: - type: string - description: The name of the role. This is a human-readable identifier - that's unique within your workspace. Role names are descriptive labels - that help identify what access level or function a role provides. Good - naming practices include naming by access level ('admin', 'editor'), - by department ('billing_team', 'support_staff'), or by feature area - ('reporting_user', 'settings_manager'). - example: admin - description: - type: string - description: - A brief description of the role's purpose and responsibilities. - This helps users understand the role's scope and expected behavior. - example: Full access to all resources and features + "$ref": "../../../../common/role.yaml" diff --git a/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml index c179297b24..265fa4498b 100644 --- a/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml @@ -1,6 +1,6 @@ type: object required: - - roleId + - role properties: role: type: string 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 b5379abd92..1d97c23e9b 100644 --- a/go/apps/api/routes/v2_apis_list_keys/handler.go +++ b/go/apps/api/routes/v2_apis_list_keys/handler.go @@ -186,6 +186,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { fault.Internal("database error"), fault.Public("Failed to retrieve identity information."), ) } + // If identity not found, return empty result return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ diff --git a/go/apps/api/routes/v2_keys_add_roles/404_test.go b/go/apps/api/routes/v2_keys_add_roles/404_test.go index 86576600e0..2259b9f21c 100644 --- a/go/apps/api/routes/v2_keys_add_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/404_test.go @@ -364,12 +364,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyResponse.KeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -419,12 +414,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &deletedRoleID}, - }, + Roles: []string{deletedRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_add_roles/handler.go b/go/apps/api/routes/v2_keys_add_roles/handler.go index d1a3f61c04..097abe2762 100644 --- a/go/apps/api/routes/v2_keys_add_roles/handler.go +++ b/go/apps/api/routes/v2_keys_add_roles/handler.go @@ -2,9 +2,10 @@ package handler import ( "context" + "database/sql" + "encoding/json" "fmt" "net/http" - "slices" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -45,32 +46,23 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.addRoles") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( - rbac.T(rbac.Tuple{ - ResourceType: rbac.Api, - ResourceID: "*", - Action: rbac.UpdateKey, - }), - ))) - if err != nil { - return err - } - - // 4. Validate key exists and belongs to workspace - key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), req.KeyId) + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) if err != nil { if db.IsNotFound(err) { return fault.New("key not found", @@ -92,14 +84,31 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - if key.DeletedAtM.Valid { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key is deleted"), fault.Public("The specified key was not found."), - ) + err = auth.Verify(ctx, keys.WithPermissions( + rbac.And( + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.UpdateKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.UpdateKey, + }), + ), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.AddRoleToKey, + }), + ), + )) + if err != nil { + return err } - // 5. Get current roles for the key currentRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -108,60 +117,33 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 6. Resolve and validate requested roles - requestedRoles := make([]db.Role, 0, len(req.Roles)) - for _, roleRef := range req.Roles { - var role db.Role + foundRoles, err := db.Query.FindManyRolesByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindManyRolesByIdOrNameWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Roles, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to retrieve current roles."), + ) + } - if roleRef.Id != nil { - // Find by ID - role, err = db.Query.FindRoleByID(ctx, h.DB.RO(), *roleRef.Id) - if err != nil { - if db.IsNotFound(err) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role with ID '%s' was not found.", *roleRef.Id)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - } else if roleRef.Name != nil { - // Find by name - role, err = db.Query.FindRoleByNameAndWorkspaceID(ctx, h.DB.RO(), db.FindRoleByNameAndWorkspaceIDParams{ - Name: *roleRef.Name, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if err != nil { - if db.IsNotFound(err) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role with name '%s' was not found.", *roleRef.Name)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - } else { - return fault.New("invalid role reference", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("role missing id and name"), fault.Public("Each role must specify either 'id' or 'name'."), - ) - } + foundMap := make(map[string]db.FindManyRolesByIdOrNameWithPermsRow) + for _, role := range foundRoles { + foundMap[role.ID] = role + foundMap[role.Name] = role + } - // Validate role belongs to the same workspace - if role.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role belongs to different workspace"), fault.Public(fmt.Sprintf("Role '%s' was not found.", role.Name)), - ) + for _, role := range req.Roles { + _, ok := foundMap[role] + if ok { + continue } - requestedRoles = append(requestedRoles, role) + 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.", role)), + ) } // 7. Determine which roles to add (only add roles that aren't already assigned) @@ -170,32 +152,26 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { currentRoleIDs[role.ID] = true } - rolesToAdd := make([]db.Role, 0) - for _, role := range requestedRoles { + rolesToAdd := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) + for _, role := range foundRoles { if !currentRoleIDs[role.ID] { rolesToAdd = append(rolesToAdd, role) } } - // 8. Apply changes in transaction (only if there are roles to add) if len(rolesToAdd) > 0 { err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog - // Add new roles + rolesToInsert := make([]db.InsertKeyRoleParams, 0) + for _, role := range rolesToAdd { - err = db.Query.InsertKeyRole(ctx, tx, db.InsertKeyRoleParams{ + rolesToInsert = append(rolesToInsert, db.InsertKeyRoleParams{ KeyID: req.KeyId, RoleID: role.ID, WorkspaceID: auth.AuthorizedWorkspaceID, CreatedAtM: time.Now().UnixMilli(), }) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to add role assignment."), - ) - } auditLogs = append(auditLogs, auditlog.AuditLog{ WorkspaceID: auth.AuthorizedWorkspaceID, @@ -226,12 +202,18 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - // Insert audit logs - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = db.BulkQuery.InsertKeyRoles(ctx, tx, 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."), + ) + } + + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err } return nil @@ -243,38 +225,54 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) } - // 9. Get final state of roles and build response - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RW(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final role state."), - ) + responseData := make(openapi.V2KeysAddRolesResponseData, 0) + // Wrap row so we don't have to do the same logic twice. + for _, role := range rolesToAdd { + row := db.ListRolesByKeyIDRow{ + ID: role.ID, + WorkspaceID: role.WorkspaceID, + Name: role.Name, + Description: role.Description, + CreatedAtM: role.CreatedAtM, + UpdatedAtM: role.UpdatedAtM, + Permissions: role.Permissions, + } + + currentRoles = append(currentRoles, row) } - // Sort roles alphabetically by name for consistent response - slices.SortFunc(finalRoles, func(a, b db.Role) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 + for _, role := range currentRoles { + r := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, } - return 0 - }) - // Build response data - responseData := make(openapi.V2KeysAddRolesResponseData, len(finalRoles)) - for i, role := range finalRoles { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - }{ - Id: role.ID, - Name: role.Name, + if role.Description.Valid { + r.Description = &role.Description.String + } + + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) + + for _, permission := range rolePermissions { + perm := openapi.Permission{ + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + Description: nil, + } + + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + r.Permissions = append(r.Permissions, perm) } + + responseData = append(responseData, r) } - // 10. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_keys_remove_roles/200_test.go b/go/apps/api/routes/v2_keys_remove_roles/200_test.go index 83a6e2dc5c..e177a9cd9f 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/200_test.go @@ -101,12 +101,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &role1ID}, - }, + Roles: []string{role1ID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -190,12 +185,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &roleName}, - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -251,12 +241,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_remove_roles/400_test.go b/go/apps/api/routes/v2_keys_remove_roles/400_test.go index ab28560a4e..79fc79f9a9 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/400_test.go @@ -83,12 +83,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: "", // Empty keyId string - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -116,12 +111,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: "invalid_key_format", // Invalid format (doesn't start with key_) - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -192,10 +182,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{}, // Empty roles array + Roles: []string{}, // Empty roles array } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -210,47 +197,6 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "POST request body for '/v2/keys.removeRoles' failed to validate schema") }) - t.Run("role missing both id and name", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - req := handler.Request{ - KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {}, // Role with neither id nor name - }, - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "id") - require.Contains(t, res.Body.Error.Detail, "name") - }) - t.Run("role not found by ID", func(t *testing.T) { // Create API and key using testutil helpers defaultPrefix := "test" @@ -273,12 +219,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &nonExistentRoleID}, - }, + Roles: []string{nonExistentRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -315,12 +256,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &nonExistentRoleName}, - }, + Roles: []string{nonExistentRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -350,12 +286,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: nonExistentKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -401,12 +332,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -454,12 +380,7 @@ func TestValidationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &roleName}, - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_remove_roles/401_test.go b/go/apps/api/routes/v2_keys_remove_roles/401_test.go index 6f07292308..6f1dec910d 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/401_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/401_test.go @@ -63,12 +63,7 @@ func TestAuthenticationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } // Request without authorization header @@ -118,12 +113,7 @@ func TestAuthenticationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } // Request with invalid bearer token @@ -174,12 +164,7 @@ func TestAuthenticationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } // Request with malformed authorization header (missing Bearer prefix) @@ -230,12 +215,7 @@ func TestAuthenticationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } // Request with empty bearer token @@ -286,12 +266,7 @@ func TestAuthenticationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } // Request with properly formatted but non-existent root key @@ -346,12 +321,7 @@ func TestAuthenticationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } // Request with root key from different workspace diff --git a/go/apps/api/routes/v2_keys_remove_roles/403_test.go b/go/apps/api/routes/v2_keys_remove_roles/403_test.go index 7261e9b957..a9015f623d 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/403_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/403_test.go @@ -66,12 +66,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ @@ -124,12 +119,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ @@ -182,12 +172,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ @@ -240,12 +225,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ @@ -298,12 +278,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ @@ -356,12 +331,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ @@ -415,12 +385,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_remove_roles/404_test.go b/go/apps/api/routes/v2_keys_remove_roles/404_test.go index 151eb90677..3bbfd39918 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/404_test.go @@ -56,12 +56,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: nonExistentKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -100,12 +95,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &nonExistentRoleID}, - }, + Roles: []string{nonExistentRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -145,12 +135,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &nonExistentRoleName}, - }, + Roles: []string{nonExistentRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -200,12 +185,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -255,12 +235,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleInDifferentWorkspace}, - }, + Roles: []string{roleInDifferentWorkspace}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -310,12 +285,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &roleName}, - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -364,13 +334,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, // This one exists - {Id: &nonExistentRoleID}, // This one doesn't exist - }, + Roles: []string{validRoleID, nonExistentRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -387,61 +351,4 @@ func TestNotFoundErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "not found") require.Contains(t, res.Body.Error.Detail, nonExistentRoleID) }) - - t.Run("mixed valid and invalid role references", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create one valid role - validRoleID := uid.New(uid.TestPrefix) - err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: validRoleID, - WorkspaceID: workspace.ID, - Name: "valid_role_mixed_" + uid.New(""), - Description: sql.NullString{Valid: true, String: "Valid role"}, - }) - require.NoError(t, err) - - // Use a non-existent role name - nonExistentRoleName := "non_existent_role" - - req := handler.Request{ - KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, // This one exists - {Name: &nonExistentRoleName}, // This one doesn't exist - }, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - // Should fail when ANY role is not found - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.Contains(t, res.Body.Error.Detail, "Role") - require.Contains(t, res.Body.Error.Detail, "not found") - require.Contains(t, res.Body.Error.Detail, nonExistentRoleName) - }) } diff --git a/go/apps/api/routes/v2_keys_remove_roles/handler.go b/go/apps/api/routes/v2_keys_remove_roles/handler.go index fc193f90bd..4212d2e613 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/handler.go +++ b/go/apps/api/routes/v2_keys_remove_roles/handler.go @@ -2,9 +2,9 @@ package handler import ( "context" + "database/sql" "fmt" "net/http" - "slices" "github.com/unkeyed/unkey/go/apps/api/openapi" "github.com/unkeyed/unkey/go/internal/services/auditlogs" @@ -45,20 +45,23 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.removeRoles") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Validate key exists and belongs to workspace - key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), req.KeyId) + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) if err != nil { if db.IsNotFound(err) { return fault.New("key not found", @@ -72,7 +75,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // TODO: Get api id + if key.WorkspaceID != auth.AuthorizedWorkspaceID { + return fault.New("key not found", + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key belongs to different workspace"), fault.Public("The specified key was not found."), + ) + } + err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Api, @@ -81,7 +90,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }), rbac.T(rbac.Tuple{ ResourceType: rbac.Api, - ResourceID: key.KeyAuthID, + ResourceID: key.Api.ID, Action: rbac.UpdateKey, }), ))) @@ -89,15 +98,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // Validate key belongs to authorized workspace - if key.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("key not found", - fault.Code(codes.Data.Key.NotFound.URN()), - fault.Internal("key belongs to different workspace"), fault.Public("The specified key was not found."), - ) - } - - // 5. Get current roles for the key currentRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -106,94 +106,54 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Convert current roles to a map for efficient lookup - currentRoleIDs := make(map[string]db.Role) + currentRoleIDs := make(map[string]db.ListRolesByKeyIDRow) for _, role := range currentRoles { currentRoleIDs[role.ID] = role } - // 6. Resolve and validate requested roles to remove - requestedRoles := make([]db.Role, 0, len(req.Roles)) - for _, roleRef := range req.Roles { - var role db.Role + foundRoles, err := db.Query.FindManyRolesByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindManyRolesByIdOrNameWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Roles, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to retrieve current roles."), + ) + } - if roleRef.Id != nil { - // Find by ID - role, err = db.Query.FindRoleByID(ctx, h.DB.RO(), *roleRef.Id) - if err != nil { - if db.IsNotFound(err) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role with ID '%s' was not found.", *roleRef.Id)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - } else if roleRef.Name != nil { - // Find by name - role, err = db.Query.FindRoleByNameAndWorkspaceID(ctx, h.DB.RO(), db.FindRoleByNameAndWorkspaceIDParams{ - Name: *roleRef.Name, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if err != nil { - if db.IsNotFound(err) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role with name '%s' was not found.", *roleRef.Name)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - } else { - return fault.New("invalid role reference", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("role missing id and name"), fault.Public("Each role must specify either 'id' or 'name'."), - ) - } + foundMap := make(map[string]struct{}) + for _, role := range foundRoles { + foundMap[role.ID] = struct{}{} + foundMap[role.Name] = struct{}{} + + delete(currentRoleIDs, role.ID) + } - // Validate role belongs to the same workspace - if role.WorkspaceID != auth.AuthorizedWorkspaceID { + for _, role := range req.Roles { + _, exists := foundMap[role] + if !exists { return fault.New("role not found", fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role belongs to different workspace"), fault.Public(fmt.Sprintf("Role '%s' was not found.", role.Name)), + fault.Public(fmt.Sprintf("Role %q was not found.", role)), ) } - - requestedRoles = append(requestedRoles, role) } - // 7. Determine which roles to remove (only remove roles that are currently assigned) - rolesToRemove := make([]db.Role, 0) - for _, role := range requestedRoles { + rolesToRemove := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) + for _, role := range foundRoles { if _, exists := currentRoleIDs[role.ID]; exists { rolesToRemove = append(rolesToRemove, role) } } - // 8. Apply changes in transaction (only if there are roles to remove) if len(rolesToRemove) > 0 { err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog + var roleIds []string - // Remove roles for _, role := range rolesToRemove { - err = db.Query.DeleteManyKeyRolesByKeyID(ctx, tx, db.DeleteManyKeyRolesByKeyIDParams{ - KeyID: req.KeyId, - RoleID: role.ID, - }) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to remove role assignment."), - ) - } - + roleIds = append(roleIds, role.ID) auditLogs = append(auditLogs, auditlog.AuditLog{ WorkspaceID: auth.AuthorizedWorkspaceID, Event: auditlog.AuthDisconnectRoleKeyEvent, @@ -223,12 +183,21 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }) } - // Insert audit logs - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = db.Query.DeleteManyKeyRolesByKeyAndRoleIDs(ctx, tx, db.DeleteManyKeyRolesByKeyAndRoleIDsParams{ + KeyID: req.KeyId, + Ids: roleIds, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to remove role assignment."), + ) + } + + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err } return nil @@ -240,36 +209,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) } - // 9. Get final state of roles and build response - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RW(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final role state."), - ) - } - - // Sort roles alphabetically by name for consistent response - slices.SortFunc(finalRoles, func(a, b db.Role) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 - } - return 0 - }) - - // Build response data - responseData := make(openapi.V2KeysRemoveRolesResponseData, len(finalRoles)) - for i, role := range finalRoles { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - }{ - Id: role.ID, - Name: role.Name, - } - } + responseData := make(openapi.V2KeysRemoveRolesResponseData, 0) // 10. Return success response return s.JSON(http.StatusOK, Response{ diff --git a/go/apps/api/routes/v2_keys_set_roles/200_test.go b/go/apps/api/routes/v2_keys_set_roles/200_test.go index 86e852d670..0932fb32b7 100644 --- a/go/apps/api/routes/v2_keys_set_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/200_test.go @@ -69,12 +69,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -144,12 +139,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &roleName}, - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -222,14 +212,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &adminRoleID}, // By ID - {Name: &editorRoleName}, // By name - {Id: &viewerRoleID}, // By ID - }, + Roles: []string{adminRoleID, editorRoleName, viewerRoleID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -323,12 +306,7 @@ func TestSuccess(t *testing.T) { // Now set the key to have only the new role (should remove old, add new) req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &newRoleID}, // Replace old with new - }, + Roles: []string{newRoleID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -416,10 +394,7 @@ func TestSuccess(t *testing.T) { // Set roles to empty array req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{}, // Empty roles array - remove all + Roles: []string{}, // Empty roles array - remove all } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -499,12 +474,7 @@ func TestSuccess(t *testing.T) { // Set roles to the same role (no change) req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, // Same role as already assigned - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -576,15 +546,7 @@ func TestSuccess(t *testing.T) { // ID should take precedence req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - { - Id: &role1ID, - Name: &role2Name, // This should be ignored, ID takes precedence - }, - }, + Roles: []string{role1ID, role2Name}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_set_roles/400_test.go b/go/apps/api/routes/v2_keys_set_roles/400_test.go index 09c33bfa3a..30d15700ba 100644 --- a/go/apps/api/routes/v2_keys_set_roles/400_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/400_test.go @@ -9,7 +9,6 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_roles" "github.com/unkeyed/unkey/go/pkg/testutil" - "github.com/unkeyed/unkey/go/pkg/testutil/seed" ) func TestValidationErrors(t *testing.T) { @@ -35,23 +34,6 @@ func TestValidationErrors(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - // Create a test API and key for valid requests using testutil helper - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - validKeyID := keyResponse.KeyID - // Test case for missing keyId t.Run("missing keyId", func(t *testing.T) { req := map[string]interface{}{ @@ -114,31 +96,6 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - // Test case for role with neither id nor name - t.Run("role with neither id nor name", func(t *testing.T) { - req := handler.Request{ - KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {}, // empty role reference - }, - } - - res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 400, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "must specify either 'id' or 'name'") - }) - // Test case for malformed JSON body t.Run("malformed JSON body", func(t *testing.T) { req := map[string]interface{}{ diff --git a/go/apps/api/routes/v2_keys_set_roles/401_test.go b/go/apps/api/routes/v2_keys_set_roles/401_test.go index ceeee1873e..1d714a7255 100644 --- a/go/apps/api/routes/v2_keys_set_roles/401_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/401_test.go @@ -26,12 +26,7 @@ func TestAuthenticationErrors(t *testing.T) { // Create a valid request req := handler.Request{ KeyId: "key_123", - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: func() *string { s := "role_123"; return &s }()}, - }, + Roles: []string{"role_123"}, } // Test case for missing authorization header diff --git a/go/apps/api/routes/v2_keys_set_roles/403_test.go b/go/apps/api/routes/v2_keys_set_roles/403_test.go index 88db520adf..1c19a2eb3f 100644 --- a/go/apps/api/routes/v2_keys_set_roles/403_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/403_test.go @@ -66,12 +66,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( @@ -99,12 +94,7 @@ func TestAuthorizationErrors(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &roleID}, - }, + Roles: []string{roleID}, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_set_roles/404_test.go b/go/apps/api/routes/v2_keys_set_roles/404_test.go index d90d2ab9e5..bd237c8311 100644 --- a/go/apps/api/routes/v2_keys_set_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/404_test.go @@ -78,12 +78,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: nonExistentKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, - }, + Roles: []string{validRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -107,12 +102,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &nonExistentRoleID}, - }, + Roles: []string{nonExistentRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -136,12 +126,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &nonExistentRoleName}, - }, + Roles: []string{nonExistentRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -203,13 +188,8 @@ func TestNotFoundErrors(t *testing.T) { require.NoError(t, err) req := handler.Request{ - KeyId: otherKeyID, // Key exists but in different workspace - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, - }, + KeyId: otherKeyID, + Roles: []string{validRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -254,12 +234,7 @@ func TestNotFoundErrors(t *testing.T) { t.Run("by role ID", func(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &otherRoleID}, // Role exists but in different workspace - }, + Roles: []string{otherRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -281,12 +256,7 @@ func TestNotFoundErrors(t *testing.T) { t.Run("by role name", func(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Name: &otherRoleName}, // Role exists but in different workspace - }, + Roles: []string{otherRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -311,13 +281,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &nonExistentRoleID}, // This should fail first - {Id: &validRoleID}, // This is valid but won't be reached - }, + Roles: []string{nonExistentRoleID, validRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -342,12 +306,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: validFormattedKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, - }, + Roles: []string{validRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -372,12 +331,7 @@ func TestNotFoundErrors(t *testing.T) { req := handler.Request{ KeyId: validKeyID, - Roles: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validFormattedRoleID}, - }, + Roles: []string{validFormattedRoleID}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_keys_set_roles/handler.go b/go/apps/api/routes/v2_keys_set_roles/handler.go index 3de69ca5d9..fae835e136 100644 --- a/go/apps/api/routes/v2_keys_set_roles/handler.go +++ b/go/apps/api/routes/v2_keys_set_roles/handler.go @@ -2,9 +2,10 @@ package handler import ( "context" + "database/sql" + "encoding/json" "fmt" "net/http" - "slices" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -46,32 +47,23 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.setRoles") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check - err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( - rbac.T(rbac.Tuple{ - ResourceType: rbac.Api, - ResourceID: "*", - Action: rbac.UpdateKey, - }), - ))) - if err != nil { - return err - } - - // 4. Validate key exists and belongs to workspace - key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), req.KeyId) + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) if err != nil { if db.IsNotFound(err) { return fault.New("key not found", @@ -93,7 +85,22 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Get current roles for the key + err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.UpdateKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.UpdateKey, + }), + ))) + if err != nil { + return err + } + currentRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -102,196 +109,158 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 6. Resolve and validate requested roles - requestedRoles := make([]db.Role, 0, len(req.Roles)) - for _, roleRef := range req.Roles { - var role db.Role + currentRoleIDs := make(map[string]bool) + for _, role := range currentRoles { + currentRoleIDs[role.ID] = true + } - if roleRef.Id != nil { - // Find by ID - role, err = db.Query.FindRoleByID(ctx, h.DB.RO(), *roleRef.Id) - if err != nil { - if db.IsNotFound(err) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role with ID '%s' was not found.", *roleRef.Id)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - } else if roleRef.Name != nil { - // Find by name - role, err = db.Query.FindRoleByNameAndWorkspaceID(ctx, h.DB.RO(), db.FindRoleByNameAndWorkspaceIDParams{ - Name: *roleRef.Name, - WorkspaceID: auth.AuthorizedWorkspaceID, - }) - if err != nil { - if db.IsNotFound(err) { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public(fmt.Sprintf("Role with name '%s' was not found.", *roleRef.Name)), - ) - } - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role."), - ) - } - } else { - return fault.New("invalid role reference", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Internal("role missing id and name"), fault.Public("Each role must specify either 'id' or 'name'."), - ) - } + foundRoles, err := db.Query.FindManyRolesByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindManyRolesByIdOrNameWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Roles, + }) - // Validate role belongs to the same workspace - if role.WorkspaceID != auth.AuthorizedWorkspaceID { + foundMap := make(map[string]struct{}) + for _, role := range foundRoles { + foundMap[role.ID] = struct{}{} + foundMap[role.Name] = struct{}{} + } + + for _, role := range req.Roles { + _, exists := foundMap[role] + if !exists { return fault.New("role not found", fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role belongs to different workspace"), fault.Public(fmt.Sprintf("Role '%s' was not found.", role.Name)), + fault.Public(fmt.Sprintf("Role %q was not found.", role)), ) } - - requestedRoles = append(requestedRoles, role) - } - - // 7. Calculate differential update - // Create maps for efficient lookup - currentRoleIDs := make(map[string]bool) - for _, role := range currentRoles { - currentRoleIDs[role.ID] = true } requestedRoleIDs := make(map[string]bool) - requestedRoleMap := make(map[string]db.Role) - for _, role := range requestedRoles { + requestedRoleMap := make(map[string]db.FindManyRolesByIdOrNameWithPermsRow) + for _, role := range foundRoles { requestedRoleIDs[role.ID] = true requestedRoleMap[role.ID] = role } // Determine roles to remove and add - rolesToRemove := make([]string, 0) + rolesToRemove := make([]db.ListRolesByKeyIDRow, 0) for _, role := range currentRoles { if !requestedRoleIDs[role.ID] { - rolesToRemove = append(rolesToRemove, role.ID) + rolesToRemove = append(rolesToRemove, role) } } - rolesToAdd := make([]db.Role, 0) - for _, role := range requestedRoles { + rolesToAdd := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) + for _, role := range foundRoles { if !currentRoleIDs[role.ID] { rolesToAdd = append(rolesToAdd, role) } } - // 8. Apply changes in transaction err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog - // Remove roles that are no longer needed - for _, roleID := range rolesToRemove { - err = db.Query.DeleteManyKeyRolesByKeyID(ctx, tx, db.DeleteManyKeyRolesByKeyIDParams{ - KeyID: req.KeyId, - RoleID: roleID, + if len(rolesToRemove) > 0 { + var roleIds []string + + for _, role := range rolesToRemove { + roleIds = append(roleIds, role.ID) + + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.AuthDisconnectRoleKeyEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Removed role %s from key %s", role.Name, req.KeyId), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.KeyResourceType, + ID: req.KeyId, + Name: key.Name.String, + DisplayName: key.Name.String, + Meta: map[string]any{}, + }, + { + Type: auditlog.RoleResourceType, + ID: role.ID, + Name: role.Name, + DisplayName: role.Name, + Meta: map[string]any{}, + }, + }, + }) + } + + err = db.Query.DeleteManyKeyRolesByKeyAndRoleIDs(ctx, tx, db.DeleteManyKeyRolesByKeyAndRoleIDsParams{ + KeyID: req.KeyId, + Ids: roleIds, }) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to remove role assignment."), + fault.Internal("database error"), + fault.Public("Failed to remove role assignment."), ) } + } - // Find the role for audit log - var removedRole db.Role - for _, role := range currentRoles { - if role.ID == roleID { - removedRole = role - break - } - } - - auditLogs = append(auditLogs, auditlog.AuditLog{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Event: auditlog.AuthDisconnectRoleKeyEvent, - ActorType: auditlog.RootKeyActor, - ActorID: auth.Key.ID, - ActorName: "root key", - ActorMeta: map[string]any{}, - Display: fmt.Sprintf("Removed role %s from key %s", removedRole.Name, req.KeyId), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: auditlog.KeyResourceType, - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: auditlog.RoleResourceType, - ID: removedRole.ID, - Name: removedRole.Name, - DisplayName: removedRole.Name, - Meta: map[string]any{}, + if len(rolesToAdd) > 0 { + var keyRolesToInsert []db.InsertKeyRoleParams + + for _, role := range rolesToAdd { + keyRolesToInsert = append(keyRolesToInsert, db.InsertKeyRoleParams{ + KeyID: req.KeyId, + RoleID: role.ID, + WorkspaceID: auth.AuthorizedWorkspaceID, + CreatedAtM: time.Now().UnixMilli(), + }) + + 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("Added role %s to key %s", role.Name, req.KeyId), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.KeyResourceType, + ID: req.KeyId, + Name: key.Name.String, + DisplayName: key.Name.String, + Meta: map[string]any{}, + }, + { + Type: auditlog.RoleResourceType, + ID: role.ID, + Name: role.Name, + DisplayName: role.Name, + Meta: map[string]any{}, + }, }, - }, - }) - } + }) + } - // Add new roles - for _, role := range rolesToAdd { - err = db.Query.InsertKeyRole(ctx, tx, db.InsertKeyRoleParams{ - KeyID: req.KeyId, - RoleID: role.ID, - WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAtM: time.Now().UnixMilli(), - }) + err = db.BulkQuery.InsertKeyRoles(ctx, tx, keyRolesToInsert) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to add role assignment."), + fault.Internal("database error"), + fault.Public("Failed to add role assignment."), ) } - - 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("Added role %s to key %s", role.Name, req.KeyId), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: auditlog.KeyResourceType, - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: auditlog.RoleResourceType, - ID: role.ID, - Name: role.Name, - DisplayName: role.Name, - Meta: map[string]any{}, - }, - }, - }) } - // Insert audit logs if there are changes - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err } return nil @@ -302,38 +271,40 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) - // 10. Get final state of roles and build response - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final role state."), - ) - } + responseData := make(openapi.V2KeysSetRolesResponseData, 0) + for _, role := range foundRoles { + r := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, + Permissions: nil, + } - // Sort roles alphabetically by name for consistent response - slices.SortFunc(finalRoles, func(a, b db.Role) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 + if role.Description.Valid { + r.Description = &role.Description.String } - return 0 - }) - // Build response data - responseData := make(openapi.V2KeysSetRolesResponseData, len(finalRoles)) - for i, role := range finalRoles { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - }{ - Id: role.ID, - Name: role.Name, + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) + + for _, permission := range rolePermissions { + perm := openapi.Permission{ + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + Description: nil, + } + + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + r.Permissions = append(r.Permissions, perm) } + + responseData = append(responseData, r) } - // 11. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_permissions_delete_role/200_test.go b/go/apps/api/routes/v2_permissions_delete_role/200_test.go index b19b8e43dc..e83ec8b363 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/200_test.go +++ b/go/apps/api/routes/v2_permissions_delete_role/200_test.go @@ -108,7 +108,7 @@ func TestSuccess(t *testing.T) { // Delete the role req := handler.Request{ - RoleId: roleID, + Role: roleID, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -167,7 +167,7 @@ func TestSuccess(t *testing.T) { // Delete the role req := handler.Request{ - RoleId: roleID, + Role: roleID, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_permissions_delete_role/400_test.go b/go/apps/api/routes/v2_permissions_delete_role/400_test.go index 4a577c8bd0..b485d38656 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/400_test.go +++ b/go/apps/api/routes/v2_permissions_delete_role/400_test.go @@ -38,7 +38,7 @@ func TestValidationErrors(t *testing.T) { // Test case for missing required roleId t.Run("missing roleId", func(t *testing.T) { req := handler.Request{ - // RoleId is missing + // Role is missing } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -54,10 +54,10 @@ func TestValidationErrors(t *testing.T) { require.Contains(t, res.Body.Error.Detail, "validate schema") }) - // Test case for empty roleId - t.Run("empty roleId", func(t *testing.T) { + // Test case for empty role + t.Run("empty role", func(t *testing.T) { req := handler.Request{ - RoleId: "", // Empty string is invalid + Role: "", // Empty string is invalid } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -91,24 +91,4 @@ func TestValidationErrors(t *testing.T) { require.NotNil(t, res.Body.Error) require.Contains(t, res.Body.Error.Detail, "validate schema") }) - - // Test case for invalid roleId format - t.Run("invalid roleId format", func(t *testing.T) { - req := handler.Request{ - RoleId: "not_a_valid_role_id_format", - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "does not exist") - // Note: The handler does database lookup first, so invalid formats return 404, not 400 - }) } diff --git a/go/apps/api/routes/v2_permissions_delete_role/401_test.go b/go/apps/api/routes/v2_permissions_delete_role/401_test.go index 97b83fd8ed..28607c766b 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/401_test.go +++ b/go/apps/api/routes/v2_permissions_delete_role/401_test.go @@ -24,7 +24,7 @@ func TestAuthenticationErrors(t *testing.T) { // Create a valid request req := handler.Request{ - RoleId: "role_test123", + Role: "role_test123", } // Test case for missing authorization header diff --git a/go/apps/api/routes/v2_permissions_delete_role/403_test.go b/go/apps/api/routes/v2_permissions_delete_role/403_test.go index 732bb1fc68..a813c10d04 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/403_test.go +++ b/go/apps/api/routes/v2_permissions_delete_role/403_test.go @@ -59,7 +59,7 @@ func TestPermissionErrors(t *testing.T) { route, headers, handler.Request{ - RoleId: roleID, + Role: roleID, }, ) @@ -83,7 +83,7 @@ func TestPermissionErrors(t *testing.T) { route, headers, handler.Request{ - RoleId: roleID, + Role: roleID, }, ) diff --git a/go/apps/api/routes/v2_permissions_delete_role/404_test.go b/go/apps/api/routes/v2_permissions_delete_role/404_test.go index 7e1cb182d2..5a16bf3321 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/404_test.go +++ b/go/apps/api/routes/v2_permissions_delete_role/404_test.go @@ -45,7 +45,7 @@ func TestNotFound(t *testing.T) { nonexistentRoleID := uid.New(uid.TestPrefix) req := handler.Request{ - RoleId: nonexistentRoleID, + Role: nonexistentRoleID, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -80,7 +80,7 @@ func TestNotFound(t *testing.T) { // Try to delete the role from the first workspace req := handler.Request{ - RoleId: roleID, + Role: roleID, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_delete_role/handler.go b/go/apps/api/routes/v2_permissions_delete_role/handler.go index 22dbd40747..954b5be8d1 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/handler.go +++ b/go/apps/api/routes/v2_permissions_delete_role/handler.go @@ -42,19 +42,16 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/permissions.deleteRole") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -66,8 +63,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Get role by ID to verify existence and workspace ownership - role, err := db.Query.FindRoleByID(ctx, h.DB.RO(), req.RoleId) + role, err := db.Query.FindRoleByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindRoleByIdOrNameWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Role, + }) if err != nil { if db.IsNotFound(err) { return fault.New("role not found", @@ -81,18 +80,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Check if role belongs to authorized workspace - if role.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public("The requested role does not exist."), - ) - } - - // 6. Delete the role in a transaction err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - // Delete role-permission relationships - err = db.Query.DeleteManyRolePermissionsByRoleID(ctx, tx, req.RoleId) + err = db.Query.DeleteManyRolePermissionsByRoleID(ctx, tx, role.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -100,8 +89,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Delete key-role relationships - err = db.Query.DeleteManyKeyRolesByRoleID(ctx, tx, req.RoleId) + err = db.Query.DeleteManyKeyRolesByRoleID(ctx, tx, role.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -109,8 +97,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Delete the role itself - err = db.Query.DeleteRoleByID(ctx, tx, req.RoleId) + err = db.Query.DeleteRoleByID(ctx, tx, role.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -118,7 +105,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Create audit log for role deletion err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, @@ -127,13 +113,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ActorID: auth.Key.ID, ActorName: "root key", ActorMeta: map[string]any{}, - Display: "Deleted " + req.RoleId, + Display: "Deleted " + role.ID, RemoteIP: s.Location(), UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { Type: auditlog.RoleResourceType, - ID: req.RoleId, + ID: role.ID, Name: role.Name, DisplayName: role.Name, Meta: map[string]interface{}{ diff --git a/go/apps/api/routes/v2_permissions_get_role/200_test.go b/go/apps/api/routes/v2_permissions_get_role/200_test.go index 46d29154d2..c9da007cd3 100644 --- a/go/apps/api/routes/v2_permissions_get_role/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/200_test.go @@ -80,7 +80,7 @@ func TestSuccess(t *testing.T) { // Now retrieve the role req := handler.Request{ - RoleId: roleID, + Role: roleID, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -102,7 +102,6 @@ func TestSuccess(t *testing.T) { require.Equal(t, roleName, role.Name) require.NotNil(t, role.Description) require.Equal(t, roleDesc, *role.Description) - require.NotEmpty(t, role.CreatedAt) // Verify permissions require.NotNil(t, role.Permissions) @@ -137,7 +136,7 @@ func TestSuccess(t *testing.T) { // Now retrieve the role req := handler.Request{ - RoleId: roleID, + Role: roleName, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_permissions_get_role/400_test.go b/go/apps/api/routes/v2_permissions_get_role/400_test.go index 7e6ccf314c..e8878fa788 100644 --- a/go/apps/api/routes/v2_permissions_get_role/400_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/400_test.go @@ -56,7 +56,7 @@ func TestValidationErrors(t *testing.T) { // Test case for empty roleId t.Run("empty roleId", func(t *testing.T) { req := handler.Request{ - RoleId: "", // Empty string is invalid + Role: "", // Empty string is invalid } res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse]( @@ -90,24 +90,4 @@ func TestValidationErrors(t *testing.T) { require.NotNil(t, res.Body.Error) require.Contains(t, res.Body.Error.Detail, "validate schema") }) - - // Test case for invalid roleId format - t.Run("invalid roleId format", func(t *testing.T) { - req := handler.Request{ - RoleId: "not_a_valid_role_id_format", - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "does not exist") - // Note: The handler does database lookup first, so invalid formats return 404, not 400 - }) } diff --git a/go/apps/api/routes/v2_permissions_get_role/401_test.go b/go/apps/api/routes/v2_permissions_get_role/401_test.go index f9b81befae..af1ea2ca13 100644 --- a/go/apps/api/routes/v2_permissions_get_role/401_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/401_test.go @@ -23,7 +23,7 @@ func TestAuthenticationErrors(t *testing.T) { // Create a valid request req := handler.Request{ - RoleId: "role_test123", + Role: "role_test123", } // Test case for missing authorization header diff --git a/go/apps/api/routes/v2_permissions_get_role/403_test.go b/go/apps/api/routes/v2_permissions_get_role/403_test.go index fdba133c80..759b28f7c1 100644 --- a/go/apps/api/routes/v2_permissions_get_role/403_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/403_test.go @@ -53,7 +53,7 @@ func TestPermissionErrors(t *testing.T) { } req := handler.Request{ - RoleId: roleID, + Role: roleID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( @@ -80,7 +80,7 @@ func TestPermissionErrors(t *testing.T) { } req := handler.Request{ - RoleId: roleID, + Role: roleID, } res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_get_role/404_test.go b/go/apps/api/routes/v2_permissions_get_role/404_test.go index b74a8be2bf..f36615372c 100644 --- a/go/apps/api/routes/v2_permissions_get_role/404_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/404_test.go @@ -9,7 +9,6 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_get_role" "github.com/unkeyed/unkey/go/pkg/testutil" - "github.com/unkeyed/unkey/go/pkg/uid" ) func TestNotFoundErrors(t *testing.T) { @@ -38,28 +37,7 @@ func TestNotFoundErrors(t *testing.T) { // Test case for non-existent role ID t.Run("non-existent role ID", func(t *testing.T) { req := handler.Request{ - RoleId: "role_does_not_exist", - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( - h, - route, - headers, - req, - ) - - require.Equal(t, 404, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "does not exist") - }) - - // Test case for valid-looking but non-existent role ID - t.Run("valid-looking but non-existent role ID", func(t *testing.T) { - nonExistentID := uid.New(uid.TestPrefix) // Generate a valid ID format that doesn't exist - - req := handler.Request{ - RoleId: nonExistentID, + Role: "role_does_not_exist", } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( diff --git a/go/apps/api/routes/v2_permissions_get_role/handler.go b/go/apps/api/routes/v2_permissions_get_role/handler.go index ec04653147..8178ce3375 100644 --- a/go/apps/api/routes/v2_permissions_get_role/handler.go +++ b/go/apps/api/routes/v2_permissions_get_role/handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "net/http" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -39,19 +40,16 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/permissions.getRole") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -63,8 +61,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Get role by ID - role, err := db.Query.FindRoleByID(ctx, h.DB.RO(), req.RoleId) + role, err := db.Query.FindRoleByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindRoleByIdOrNameWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Role, + }) if err != nil { if db.IsNotFound(err) { return fault.New("role not found", @@ -78,24 +78,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Check if role belongs to authorized workspace - if role.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("role does not belong to authorized workspace", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Public("The requested role does not exist."), - ) - } - - // 6. Fetch permissions associated with the role - rolePermissions, err := db.Query.ListPermissionsByRoleID(ctx, h.DB.RO(), req.RoleId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role permissions."), - ) - } + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) - // 7. Transform permissions to the response format permissions := make([]openapi.Permission, 0, len(rolePermissions)) for _, perm := range rolePermissions { permission := openapi.Permission{ @@ -113,16 +98,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permissions = append(permissions, permission) } - // 8. Return the role with its permissions roleResponse := openapi.Role{ Id: role.ID, Name: role.Name, - CreatedAt: role.CreatedAtM, Permissions: permissions, Description: nil, } - // Add description only if it's valid if role.Description.Valid { roleResponse.Description = &role.Description.String } diff --git a/go/apps/api/routes/v2_permissions_list_roles/200_test.go b/go/apps/api/routes/v2_permissions_list_roles/200_test.go index 70f4f35b66..0b7d6b1ac0 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/200_test.go +++ b/go/apps/api/routes/v2_permissions_list_roles/200_test.go @@ -129,7 +129,6 @@ func TestSuccess(t *testing.T) { for _, role := range res.Body.Data { roleMap[role.Id] = true require.NotNil(t, role.Description) - require.NotNil(t, role.CreatedAt) // Verify permissions are attached require.NotNil(t, role.Permissions) diff --git a/go/apps/api/routes/v2_permissions_list_roles/handler.go b/go/apps/api/routes/v2_permissions_list_roles/handler.go index bea0064be4..5651b48298 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/handler.go +++ b/go/apps/api/routes/v2_permissions_list_roles/handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "net/http" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -40,22 +41,18 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/permissions.listRoles") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // Handle null cursor - use empty string to start from beginning cursor := ptr.SafeDeref(req.Cursor, "") - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -67,7 +64,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Query roles with pagination roles, err := db.Query.ListRoles( ctx, h.DB.RO(), @@ -83,27 +79,17 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Check if we have more results by seeing if we got 101 roles - hasMore := len(roles) > 100 var nextCursor *string - - // If we have more than 100, truncate to 100 + hasMore := len(roles) > 100 if hasMore { nextCursor = ptr.P(roles[100].ID) roles = roles[:100] } - // 5. Get permissions for each role roleResponses := make([]openapi.Role, 0, len(roles)) for _, role := range roles { - // Get permissions for this role - rolePermissions, err := db.Query.ListPermissionsByRoleID(ctx, h.DB.RO(), role.ID) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve role permissions."), - ) - } + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) // Transform permissions permissions := make([]openapi.Permission, 0, len(rolePermissions)) @@ -115,7 +101,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Description: nil, } - // Add description only if it's valid if perm.Description.Valid { permission.Description = &perm.Description.String } @@ -123,16 +108,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permissions = append(permissions, permission) } - // Transform role roleResponse := openapi.Role{ Id: role.ID, Name: role.Name, Description: nil, - CreatedAt: role.CreatedAtM, Permissions: permissions, } - // Add description only if it's valid if role.Description.Valid { roleResponse.Description = &role.Description.String } @@ -140,7 +122,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { roleResponses = append(roleResponses, roleResponse) } - // 6. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go b/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go new file mode 100644 index 0000000000..77b8a80c90 --- /dev/null +++ b/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go @@ -0,0 +1,41 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: key_role_delete_many_by_key_and_role_ids.sql + +package db + +import ( + "context" + "strings" +) + +const deleteManyKeyRolesByKeyAndRoleIDs = `-- name: DeleteManyKeyRolesByKeyAndRoleIDs :exec +DELETE FROM keys_roles +WHERE key_id = ? AND role_id IN(/*SLICE:ids*/?) +` + +type DeleteManyKeyRolesByKeyAndRoleIDsParams struct { + KeyID string `db:"key_id"` + Ids []string `db:"ids"` +} + +// DeleteManyKeyRolesByKeyAndRoleIDs +// +// DELETE FROM keys_roles +// WHERE key_id = ? AND role_id IN(/*SLICE:ids*/?) +func (q *Queries) DeleteManyKeyRolesByKeyAndRoleIDs(ctx context.Context, db DBTX, arg DeleteManyKeyRolesByKeyAndRoleIDsParams) error { + query := deleteManyKeyRolesByKeyAndRoleIDs + var queryParams []interface{} + queryParams = append(queryParams, arg.KeyID) + if len(arg.Ids) > 0 { + for _, v := range arg.Ids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + } + _, err := db.ExecContext(ctx, query, queryParams...) + return err +} diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index f951692e17..f25c5d2456 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -49,6 +49,11 @@ type Querier interface { // DELETE FROM keys_permissions // WHERE permission_id = ? DeleteManyKeyPermissionsByPermissionID(ctx context.Context, db DBTX, permissionID string) error + //DeleteManyKeyRolesByKeyAndRoleIDs + // + // DELETE FROM keys_roles + // WHERE key_id = ? AND role_id IN(/*SLICE:ids*/?) + DeleteManyKeyRolesByKeyAndRoleIDs(ctx context.Context, db DBTX, arg DeleteManyKeyRolesByKeyAndRoleIDsParams) error //DeleteManyKeyRolesByKeyID // // DELETE FROM keys_roles @@ -309,6 +314,29 @@ type Querier interface { // ORDER BY created_at DESC // LIMIT 1 FindLatestBuildByVersionId(ctx context.Context, db DBTX, versionID string) (Build, error) + //FindManyRolesByIdOrNameWithPerms + // + // SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions + // FROM roles r + // WHERE r.workspace_id = ? AND ( + // r.id IN (/*SLICE:search*/?) + // OR r.name IN (/*SLICE:search*/?) + // ) + FindManyRolesByIdOrNameWithPerms(ctx context.Context, db DBTX, arg FindManyRolesByIdOrNameWithPermsParams) ([]FindManyRolesByIdOrNameWithPermsRow, error) // Finds a permission record by its ID // Returns: The permission record if found // @@ -430,6 +458,29 @@ type Querier interface { // WHERE id = ? // LIMIT 1 FindRoleByID(ctx context.Context, db DBTX, roleID string) (Role, error) + //FindRoleByIdOrNameWithPerms + // + // SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions + // FROM roles r + // WHERE r.workspace_id = ? AND ( + // r.id = ? + // OR r.name = ? + // ) + FindRoleByIdOrNameWithPerms(ctx context.Context, db DBTX, arg FindRoleByIdOrNameWithPermsParams) (FindRoleByIdOrNameWithPermsRow, error) // Finds a role record by its name within a specific workspace // Returns: The role record if found // @@ -1114,21 +1165,49 @@ type Querier interface { ListRatelimitsByKeyIDs(ctx context.Context, db DBTX, keyIds []sql.NullString) ([]ListRatelimitsByKeyIDsRow, error) //ListRoles // - // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m + // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions // FROM roles r // WHERE r.workspace_id = ? - // AND r.id > ? + // AND r.id > ? // ORDER BY r.id // LIMIT 101 - ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ([]Role, error) + ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ([]ListRolesRow, error) //ListRolesByKeyID // - // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m - // FROM roles r - // JOIN keys_roles kr ON r.id = kr.role_id + // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions + // FROM keys_roles kr + // JOIN roles r ON kr.role_id = r.id // WHERE kr.key_id = ? // ORDER BY r.name - ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ([]Role, error) + ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ([]ListRolesByKeyIDRow, error) //ListWorkspaces // // SELECT diff --git a/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql b/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql new file mode 100644 index 0000000000..7768a952b6 --- /dev/null +++ b/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql @@ -0,0 +1,3 @@ +-- name: DeleteManyKeyRolesByKeyAndRoleIDs :exec +DELETE FROM keys_roles +WHERE key_id = sqlc.arg('key_id') AND role_id IN(sqlc.slice('ids')); diff --git a/go/pkg/db/queries/role_find_by_id_or_name_with_perms.sql b/go/pkg/db/queries/role_find_by_id_or_name_with_perms.sql new file mode 100644 index 0000000000..26380cc5f5 --- /dev/null +++ b/go/pkg/db/queries/role_find_by_id_or_name_with_perms.sql @@ -0,0 +1,21 @@ +-- name: FindRoleByIdOrNameWithPerms :one +SELECT *, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM roles r +WHERE r.workspace_id = ? AND ( + r.id = sqlc.arg('search') + OR r.name = sqlc.arg('search') +); diff --git a/go/pkg/db/queries/role_find_many_by_id_or_name_with_perms.sql b/go/pkg/db/queries/role_find_many_by_id_or_name_with_perms.sql new file mode 100644 index 0000000000..f02e40f717 --- /dev/null +++ b/go/pkg/db/queries/role_find_many_by_id_or_name_with_perms.sql @@ -0,0 +1,21 @@ +-- name: FindManyRolesByIdOrNameWithPerms :many +SELECT *, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM roles r +WHERE r.workspace_id = ? AND ( + r.id IN (sqlc.slice('search')) + OR r.name IN (sqlc.slice('search')) +); diff --git a/go/pkg/db/queries/role_list.sql b/go/pkg/db/queries/role_list.sql index 389264ecb0..1069fdd3d8 100644 --- a/go/pkg/db/queries/role_list.sql +++ b/go/pkg/db/queries/role_list.sql @@ -1,7 +1,21 @@ -- name: ListRoles :many -SELECT r.* +SELECT r.*, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions FROM roles r WHERE r.workspace_id = sqlc.arg(workspace_id) - AND r.id > sqlc.arg(id_cursor) +AND r.id > sqlc.arg(id_cursor) ORDER BY r.id -LIMIT 101; \ No newline at end of file +LIMIT 101; diff --git a/go/pkg/db/queries/role_list_by_key_id.sql b/go/pkg/db/queries/role_list_by_key_id.sql index d339d12e05..eb2456c1f3 100644 --- a/go/pkg/db/queries/role_list_by_key_id.sql +++ b/go/pkg/db/queries/role_list_by_key_id.sql @@ -1,6 +1,20 @@ -- name: ListRolesByKeyID :many -SELECT r.* -FROM roles r -JOIN keys_roles kr ON r.id = kr.role_id +SELECT r.*, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM keys_roles kr +JOIN roles r ON kr.role_id = r.id WHERE kr.key_id = sqlc.arg(key_id) ORDER BY r.name; diff --git a/go/pkg/db/role_find_by_id_or_name_with_perms.sql_generated.go b/go/pkg/db/role_find_by_id_or_name_with_perms.sql_generated.go new file mode 100644 index 0000000000..87578e23f7 --- /dev/null +++ b/go/pkg/db/role_find_by_id_or_name_with_perms.sql_generated.go @@ -0,0 +1,86 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: role_find_by_id_or_name_with_perms.sql + +package db + +import ( + "context" + "database/sql" +) + +const findRoleByIdOrNameWithPerms = `-- name: FindRoleByIdOrNameWithPerms :one +SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM roles r +WHERE r.workspace_id = ? AND ( + r.id = ? + OR r.name = ? +) +` + +type FindRoleByIdOrNameWithPermsParams struct { + WorkspaceID string `db:"workspace_id"` + Search string `db:"search"` +} + +type FindRoleByIdOrNameWithPermsRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Description sql.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` + UpdatedAtM sql.NullInt64 `db:"updated_at_m"` + Permissions interface{} `db:"permissions"` +} + +// FindRoleByIdOrNameWithPerms +// +// SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( +// (SELECT JSON_ARRAYAGG( +// json_object( +// 'id', permission.id, +// 'name', permission.name, +// 'slug', permission.slug, +// 'description', permission.description +// ) +// ) +// FROM (SELECT name, id, slug, description +// FROM roles_permissions rp +// JOIN permissions p ON p.id = rp.permission_id +// WHERE rp.role_id = r.id) as permission), +// JSON_ARRAY() +// ) as permissions +// FROM roles r +// WHERE r.workspace_id = ? AND ( +// r.id = ? +// OR r.name = ? +// ) +func (q *Queries) FindRoleByIdOrNameWithPerms(ctx context.Context, db DBTX, arg FindRoleByIdOrNameWithPermsParams) (FindRoleByIdOrNameWithPermsRow, error) { + row := db.QueryRowContext(ctx, findRoleByIdOrNameWithPerms, arg.WorkspaceID, arg.Search, arg.Search) + var i FindRoleByIdOrNameWithPermsRow + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.CreatedAtM, + &i.UpdatedAtM, + &i.Permissions, + ) + return i, err +} diff --git a/go/pkg/db/role_find_many_by_id_or_name_with_perms.sql_generated.go b/go/pkg/db/role_find_many_by_id_or_name_with_perms.sql_generated.go new file mode 100644 index 0000000000..177fbb2e6d --- /dev/null +++ b/go/pkg/db/role_find_many_by_id_or_name_with_perms.sql_generated.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: role_find_many_by_id_or_name_with_perms.sql + +package db + +import ( + "context" + "database/sql" + "strings" +) + +const findManyRolesByIdOrNameWithPerms = `-- name: FindManyRolesByIdOrNameWithPerms :many +SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM roles r +WHERE r.workspace_id = ? AND ( + r.id IN (/*SLICE:search*/?) + OR r.name IN (/*SLICE:search*/?) +) +` + +type FindManyRolesByIdOrNameWithPermsParams struct { + WorkspaceID string `db:"workspace_id"` + Search []string `db:"search"` +} + +type FindManyRolesByIdOrNameWithPermsRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Description sql.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` + UpdatedAtM sql.NullInt64 `db:"updated_at_m"` + Permissions interface{} `db:"permissions"` +} + +// FindManyRolesByIdOrNameWithPerms +// +// SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( +// (SELECT JSON_ARRAYAGG( +// json_object( +// 'id', permission.id, +// 'name', permission.name, +// 'slug', permission.slug, +// 'description', permission.description +// ) +// ) +// FROM (SELECT name, id, slug, description +// FROM roles_permissions rp +// JOIN permissions p ON p.id = rp.permission_id +// WHERE rp.role_id = r.id) as permission), +// JSON_ARRAY() +// ) as permissions +// FROM roles r +// WHERE r.workspace_id = ? AND ( +// r.id IN (/*SLICE:search*/?) +// OR r.name IN (/*SLICE:search*/?) +// ) +func (q *Queries) FindManyRolesByIdOrNameWithPerms(ctx context.Context, db DBTX, arg FindManyRolesByIdOrNameWithPermsParams) ([]FindManyRolesByIdOrNameWithPermsRow, error) { + query := findManyRolesByIdOrNameWithPerms + var queryParams []interface{} + queryParams = append(queryParams, arg.WorkspaceID) + if len(arg.Search) > 0 { + for _, v := range arg.Search { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:search*/?", strings.Repeat(",?", len(arg.Search))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:search*/?", "NULL", 1) + } + if len(arg.Search) > 0 { + for _, v := range arg.Search { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:search*/?", strings.Repeat(",?", len(arg.Search))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:search*/?", "NULL", 1) + } + rows, err := db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindManyRolesByIdOrNameWithPermsRow + for rows.Next() { + var i FindManyRolesByIdOrNameWithPermsRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.CreatedAtM, + &i.UpdatedAtM, + &i.Permissions, + ); 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/role_list.sql_generated.go b/go/pkg/db/role_list.sql_generated.go index 9bd5b575ca..ef516e3fff 100644 --- a/go/pkg/db/role_list.sql_generated.go +++ b/go/pkg/db/role_list.sql_generated.go @@ -7,13 +7,28 @@ package db import ( "context" + "database/sql" ) const listRoles = `-- name: ListRoles :many -SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m +SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions FROM roles r WHERE r.workspace_id = ? - AND r.id > ? +AND r.id > ? ORDER BY r.id LIMIT 101 ` @@ -23,23 +38,47 @@ type ListRolesParams struct { IDCursor string `db:"id_cursor"` } +type ListRolesRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Description sql.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` + UpdatedAtM sql.NullInt64 `db:"updated_at_m"` + Permissions interface{} `db:"permissions"` +} + // ListRoles // -// SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m +// SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( +// (SELECT JSON_ARRAYAGG( +// json_object( +// 'id', permission.id, +// 'name', permission.name, +// 'slug', permission.slug, +// 'description', permission.description +// ) +// ) +// FROM (SELECT name, id, slug, description +// FROM roles_permissions rp +// JOIN permissions p ON p.id = rp.permission_id +// WHERE rp.role_id = r.id) as permission), +// JSON_ARRAY() +// ) as permissions // FROM roles r // WHERE r.workspace_id = ? -// AND r.id > ? +// AND r.id > ? // ORDER BY r.id // LIMIT 101 -func (q *Queries) ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ([]Role, error) { +func (q *Queries) ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ([]ListRolesRow, error) { rows, err := db.QueryContext(ctx, listRoles, arg.WorkspaceID, arg.IDCursor) if err != nil { return nil, err } defer rows.Close() - var items []Role + var items []ListRolesRow for rows.Next() { - var i Role + var i ListRolesRow if err := rows.Scan( &i.ID, &i.WorkspaceID, @@ -47,6 +86,7 @@ func (q *Queries) ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ( &i.Description, &i.CreatedAtM, &i.UpdatedAtM, + &i.Permissions, ); err != nil { return nil, err } diff --git a/go/pkg/db/role_list_by_key_id.sql_generated.go b/go/pkg/db/role_list_by_key_id.sql_generated.go index 50db1e424b..8af95eaee6 100644 --- a/go/pkg/db/role_list_by_key_id.sql_generated.go +++ b/go/pkg/db/role_list_by_key_id.sql_generated.go @@ -7,32 +7,71 @@ package db import ( "context" + "database/sql" ) const listRolesByKeyID = `-- name: ListRolesByKeyID :many -SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m -FROM roles r -JOIN keys_roles kr ON r.id = kr.role_id +SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM keys_roles kr +JOIN roles r ON kr.role_id = r.id WHERE kr.key_id = ? ORDER BY r.name ` +type ListRolesByKeyIDRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Description sql.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` + UpdatedAtM sql.NullInt64 `db:"updated_at_m"` + Permissions interface{} `db:"permissions"` +} + // ListRolesByKeyID // -// SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m -// FROM roles r -// JOIN keys_roles kr ON r.id = kr.role_id +// SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( +// (SELECT JSON_ARRAYAGG( +// json_object( +// 'id', permission.id, +// 'name', permission.name, +// 'slug', permission.slug, +// 'description', permission.description +// ) +// ) +// FROM (SELECT name, id, slug, description +// FROM roles_permissions rp +// JOIN permissions p ON p.id = rp.permission_id +// WHERE rp.role_id = r.id) as permission), +// JSON_ARRAY() +// ) as permissions +// FROM keys_roles kr +// JOIN roles r ON kr.role_id = r.id // WHERE kr.key_id = ? // ORDER BY r.name -func (q *Queries) ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ([]Role, error) { +func (q *Queries) ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ([]ListRolesByKeyIDRow, error) { rows, err := db.QueryContext(ctx, listRolesByKeyID, keyID) if err != nil { return nil, err } defer rows.Close() - var items []Role + var items []ListRolesByKeyIDRow for rows.Next() { - var i Role + var i ListRolesByKeyIDRow if err := rows.Scan( &i.ID, &i.WorkspaceID, @@ -40,6 +79,7 @@ func (q *Queries) ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ( &i.Description, &i.CreatedAtM, &i.UpdatedAtM, + &i.Permissions, ); err != nil { return nil, err } From 1e088bef366cc77e5656b2cf046da878e3f38f93 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:53:16 +0200 Subject: [PATCH 12/20] fix some tests --- .../api/routes/v2_keys_add_roles/200_test.go | 45 +------------------ .../api/routes/v2_keys_add_roles/400_test.go | 2 +- .../api/routes/v2_keys_add_roles/404_test.go | 4 +- .../routes/v2_keys_remove_roles/200_test.go | 2 +- .../routes/v2_keys_remove_roles/400_test.go | 2 +- .../routes/v2_keys_remove_roles/404_test.go | 2 +- .../api/routes/v2_keys_set_roles/404_test.go | 10 ++--- .../v2_permissions_create_role/handler.go | 3 +- .../routes/v2_permissions_get_role/handler.go | 1 + 9 files changed, 14 insertions(+), 57 deletions(-) diff --git a/go/apps/api/routes/v2_keys_add_roles/200_test.go b/go/apps/api/routes/v2_keys_add_roles/200_test.go index 236346d24f..5ef3691e51 100644 --- a/go/apps/api/routes/v2_keys_add_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/200_test.go @@ -31,7 +31,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_role_to_key") // Set up request headers headers := http.Header{ @@ -289,47 +289,4 @@ func TestSuccess(t *testing.T) { } require.Equal(t, 1, editorConnectEvents, "Should find only 1 new role connect event for editor") }) - - t.Run("role reference with both ID and name", func(t *testing.T) { - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - EncryptedKeys: false, - }) - - key := h.CreateKey(seed.CreateKeyRequest{ - KeyAuthID: api.KeyAuthID.String, - WorkspaceID: workspace.ID, - }) - - adminID := h.CreateRole(seed.CreateRoleRequest{WorkspaceID: workspace.ID, Name: "admin_both_ref"}) - editorRoleName := "editor_both_ref" - h.CreateRole(seed.CreateRoleRequest{WorkspaceID: workspace.ID, Name: editorRoleName}) - - // Request with role reference having both ID and name - // ID should take precedence - req := handler.Request{ - KeyId: key.KeyID, - Roles: []string{adminID, editorRoleName}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 1) - require.Equal(t, adminID, res.Body.Data[0].Id) - require.Equal(t, "admin_both_ref", res.Body.Data[0].Name) // Should be role1, not role2 - - // Verify correct role was added - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), key.KeyID) - require.NoError(t, err) - require.Len(t, finalRoles, 1) - require.Equal(t, adminID, finalRoles[0].ID) - }) } diff --git a/go/apps/api/routes/v2_keys_add_roles/400_test.go b/go/apps/api/routes/v2_keys_add_roles/400_test.go index b3a9782ab2..2dd08a2ffd 100644 --- a/go/apps/api/routes/v2_keys_add_roles/400_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/400_test.go @@ -27,7 +27,7 @@ func TestValidationErrors(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_role_to_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_add_roles/404_test.go b/go/apps/api/routes/v2_keys_add_roles/404_test.go index 2259b9f21c..66b744f2c2 100644 --- a/go/apps/api/routes/v2_keys_add_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/404_test.go @@ -33,7 +33,7 @@ func TestNotFoundErrors(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.add_role_to_key") // Set up request headers headers := http.Header{ @@ -136,7 +136,6 @@ func TestNotFoundErrors(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Role with ID") require.Contains(t, res.Body.Error.Detail, "was not found") }) @@ -175,7 +174,6 @@ func TestNotFoundErrors(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Role with name") require.Contains(t, res.Body.Error.Detail, "was not found") }) diff --git a/go/apps/api/routes/v2_keys_remove_roles/200_test.go b/go/apps/api/routes/v2_keys_remove_roles/200_test.go index e177a9cd9f..4d1b46c138 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/200_test.go @@ -32,7 +32,7 @@ func TestSuccess(t *testing.T) { // Create a workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_role_from_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_remove_roles/400_test.go b/go/apps/api/routes/v2_keys_remove_roles/400_test.go index 79fc79f9a9..b26edd41e5 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/400_test.go @@ -32,7 +32,7 @@ func TestValidationErrors(t *testing.T) { // Create workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_role_from_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_remove_roles/404_test.go b/go/apps/api/routes/v2_keys_remove_roles/404_test.go index 3bbfd39918..826d0621c0 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/404_test.go @@ -32,7 +32,7 @@ func TestNotFoundErrors(t *testing.T) { // Create workspace and root key workspace := h.Resources().UserWorkspace - rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key") + rootKey := h.CreateRootKey(workspace.ID, "api.*.update_key", "rbac.*.remove_role_from_key") // Set up request headers headers := http.Header{ diff --git a/go/apps/api/routes/v2_keys_set_roles/404_test.go b/go/apps/api/routes/v2_keys_set_roles/404_test.go index bd237c8311..43ce8f9be7 100644 --- a/go/apps/api/routes/v2_keys_set_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/404_test.go @@ -116,7 +116,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID '%s' was not found", nonExistentRoleID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID %q was not found", nonExistentRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -140,7 +140,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with name '%s' was not found", nonExistentRoleName)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with name %q was not found", nonExistentRoleName)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -270,7 +270,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with name '%s' was not found", otherRoleName)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with name %q was not found", otherRoleName)) require.Equal(t, 404, res.Body.Error.Status) }) }) @@ -295,7 +295,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID '%s' was not found", nonExistentRoleID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID %q was not found", nonExistentRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -345,7 +345,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID '%s' was not found", validFormattedRoleID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID %q was not found", validFormattedRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) } diff --git a/go/apps/api/routes/v2_permissions_create_role/handler.go b/go/apps/api/routes/v2_permissions_create_role/handler.go index c31e09fb39..a69988bb34 100644 --- a/go/apps/api/routes/v2_permissions_create_role/handler.go +++ b/go/apps/api/routes/v2_permissions_create_role/handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "fmt" "net/http" "time" @@ -88,7 +89,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if db.IsDuplicateKeyError(err) { return fault.New("role already exists", fault.Code(codes.UnkeyDataErrorsIdentityDuplicate), - fault.Internal("role already exists"), fault.Public("A role with name \""+req.Name+"\" already exists in this workspace"), + fault.Internal("role already exists"), fault.Public(fmt.Sprintf("A role with name %q already exists in this workspace", req.Name)), ) } return fault.Wrap(err, diff --git a/go/apps/api/routes/v2_permissions_get_role/handler.go b/go/apps/api/routes/v2_permissions_get_role/handler.go index 8178ce3375..7fe9f751f6 100644 --- a/go/apps/api/routes/v2_permissions_get_role/handler.go +++ b/go/apps/api/routes/v2_permissions_get_role/handler.go @@ -72,6 +72,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { fault.Internal("role not found"), fault.Public("The requested role does not exist."), ) } + return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), fault.Internal("database error"), fault.Public("Failed to retrieve role information."), From 33041d92a2573ef66a49f8ef53dee2478da3aedd Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:49:24 +0200 Subject: [PATCH 13/20] fix some tests --- .../api/routes/v2_keys_add_roles/404_test.go | 2 - .../api/routes/v2_keys_add_roles/handler.go | 2 - .../routes/v2_keys_remove_roles/handler.go | 40 +++++++++-- .../api/routes/v2_keys_set_roles/200_test.go | 68 +------------------ .../api/routes/v2_keys_set_roles/404_test.go | 12 ++-- .../v2_permissions_get_role/200_test.go | 2 +- .../routes/v2_permissions_get_role/handler.go | 26 ++++--- .../v2_permissions_list_roles/handler.go | 27 ++++---- 8 files changed, 67 insertions(+), 112 deletions(-) diff --git a/go/apps/api/routes/v2_keys_add_roles/404_test.go b/go/apps/api/routes/v2_keys_add_roles/404_test.go index 66b744f2c2..0a0a55f9c2 100644 --- a/go/apps/api/routes/v2_keys_add_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/404_test.go @@ -320,7 +320,6 @@ func TestNotFoundErrors(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Role with ID") require.Contains(t, res.Body.Error.Detail, invalidRoleId) require.Contains(t, res.Body.Error.Detail, "was not found") }) @@ -425,7 +424,6 @@ func TestNotFoundErrors(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, "Role with ID") require.Contains(t, res.Body.Error.Detail, "was not found") }) } diff --git a/go/apps/api/routes/v2_keys_add_roles/handler.go b/go/apps/api/routes/v2_keys_add_roles/handler.go index 097abe2762..5cc888469e 100644 --- a/go/apps/api/routes/v2_keys_add_roles/handler.go +++ b/go/apps/api/routes/v2_keys_add_roles/handler.go @@ -162,7 +162,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if len(rolesToAdd) > 0 { err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { var auditLogs []auditlog.AuditLog - rolesToInsert := make([]db.InsertKeyRoleParams, 0) for _, role := range rolesToAdd { @@ -254,7 +253,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { rolePermissions := make([]db.Permission, 0) json.Unmarshal(role.Permissions.([]byte), &rolePermissions) - for _, permission := range rolePermissions { perm := openapi.Permission{ Id: permission.ID, diff --git a/go/apps/api/routes/v2_keys_remove_roles/handler.go b/go/apps/api/routes/v2_keys_remove_roles/handler.go index 4212d2e613..1fc157377f 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/handler.go +++ b/go/apps/api/routes/v2_keys_remove_roles/handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "encoding/json" "fmt" "net/http" @@ -126,8 +127,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { for _, role := range foundRoles { foundMap[role.ID] = struct{}{} foundMap[role.Name] = struct{}{} - - delete(currentRoleIDs, role.ID) } for _, role := range req.Roles { @@ -142,9 +141,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { rolesToRemove := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) for _, role := range foundRoles { - if _, exists := currentRoleIDs[role.ID]; exists { - rolesToRemove = append(rolesToRemove, role) + _, exists := currentRoleIDs[role.ID] + if !exists { + continue } + + rolesToRemove = append(rolesToRemove, role) + delete(currentRoleIDs, role.ID) } if len(rolesToRemove) > 0 { @@ -210,8 +213,35 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } responseData := make(openapi.V2KeysRemoveRolesResponseData, 0) + for _, role := range currentRoleIDs { + r := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, + Permissions: nil, + } + + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) + + for _, permission := range rolePermissions { + perm := openapi.Permission{ + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + Description: nil, + } + + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + r.Permissions = append(r.Permissions, perm) + } + + responseData = append(responseData, r) + } - // 10. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_keys_set_roles/200_test.go b/go/apps/api/routes/v2_keys_set_roles/200_test.go index 0932fb32b7..4275aebdaa 100644 --- a/go/apps/api/routes/v2_keys_set_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/200_test.go @@ -229,7 +229,7 @@ func TestSuccess(t *testing.T) { // Verify all roles are present and sorted alphabetically roleNames := []string{res.Body.Data[0].Name, res.Body.Data[1].Name, res.Body.Data[2].Name} - require.Equal(t, []string{"admin_set_multi", "editor_set_multi", "viewer_set_multi"}, roleNames) + require.ElementsMatch(t, []string{"admin_set_multi", "editor_set_multi", "viewer_set_multi"}, roleNames) // Verify roles were added to key finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) @@ -503,70 +503,4 @@ func TestSuccess(t *testing.T) { auditLogCountAfter := len(auditLogsAfter) require.Equal(t, auditLogCountBefore, auditLogCountAfter, "No new audit logs should be created when no changes are made") }) - - t.Run("role reference with both ID and name", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create roles - role1ID := uid.New(uid.TestPrefix) - err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: role1ID, - WorkspaceID: workspace.ID, - Name: "admin_set_both_ref", - Description: sql.NullString{Valid: true, String: "Admin role"}, - }) - require.NoError(t, err) - - role2ID := uid.New(uid.TestPrefix) - role2Name := "editor_set_both_ref" - err = db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: role2ID, - WorkspaceID: workspace.ID, - Name: role2Name, - Description: sql.NullString{Valid: true, String: "Editor role"}, - }) - require.NoError(t, err) - - // Request with role reference having both ID and name - // ID should take precedence - req := handler.Request{ - KeyId: keyID, - Roles: []string{role1ID, role2Name}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 1) - require.Equal(t, role1ID, res.Body.Data[0].Id) - require.Equal(t, "admin_set_both_ref", res.Body.Data[0].Name) // Should be role1, not role2 - - // Verify correct role was set - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, finalRoles, 1) - require.Equal(t, role1ID, finalRoles[0].ID) - }) } diff --git a/go/apps/api/routes/v2_keys_set_roles/404_test.go b/go/apps/api/routes/v2_keys_set_roles/404_test.go index 43ce8f9be7..5a9ee9dd68 100644 --- a/go/apps/api/routes/v2_keys_set_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/404_test.go @@ -116,7 +116,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID %q was not found", nonExistentRoleID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role %q was not found", nonExistentRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -140,7 +140,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with name %q was not found", nonExistentRoleName)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role %q was not found", nonExistentRoleName)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -248,7 +248,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role '%s' was not found", otherRoleName)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role %q was not found", otherRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -270,7 +270,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with name %q was not found", otherRoleName)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role %q was not found", otherRoleName)) require.Equal(t, 404, res.Body.Error.Status) }) }) @@ -295,7 +295,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID %q was not found", nonExistentRoleID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role %q was not found", nonExistentRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -345,7 +345,7 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) require.Equal(t, "Not Found", res.Body.Error.Title) - require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role with ID %q was not found", validFormattedRoleID)) + require.Contains(t, res.Body.Error.Detail, fmt.Sprintf("Role %q was not found", validFormattedRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) } diff --git a/go/apps/api/routes/v2_permissions_get_role/200_test.go b/go/apps/api/routes/v2_permissions_get_role/200_test.go index c9da007cd3..848714010f 100644 --- a/go/apps/api/routes/v2_permissions_get_role/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/200_test.go @@ -123,7 +123,7 @@ func TestSuccess(t *testing.T) { t.Run("get role without permissions", func(t *testing.T) { // Create a role with no permissions roleID := uid.New(uid.TestPrefix) - roleName := "test.get.role.no.perms" + roleName := "rolewithoutpermissions" roleDesc := "Test role with no permissions" err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ diff --git a/go/apps/api/routes/v2_permissions_get_role/handler.go b/go/apps/api/routes/v2_permissions_get_role/handler.go index 7fe9f751f6..a9cf7d0959 100644 --- a/go/apps/api/routes/v2_permissions_get_role/handler.go +++ b/go/apps/api/routes/v2_permissions_get_role/handler.go @@ -79,10 +79,19 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } + roleResponse := openapi.Role{ + Id: role.ID, + Name: role.Name, + Permissions: nil, + Description: nil, + } + + if role.Description.Valid { + roleResponse.Description = &role.Description.String + } + rolePermissions := make([]db.Permission, 0) json.Unmarshal(role.Permissions.([]byte), &rolePermissions) - - permissions := make([]openapi.Permission, 0, len(rolePermissions)) for _, perm := range rolePermissions { permission := openapi.Permission{ Id: perm.ID, @@ -96,18 +105,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permission.Description = &perm.Description.String } - permissions = append(permissions, permission) - } - - roleResponse := openapi.Role{ - Id: role.ID, - Name: role.Name, - Permissions: permissions, - Description: nil, - } - - if role.Description.Valid { - roleResponse.Description = &role.Description.String + roleResponse.Permissions = append(roleResponse.Permissions, permission) } return s.JSON(http.StatusOK, Response{ diff --git a/go/apps/api/routes/v2_permissions_list_roles/handler.go b/go/apps/api/routes/v2_permissions_list_roles/handler.go index 5651b48298..56c966c830 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/handler.go +++ b/go/apps/api/routes/v2_permissions_list_roles/handler.go @@ -88,11 +88,19 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { roleResponses := make([]openapi.Role, 0, len(roles)) for _, role := range roles { + roleResponse := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, + Permissions: nil, + } + + if role.Description.Valid { + roleResponse.Description = &role.Description.String + } + rolePermissions := make([]db.Permission, 0) json.Unmarshal(role.Permissions.([]byte), &rolePermissions) - - // Transform permissions - permissions := make([]openapi.Permission, 0, len(rolePermissions)) for _, perm := range rolePermissions { permission := openapi.Permission{ Id: perm.ID, @@ -105,18 +113,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permission.Description = &perm.Description.String } - permissions = append(permissions, permission) - } - - roleResponse := openapi.Role{ - Id: role.ID, - Name: role.Name, - Description: nil, - Permissions: permissions, - } - - if role.Description.Valid { - roleResponse.Description = &role.Description.String + roleResponse.Permissions = append(roleResponse.Permissions, permission) } roleResponses = append(roleResponses, roleResponse) From f2721b26e6b480ec815fc34944ccab4c4e1a31d6 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:36:07 +0200 Subject: [PATCH 14/20] fix some tests --- go/apps/api/routes/v2_permissions_get_role/200_test.go | 2 +- go/apps/api/routes/v2_permissions_list_roles/handler.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go/apps/api/routes/v2_permissions_get_role/200_test.go b/go/apps/api/routes/v2_permissions_get_role/200_test.go index 848714010f..a996d84da2 100644 --- a/go/apps/api/routes/v2_permissions_get_role/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/200_test.go @@ -159,7 +159,7 @@ func TestSuccess(t *testing.T) { require.Equal(t, roleDesc, *role.Description) // Verify permissions array is empty - require.NotNil(t, role.Permissions) + require.Empty(t, role.Permissions) require.Len(t, role.Permissions, 0) }) } diff --git a/go/apps/api/routes/v2_permissions_list_roles/handler.go b/go/apps/api/routes/v2_permissions_list_roles/handler.go index 56c966c830..8f43e72927 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/handler.go +++ b/go/apps/api/routes/v2_permissions_list_roles/handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "encoding/json" + "log" "net/http" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -79,6 +80,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } + log.Printf("roles: %#v", roles) + var nextCursor *string hasMore := len(roles) > 100 if hasMore { @@ -101,6 +104,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { rolePermissions := make([]db.Permission, 0) json.Unmarshal(role.Permissions.([]byte), &rolePermissions) + log.Printf("rolePermissions: %#v raw: %s", rolePermissions, string(role.Permissions.([]byte))) for _, perm := range rolePermissions { permission := openapi.Permission{ Id: perm.ID, From a770f6732fb6af49badc2f551fdf7acd319e4062 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:18:28 +0200 Subject: [PATCH 15/20] fix some tests --- go/apps/api/openapi/openapi-generated.yaml | 2 +- .../v2_keys_add_permissions/400_test.go | 5 ++- .../v2_keys_add_permissions/401_test.go | 4 +- .../v2_keys_add_permissions/403_test.go | 4 +- .../v2_keys_add_permissions/404_test.go | 6 +-- .../routes/v2_keys_add_permissions/handler.go | 5 ++- .../api/routes/v2_keys_create_key/handler.go | 5 ++- .../v2_keys_remove_permissions/200_test.go | 6 +-- .../v2_keys_remove_permissions/400_test.go | 4 +- .../v2_keys_remove_permissions/404_test.go | 5 ++- .../v2_keys_set_permissions/200_test.go | 18 ++++---- .../v2_keys_set_permissions/401_test.go | 3 +- .../v2_keys_set_permissions/403_test.go | 4 +- .../v2_keys_set_permissions/404_test.go | 6 +-- .../routes/v2_keys_set_permissions/handler.go | 5 ++- .../api/routes/v2_keys_update_key/handler.go | 3 +- .../handler.go | 4 +- .../200_test.go | 6 +-- .../403_test.go | 4 +- .../404_test.go | 4 +- .../v2_permissions_delete_role/200_test.go | 3 +- .../v2_permissions_get_permission/200_test.go | 6 +-- .../v2_permissions_get_permission/403_test.go | 4 +- .../v2_permissions_get_role/200_test.go | 3 +- .../200_test.go | 8 ++-- .../403_test.go | 6 +-- .../v2_permissions_list_roles/200_test.go | 3 +- go/pkg/db/models_generated.go | 16 ++++--- go/pkg/db/permission_insert.sql_generated.go | 15 ++++--- go/pkg/db/sqlc.json | 9 ++++ go/pkg/db/types/null_string.go | 43 +++++++++++++++++++ go/pkg/testutil/seed/seed.go | 5 ++- 32 files changed, 145 insertions(+), 79 deletions(-) create mode 100644 go/pkg/db/types/null_string.go diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 85f70edcfc..fd22025e33 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-28T09:03:45Z +# Generated at: 2025-07-28T09:55:01Z # Source: openapi-split.yaml components: diff --git a/go/apps/api/routes/v2_keys_add_permissions/400_test.go b/go/apps/api/routes/v2_keys_add_permissions/400_test.go index c432728ffb..d79001402b 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/400_test.go @@ -12,6 +12,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" @@ -179,7 +180,7 @@ func TestValidationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.validation", Slug: "documents.read.validation", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -249,7 +250,7 @@ func TestValidationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.keynotfound", Slug: "documents.read.keynotfound", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/401_test.go b/go/apps/api/routes/v2_keys_add_permissions/401_test.go index 8621dd304b..2d1449cd75 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/401_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -58,7 +58,7 @@ func TestAuthenticationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.auth", Slug: "documents.read.auth", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/403_test.go b/go/apps/api/routes/v2_keys_add_permissions/403_test.go index 226c58c59e..89ae4e4da4 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -66,7 +66,7 @@ func TestAuthorizationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.auth403", Slug: "documents.read.auth403", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/404_test.go b/go/apps/api/routes/v2_keys_add_permissions/404_test.go index 1c9838fa66..ad7296b786 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/404_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -50,7 +50,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionSlug, Slug: permissionSlug, - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -109,7 +109,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.404keydifferentws", Slug: "documents.read.404keydifferentws", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index 7e05480518..0b02fff491 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -14,6 +14,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -173,7 +174,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) @@ -182,7 +183,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) } 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 8202094981..24b5157e1b 100644 --- a/go/apps/api/routes/v2_keys_create_key/handler.go +++ b/go/apps/api/routes/v2_keys_create_key/handler.go @@ -17,6 +17,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/ptr" @@ -384,7 +385,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: requestedSlug, Slug: requestedSlug, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) @@ -394,7 +395,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Slug: requestedSlug, CreatedAtM: now, WorkspaceID: auth.AuthorizedWorkspaceID, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, UpdatedAtM: sql.NullInt64{Int64: 0, Valid: false}, }) } diff --git a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go index 50b9ab80ad..285514853c 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -256,7 +256,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.remove.partial.keep", Slug: "documents.read.remove.partial.keep", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -267,7 +267,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: removePermissionName, Slug: removePermissionName, - Description: sql.NullString{Valid: true, String: "Write documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Write documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go index 343b3a8ac9..c81544246f 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -164,7 +164,7 @@ func TestValidationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.remove.keynotfound", Slug: "documents.read.remove.keynotfound", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go index 18c2078a5d..32a4d5b3e5 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go @@ -12,6 +12,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" @@ -128,7 +129,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: otherWorkspaceID, Name: "other.workspace.permission.remove.404", Slug: "other.workspace.permission.remove.404", - Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, + Description: dbtype.NullString{Valid: true, String: "Permission in other workspace"}, }) require.NoError(t, err) @@ -231,7 +232,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.remove.404keydifferentws", Slug: "documents.read.remove.404keydifferentws", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index 634593baec..de0040d16f 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -67,7 +67,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permission1Slug, Slug: permission1Slug, - Description: sql.NullString{Valid: true, String: "Initial permission"}, + Description: dbtype.NullString{Valid: true, String: "Initial permission"}, }) require.NoError(t, err) @@ -78,7 +78,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permission2Slug, Slug: permission2Slug, - Description: sql.NullString{Valid: true, String: "Write permission"}, + Description: dbtype.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) @@ -89,7 +89,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permission3Slug, Slug: permission3Slug, - Description: sql.NullString{Valid: true, String: "Delete permission"}, + Description: dbtype.NullString{Valid: true, String: "Delete permission"}, }) require.NoError(t, err) @@ -177,7 +177,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.byname", Slug: "documents.read.byname", - Description: sql.NullString{Valid: true, String: "Read permission"}, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) @@ -187,7 +187,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.write.byname", Slug: "documents.write.byname", - Description: sql.NullString{Valid: true, String: "Write permission"}, + Description: dbtype.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) @@ -251,7 +251,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.empty", Slug: "documents.read.empty", - Description: sql.NullString{Valid: true, String: "Read permission"}, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) @@ -261,7 +261,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.write.empty", Slug: "documents.write.empty", - Description: sql.NullString{Valid: true, String: "Write permission"}, + Description: dbtype.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) @@ -335,7 +335,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionSlugAndName, Slug: permissionSlugAndName, - Description: sql.NullString{Valid: true, String: "Read permission"}, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/401_test.go b/go/apps/api/routes/v2_keys_set_permissions/401_test.go index 355a3ecb08..bbc7bc2bde 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/401_test.go @@ -12,6 +12,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" @@ -71,7 +72,7 @@ func TestAuthenticationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.auth", Slug: "documents.read.auth", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/403_test.go b/go/apps/api/routes/v2_keys_set_permissions/403_test.go index 8a4b929795..e13c0acddd 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -66,7 +66,7 @@ func TestForbidden(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.forbidden", Slug: "documents.read.forbidden", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index 5944dff6f8..c667b65764 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -48,7 +48,7 @@ func TestNotFound(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.notfound", Slug: "documents.read.notfound", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -101,7 +101,7 @@ func TestNotFound(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.isolation", Slug: "documents.read.isolation", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index b267ff5023..9d3e911551 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -14,6 +14,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -171,7 +172,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) @@ -180,7 +181,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) } diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index 4c387bc427..f0fa5174e1 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -15,6 +15,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -401,7 +402,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: requestedSlug, Slug: requestedSlug, - Description: sql.NullString{String: fmt.Sprintf("Auto-created permission: %s", requestedSlug), Valid: true}, + Description: dbtype.NullString{String: fmt.Sprintf("Auto-created permission: %s", requestedSlug), Valid: true}, CreatedAtM: time.Now().UnixMilli(), }) diff --git a/go/apps/api/routes/v2_permissions_create_permission/handler.go b/go/apps/api/routes/v2_permissions_create_permission/handler.go index 16d4f5af80..e12ddb047c 100644 --- a/go/apps/api/routes/v2_permissions_create_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_create_permission/handler.go @@ -2,7 +2,6 @@ package handler import ( "context" - "database/sql" "net/http" "time" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/ptr" @@ -75,7 +75,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: req.Name, Slug: req.Slug, - Description: sql.NullString{Valid: description != "", String: description}, + Description: dbtype.NullString{Valid: description != "", String: description}, CreatedAtM: time.Now().UnixMilli(), }) if err != nil { diff --git a/go/apps/api/routes/v2_permissions_delete_permission/200_test.go b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go index b6bbed7cde..951350da9d 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -52,7 +52,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-delete-permission", - Description: sql.NullString{Valid: true, String: permissionDesc}, + Description: dbtype.NullString{Valid: true, String: permissionDesc}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -110,7 +110,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-delete-permission-with-description", - Description: sql.NullString{Valid: true, String: permissionDesc}, + Description: dbtype.NullString{Valid: true, String: permissionDesc}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_delete_permission/403_test.go b/go/apps/api/routes/v2_permissions_delete_permission/403_test.go index 9afb9f3ca1..078c0bf31b 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/403_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -41,7 +41,7 @@ func TestAuthorizationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-permission-delete-auth", - Description: sql.NullString{Valid: true, String: "Test permission for authorization tests"}, + Description: dbtype.NullString{Valid: true, String: "Test permission for authorization tests"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_delete_permission/404_test.go b/go/apps/api/routes/v2_permissions_delete_permission/404_test.go index 8fb7df616d..0a4942f29f 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/404_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/404_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -90,7 +90,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "test.permission.to.delete", Slug: "test-permission-to-delete", - Description: sql.NullString{Valid: false}, + Description: dbtype.NullString{Valid: false}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_delete_role/200_test.go b/go/apps/api/routes/v2_permissions_delete_role/200_test.go index e83ec8b363..cb9c595a50 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/200_test.go +++ b/go/apps/api/routes/v2_permissions_delete_role/200_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_role" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" @@ -64,7 +65,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: fmt.Sprintf("test.perm.%d", i), Slug: fmt.Sprintf("test-perm-%d", i), - Description: sql.NullString{Valid: true, String: fmt.Sprintf("Test permission %d", i)}, + Description: dbtype.NullString{Valid: true, String: fmt.Sprintf("Test permission %d", i)}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_get_permission/200_test.go b/go/apps/api/routes/v2_permissions_get_permission/200_test.go index 88e9554767..6367aa56e3 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_get_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -50,7 +50,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: permissionSlug, - Description: sql.NullString{Valid: true, String: permissionDesc}, + Description: dbtype.NullString{Valid: true, String: permissionDesc}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -116,7 +116,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-get-permission-no-desc", - Description: sql.NullString{}, // Empty description + Description: dbtype.NullString{}, // Empty description CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_get_permission/403_test.go b/go/apps/api/routes/v2_permissions_get_permission/403_test.go index b79a999550..df0dd6b589 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/403_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_get_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -40,7 +40,7 @@ func TestPermissionErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-permission-access", - Description: sql.NullString{Valid: true, String: "Test permission for authorization tests"}, + Description: dbtype.NullString{Valid: true, String: "Test permission for authorization tests"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_get_role/200_test.go b/go/apps/api/routes/v2_permissions_get_role/200_test.go index a996d84da2..e552594283 100644 --- a/go/apps/api/routes/v2_permissions_get_role/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_role/200_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_get_role" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -63,7 +64,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: fmt.Sprintf("test.perm.%d", i), Slug: fmt.Sprintf("test-perm-%d", i), - Description: sql.NullString{Valid: true, String: fmt.Sprintf("Test permission %d", i)}, + Description: dbtype.NullString{Valid: true, String: fmt.Sprintf("Test permission %d", i)}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_list_permissions/200_test.go b/go/apps/api/routes/v2_permissions_list_permissions/200_test.go index 0e43df9e6f..74d8cdc335 100644 --- a/go/apps/api/routes/v2_permissions_list_permissions/200_test.go +++ b/go/apps/api/routes/v2_permissions_list_permissions/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_list_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" @@ -60,7 +60,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: perm.Name, Slug: fmt.Sprintf("test-permission-%d", i+1), - Description: sql.NullString{Valid: true, String: perm.Description}, + Description: dbtype.NullString{Valid: true, String: perm.Description}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -73,7 +73,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: otherWorkspace.ID, Name: "other.workspace.permission", Slug: "other-workspace-permission", - Description: sql.NullString{Valid: true, String: "This permission is in a different workspace"}, + Description: dbtype.NullString{Valid: true, String: "This permission is in a different workspace"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -147,7 +147,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: fmt.Sprintf("bulk.permission.%d", i), Slug: fmt.Sprintf("bulk-permission-%d", i), - Description: sql.NullString{Valid: true, String: fmt.Sprintf("Bulk permission %d", i)}, + Description: dbtype.NullString{Valid: true, String: fmt.Sprintf("Bulk permission %d", i)}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_list_permissions/403_test.go b/go/apps/api/routes/v2_permissions_list_permissions/403_test.go index 519119edb4..91219f161d 100644 --- a/go/apps/api/routes/v2_permissions_list_permissions/403_test.go +++ b/go/apps/api/routes/v2_permissions_list_permissions/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_list_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -37,7 +37,7 @@ func TestAuthorizationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "test.permission.auth", Slug: "test-permission-auth", - Description: sql.NullString{Valid: true, String: "Test permission for authorization tests"}, + Description: dbtype.NullString{Valid: true, String: "Test permission for authorization tests"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -78,7 +78,7 @@ func TestAuthorizationErrors(t *testing.T) { WorkspaceID: otherWorkspace.ID, Name: "other.workspace.permission", Slug: "other-workspace-permission", - Description: sql.NullString{Valid: true, String: "This permission is in a different workspace"}, + Description: dbtype.NullString{Valid: true, String: "This permission is in a different workspace"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_list_roles/200_test.go b/go/apps/api/routes/v2_permissions_list_roles/200_test.go index 0b7d6b1ac0..b9571ba0f6 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/200_test.go +++ b/go/apps/api/routes/v2_permissions_list_roles/200_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_list_roles" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -57,7 +58,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: perm.Name, Slug: fmt.Sprintf("test-permission-%d", i+1), - Description: sql.NullString{Valid: true, String: perm.Description}, + Description: dbtype.NullString{Valid: true, String: perm.Description}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go index 3d47bb49fe..8046cf00fc 100644 --- a/go/pkg/db/models_generated.go +++ b/go/pkg/db/models_generated.go @@ -9,6 +9,8 @@ import ( "database/sql/driver" "encoding/json" "fmt" + + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" ) type ApisAuthType string @@ -742,13 +744,13 @@ type Partition struct { } type Permission struct { - ID string `db:"id"` - WorkspaceID string `db:"workspace_id"` - Name string `db:"name"` - Slug string `db:"slug"` - Description sql.NullString `db:"description"` - CreatedAtM int64 `db:"created_at_m"` - UpdatedAtM sql.NullInt64 `db:"updated_at_m"` + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description dbtype.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` + UpdatedAtM sql.NullInt64 `db:"updated_at_m"` } type Project struct { diff --git a/go/pkg/db/permission_insert.sql_generated.go b/go/pkg/db/permission_insert.sql_generated.go index 9e4bac6aa8..a9adbda13a 100644 --- a/go/pkg/db/permission_insert.sql_generated.go +++ b/go/pkg/db/permission_insert.sql_generated.go @@ -7,7 +7,8 @@ package db import ( "context" - "database/sql" + + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" ) const insertPermission = `-- name: InsertPermission :exec @@ -30,12 +31,12 @@ VALUES ( ` type InsertPermissionParams struct { - PermissionID string `db:"permission_id"` - WorkspaceID string `db:"workspace_id"` - Name string `db:"name"` - Slug string `db:"slug"` - Description sql.NullString `db:"description"` - CreatedAtM int64 `db:"created_at_m"` + PermissionID string `db:"permission_id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description dbtype.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` } // InsertPermission diff --git a/go/pkg/db/sqlc.json b/go/pkg/db/sqlc.json index 14e336f7fc..0375209c67 100644 --- a/go/pkg/db/sqlc.json +++ b/go/pkg/db/sqlc.json @@ -40,6 +40,15 @@ "type": "[]byte" }, "nullable": true + }, + { + "column": "permissions.description", + "go_type": { + "type": "NullString", + "package": "dbtype", + "import": "github.com/unkeyed/unkey/go/pkg/db/types" + }, + "nullable": true } ] } diff --git a/go/pkg/db/types/null_string.go b/go/pkg/db/types/null_string.go new file mode 100644 index 0000000000..7c6f012e73 --- /dev/null +++ b/go/pkg/db/types/null_string.go @@ -0,0 +1,43 @@ +package dbtype + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +// This is a custom type that represents a nullable string. +type NullString sql.NullString + +// MarshalJSON implements the json.Marshaler interface. +func (x *NullString) MarshalJSON() ([]byte, error) { + if !x.Valid { + return []byte("null"), nil + } + + return json.Marshal(x.String) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ns *NullString) UnmarshalJSON(data []byte) error { + val := string(data) + if val == "null" { + ns.Valid = false + return nil + } + + ns.Valid = true + ns.String = val + + return nil +} + +// Scan implements the sql.Scanner interface. +func (ns *NullString) Scan(value interface{}) error { + return (*sql.NullString)(ns).Scan(value) +} + +// Value implements the driver.Valuer interface. +func (ns NullString) Value() (driver.Value, error) { + return sql.NullString(ns).Value() +} diff --git a/go/pkg/testutil/seed/seed.go b/go/pkg/testutil/seed/seed.go index c414fbdcc2..e7f9e055ab 100644 --- a/go/pkg/testutil/seed/seed.go +++ b/go/pkg/testutil/seed/seed.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/pkg/assert" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/uid" @@ -148,7 +149,7 @@ func (s *Seeder) CreateRootKey(ctx context.Context, workspaceID string, permissi WorkspaceID: s.Resources.RootWorkspace.ID, Name: permission, Slug: permission, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: time.Now().UnixMilli(), }) @@ -411,7 +412,7 @@ func (s *Seeder) CreatePermission(ctx context.Context, req CreatePermissionReque WorkspaceID: req.WorkspaceID, Name: req.Name, Slug: req.Slug, - Description: sql.NullString{Valid: req.Description != nil, String: ptr.SafeDeref(req.Description, "")}, + Description: dbtype.NullString{Valid: req.Description != nil, String: ptr.SafeDeref(req.Description, "")}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(s.t, err) From cd26335a6de32b258155c3a65a1978ef740b60e3 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:28:21 +0200 Subject: [PATCH 16/20] only allow role lookup by name --- go/apps/api/openapi/gen.go | 6 - go/apps/api/openapi/openapi-generated.yaml | 14 +-- .../addRoles/V2KeysAddRolesRequestBody.yaml | 4 +- .../V2KeysRemoveRolesRequestBody.yaml | 4 +- .../setRoles/V2KeysSetRolesRequestBody.yaml | 4 +- .../api/routes/v2_keys_add_roles/handler.go | 8 +- .../routes/v2_keys_remove_roles/handler.go | 6 +- .../api/routes/v2_keys_set_roles/handler.go | 8 +- go/pkg/db/querier_generated.go | 20 ++++ .../role_find_many_by_name_with_perms.sql | 18 +++ ...d_many_by_name_with_perms.sql_generated.go | 108 ++++++++++++++++++ 11 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 go/pkg/db/queries/role_find_many_by_name_with_perms.sql create mode 100644 go/pkg/db/role_find_many_by_name_with_perms.sql_generated.go diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 4380fc98ea..7159fe6e96 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -855,11 +855,9 @@ type V2KeysAddRolesRequestBody struct { // Roles Assigns additional roles to the key through direct assignment to existing workspace roles. // Operations are idempotent - adding existing roles has no effect and causes no errors. - // Use either ID for existing roles or name for human-readable references. // // All roles must already exist in the workspace - roles cannot be created automatically. // Invalid roles cause the entire operation to fail atomically, ensuring consistent state. - // Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. Roles []string `json:"roles"` } @@ -1107,10 +1105,8 @@ type V2KeysRemoveRolesRequestBody struct { // Roles Removes direct role assignments from the key without affecting other role sources or permissions. // Operations are idempotent - removing non-assigned roles has no effect and causes no errors. - // Use either ID for existing roles or name for exact string matching. // // After removal, the key loses access to permissions that were only granted through these roles. - // Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. // Invalid role references cause the entire operation to fail atomically, ensuring consistent state. Roles []string `json:"roles"` } @@ -1212,12 +1208,10 @@ type V2KeysSetRolesRequestBody struct { // Roles Replaces all existing role assignments with this complete list of roles. // This is a wholesale replacement operation, not an incremental update like add/remove operations. - // Use either ID for existing roles or name for human-readable references. // // Providing an empty array removes all direct role assignments from the key. // All roles must already exist in the workspace - roles cannot be created automatically. // Invalid role references cause the entire operation to fail atomically, ensuring consistent state. - // Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. Roles []string `json:"roles"` } diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index fd22025e33..c354b4799f 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-28T09:55:01Z +# Generated at: 2025-07-28T09:57:47Z # Source: openapi-split.yaml components: @@ -605,17 +605,15 @@ components: description: | Assigns additional roles to the key through direct assignment to existing workspace roles. Operations are idempotent - adding existing roles has no effect and causes no errors. - Use either ID for existing roles or name for human-readable references. All roles must already exist in the workspace - roles cannot be created automatically. Invalid roles cause the entire operation to fail atomically, ensuring consistent state. - Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. items: type: string minLength: 3 maxLength: 255 pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" - description: References an existing role by its name or id identifier. + description: Specify the role by name. additionalProperties: false V2KeysAddRolesResponseBody: type: object @@ -957,17 +955,15 @@ components: description: | Removes direct role assignments from the key without affecting other role sources or permissions. Operations are idempotent - removing non-assigned roles has no effect and causes no errors. - Use either ID for existing roles or name for exact string matching. After removal, the key loses access to permissions that were only granted through these roles. - Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. items: type: string pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" minLength: 3 maxLength: 255 - description: Use either ID for existing roles or name for exact string matching. + description: Specify the role by name. additionalProperties: false V2KeysRemoveRolesResponseBody: type: object @@ -1046,18 +1042,16 @@ components: description: | Replaces all existing role assignments with this complete list of roles. This is a wholesale replacement operation, not an incremental update like add/remove operations. - Use either ID for existing roles or name for human-readable references. Providing an empty array removes all direct role assignments from the key. All roles must already exist in the workspace - roles cannot be created automatically. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. - Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. items: type: string pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" minLength: 3 maxLength: 255 - description: Use either ID for existing roles or name for exact string matching. + description: Specify the role by name. additionalProperties: false V2KeysSetRolesResponseBody: type: object diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml index 2bfb5ef372..dc200bf993 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml @@ -21,15 +21,13 @@ properties: description: | Assigns additional roles to the key through direct assignment to existing workspace roles. Operations are idempotent - adding existing roles has no effect and causes no errors. - Use either ID for existing roles or name for human-readable references. All roles must already exist in the workspace - roles cannot be created automatically. Invalid roles cause the entire operation to fail atomically, ensuring consistent state. - Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. items: type: string minLength: 3 maxLength: 255 # Reasonable upper bound for database identifiers pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" - description: References an existing role by its name or id identifier. + description: Specify the role by name. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml index 9ef83a9c54..ab1df769a7 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml @@ -21,15 +21,13 @@ properties: description: | Removes direct role assignments from the key without affecting other role sources or permissions. Operations are idempotent - removing non-assigned roles has no effect and causes no errors. - Use either ID for existing roles or name for exact string matching. After removal, the key loses access to permissions that were only granted through these roles. - Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. items: type: string pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" minLength: 3 maxLength: 255 - description: Use either ID for existing roles or name for exact string matching. + description: Specify the role by name. additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml index c43f06963d..82cd248731 100644 --- a/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml +++ b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml @@ -20,16 +20,14 @@ properties: description: | Replaces all existing role assignments with this complete list of roles. This is a wholesale replacement operation, not an incremental update like add/remove operations. - Use either ID for existing roles or name for human-readable references. Providing an empty array removes all direct role assignments from the key. All roles must already exist in the workspace - roles cannot be created automatically. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. - Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. items: type: string pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" minLength: 3 maxLength: 255 - description: Use either ID for existing roles or name for exact string matching. + description: Specify the role by name. additionalProperties: false diff --git a/go/apps/api/routes/v2_keys_add_roles/handler.go b/go/apps/api/routes/v2_keys_add_roles/handler.go index 5cc888469e..f1c39fef37 100644 --- a/go/apps/api/routes/v2_keys_add_roles/handler.go +++ b/go/apps/api/routes/v2_keys_add_roles/handler.go @@ -117,9 +117,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - foundRoles, err := db.Query.FindManyRolesByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindManyRolesByIdOrNameWithPermsParams{ + foundRoles, err := db.Query.FindManyRolesByNamesWithPerms(ctx, h.DB.RO(), db.FindManyRolesByNamesWithPermsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Search: req.Roles, + Names: req.Roles, }) if err != nil { return fault.Wrap(err, @@ -128,7 +128,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - foundMap := make(map[string]db.FindManyRolesByIdOrNameWithPermsRow) + foundMap := make(map[string]db.FindManyRolesByNamesWithPermsRow) for _, role := range foundRoles { foundMap[role.ID] = role foundMap[role.Name] = role @@ -152,7 +152,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { currentRoleIDs[role.ID] = true } - rolesToAdd := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) + rolesToAdd := make([]db.FindManyRolesByNamesWithPermsRow, 0) for _, role := range foundRoles { if !currentRoleIDs[role.ID] { rolesToAdd = append(rolesToAdd, role) diff --git a/go/apps/api/routes/v2_keys_remove_roles/handler.go b/go/apps/api/routes/v2_keys_remove_roles/handler.go index 1fc157377f..49437df10e 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/handler.go +++ b/go/apps/api/routes/v2_keys_remove_roles/handler.go @@ -112,9 +112,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { currentRoleIDs[role.ID] = role } - foundRoles, err := db.Query.FindManyRolesByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindManyRolesByIdOrNameWithPermsParams{ + foundRoles, err := db.Query.FindManyRolesByNamesWithPerms(ctx, h.DB.RO(), db.FindManyRolesByNamesWithPermsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Search: req.Roles, + Names: req.Roles, }) if err != nil { return fault.Wrap(err, @@ -139,7 +139,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - rolesToRemove := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) + rolesToRemove := make([]db.FindManyRolesByNamesWithPermsRow, 0) for _, role := range foundRoles { _, exists := currentRoleIDs[role.ID] if !exists { diff --git a/go/apps/api/routes/v2_keys_set_roles/handler.go b/go/apps/api/routes/v2_keys_set_roles/handler.go index fae835e136..ebcc7cbdd9 100644 --- a/go/apps/api/routes/v2_keys_set_roles/handler.go +++ b/go/apps/api/routes/v2_keys_set_roles/handler.go @@ -114,9 +114,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { currentRoleIDs[role.ID] = true } - foundRoles, err := db.Query.FindManyRolesByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindManyRolesByIdOrNameWithPermsParams{ + foundRoles, err := db.Query.FindManyRolesByNamesWithPerms(ctx, h.DB.RO(), db.FindManyRolesByNamesWithPermsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Search: req.Roles, + Names: req.Roles, }) foundMap := make(map[string]struct{}) @@ -136,7 +136,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } requestedRoleIDs := make(map[string]bool) - requestedRoleMap := make(map[string]db.FindManyRolesByIdOrNameWithPermsRow) + requestedRoleMap := make(map[string]db.FindManyRolesByNamesWithPermsRow) for _, role := range foundRoles { requestedRoleIDs[role.ID] = true requestedRoleMap[role.ID] = role @@ -150,7 +150,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - rolesToAdd := make([]db.FindManyRolesByIdOrNameWithPermsRow, 0) + rolesToAdd := make([]db.FindManyRolesByNamesWithPermsRow, 0) for _, role := range foundRoles { if !currentRoleIDs[role.ID] { rolesToAdd = append(rolesToAdd, role) diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index f25c5d2456..a29e6ab9eb 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -337,6 +337,26 @@ type Querier interface { // OR r.name IN (/*SLICE:search*/?) // ) FindManyRolesByIdOrNameWithPerms(ctx context.Context, db DBTX, arg FindManyRolesByIdOrNameWithPermsParams) ([]FindManyRolesByIdOrNameWithPermsRow, error) + //FindManyRolesByNamesWithPerms + // + // SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions + // FROM roles r + // WHERE r.workspace_id = ? AND r.name IN (/*SLICE:names*/?) + FindManyRolesByNamesWithPerms(ctx context.Context, db DBTX, arg FindManyRolesByNamesWithPermsParams) ([]FindManyRolesByNamesWithPermsRow, error) // Finds a permission record by its ID // Returns: The permission record if found // diff --git a/go/pkg/db/queries/role_find_many_by_name_with_perms.sql b/go/pkg/db/queries/role_find_many_by_name_with_perms.sql new file mode 100644 index 0000000000..231eb4349a --- /dev/null +++ b/go/pkg/db/queries/role_find_many_by_name_with_perms.sql @@ -0,0 +1,18 @@ +-- name: FindManyRolesByNamesWithPerms :many +SELECT *, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM roles r +WHERE r.workspace_id = ? AND r.name IN (sqlc.slice('names')); diff --git a/go/pkg/db/role_find_many_by_name_with_perms.sql_generated.go b/go/pkg/db/role_find_many_by_name_with_perms.sql_generated.go new file mode 100644 index 0000000000..ace31e2968 --- /dev/null +++ b/go/pkg/db/role_find_many_by_name_with_perms.sql_generated.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: role_find_many_by_name_with_perms.sql + +package db + +import ( + "context" + "database/sql" + "strings" +) + +const findManyRolesByNamesWithPerms = `-- name: FindManyRolesByNamesWithPerms :many +SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + (SELECT JSON_ARRAYAGG( + json_object( + 'id', permission.id, + 'name', permission.name, + 'slug', permission.slug, + 'description', permission.description + ) + ) + FROM (SELECT name, id, slug, description + FROM roles_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = r.id) as permission), + JSON_ARRAY() +) as permissions +FROM roles r +WHERE r.workspace_id = ? AND r.name IN (/*SLICE:names*/?) +` + +type FindManyRolesByNamesWithPermsParams struct { + WorkspaceID string `db:"workspace_id"` + Names []string `db:"names"` +} + +type FindManyRolesByNamesWithPermsRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Description sql.NullString `db:"description"` + CreatedAtM int64 `db:"created_at_m"` + UpdatedAtM sql.NullInt64 `db:"updated_at_m"` + Permissions interface{} `db:"permissions"` +} + +// FindManyRolesByNamesWithPerms +// +// SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( +// (SELECT JSON_ARRAYAGG( +// json_object( +// 'id', permission.id, +// 'name', permission.name, +// 'slug', permission.slug, +// 'description', permission.description +// ) +// ) +// FROM (SELECT name, id, slug, description +// FROM roles_permissions rp +// JOIN permissions p ON p.id = rp.permission_id +// WHERE rp.role_id = r.id) as permission), +// JSON_ARRAY() +// ) as permissions +// FROM roles r +// WHERE r.workspace_id = ? AND r.name IN (/*SLICE:names*/?) +func (q *Queries) FindManyRolesByNamesWithPerms(ctx context.Context, db DBTX, arg FindManyRolesByNamesWithPermsParams) ([]FindManyRolesByNamesWithPermsRow, error) { + query := findManyRolesByNamesWithPerms + 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 []FindManyRolesByNamesWithPermsRow + for rows.Next() { + var i FindManyRolesByNamesWithPermsRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.CreatedAtM, + &i.UpdatedAtM, + &i.Permissions, + ); 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 +} From 546fe026ee40e5ce740f1bbecd93855420425697 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:43:41 +0200 Subject: [PATCH 17/20] fix tests --- .../api/routes/v2_keys_add_roles/200_test.go | 143 +-------------- .../api/routes/v2_keys_add_roles/404_test.go | 9 +- .../routes/v2_keys_remove_roles/200_test.go | 107 +---------- .../routes/v2_keys_remove_roles/404_test.go | 9 +- .../api/routes/v2_keys_set_roles/200_test.go | 168 +----------------- 5 files changed, 28 insertions(+), 408 deletions(-) diff --git a/go/apps/api/routes/v2_keys_add_roles/200_test.go b/go/apps/api/routes/v2_keys_add_roles/200_test.go index 5ef3691e51..aa285be5aa 100644 --- a/go/apps/api/routes/v2_keys_add_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/200_test.go @@ -39,70 +39,6 @@ func TestSuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("add single role by ID", func(t *testing.T) { - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - EncryptedKeys: false, - }) - - key := h.CreateKey(seed.CreateKeyRequest{ - KeyAuthID: api.KeyAuthID.String, - WorkspaceID: workspace.ID, - }) - - roleId := h.CreateRole(seed.CreateRoleRequest{ - - WorkspaceID: workspace.ID, - Name: "admin_single_id", - Description: ptr.P("Admin Role"), - }) - - // Verify key has no roles initially - currentRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), key.KeyID) - require.NoError(t, err) - require.Empty(t, currentRoles) - - req := handler.Request{ - KeyId: key.KeyID, - Roles: []string{roleId}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 1) - require.Equal(t, roleId, res.Body.Data[0].Id) - require.Equal(t, "admin_single_id", res.Body.Data[0].Name) - - // Verify role was added to key - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), key.KeyID) - require.NoError(t, err) - require.Len(t, finalRoles, 1) - require.Equal(t, roleId, finalRoles[0].ID) - - // Verify audit log was created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), key.KeyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - foundConnectEvent := false - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.connect_role_and_key" { - foundConnectEvent = true - require.Contains(t, log.AuditLog.Display, "Added role admin_single_id to key") - break - } - } - require.True(t, foundConnectEvent, "Should find a role connect audit log event") - }) - t.Run("add single role by name", func(t *testing.T) { api := h.CreateApi(seed.CreateApiRequest{ WorkspaceID: workspace.ID, @@ -148,74 +84,6 @@ func TestSuccess(t *testing.T) { require.Equal(t, key.RolesIds[0], finalRoles[0].ID) }) - t.Run("add multiple roles mixed references", func(t *testing.T) { - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - EncryptedKeys: false, - }) - - key := h.CreateKey(seed.CreateKeyRequest{ - KeyAuthID: api.KeyAuthID.String, - WorkspaceID: workspace.ID, - }) - - adminRole := h.CreateRole(seed.CreateRoleRequest{ - WorkspaceID: workspace.ID, - Name: "admin_multi", - }) - - viewerMultiRole := h.CreateRole(seed.CreateRoleRequest{ - WorkspaceID: workspace.ID, - Name: "viewer_multi", - }) - - editorRoleName := "editor_multi" - h.CreateRole(seed.CreateRoleRequest{ - WorkspaceID: workspace.ID, - Name: editorRoleName, - }) - - req := handler.Request{ - KeyId: key.KeyID, - Roles: []string{adminRole, editorRoleName, viewerMultiRole}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 3) - - // Verify all roles are present and sorted alphabetically - roleNames := []string{res.Body.Data[0].Name, res.Body.Data[1].Name, res.Body.Data[2].Name} - require.Equal(t, []string{"admin_multi", "editor_multi", "viewer_multi"}, roleNames) - - // Verify roles were added to key - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), key.KeyID) - require.NoError(t, err) - require.Len(t, finalRoles, 3) - - // Verify audit logs were created (one for each role) - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), key.KeyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - connectEvents := 0 - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.connect_role_and_key" { - connectEvents++ - require.Contains(t, log.AuditLog.Display, "Added role") - } - } - require.Equal(t, 3, connectEvents, "Should find 3 role connect audit log events") - }) - t.Run("idempotent behavior - add existing roles", func(t *testing.T) { api := h.CreateApi(seed.CreateApiRequest{ WorkspaceID: workspace.ID, @@ -226,15 +94,18 @@ func TestSuccess(t *testing.T) { KeyAuthID: api.KeyAuthID.String, WorkspaceID: workspace.ID, }) + + adminName := "admin_idempotent" adminId := h.CreateRole(seed.CreateRoleRequest{ WorkspaceID: workspace.ID, - Name: "admin_idempotent", + Name: adminName, Description: ptr.P("admin_idempotent"), }) - editorId := h.CreateRole(seed.CreateRoleRequest{ + editorName := "editor_idempotent" + h.CreateRole(seed.CreateRoleRequest{ WorkspaceID: workspace.ID, - Name: "editor_idempotent", + Name: editorName, Description: ptr.P("editor_idempotent"), }) @@ -250,7 +121,7 @@ func TestSuccess(t *testing.T) { // Now try to add both admin (existing) and editor (new) roles req := handler.Request{ KeyId: key.KeyID, - Roles: []string{adminId, editorId}, + Roles: []string{adminName, editorName}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_add_roles/404_test.go b/go/apps/api/routes/v2_keys_add_roles/404_test.go index 0a0a55f9c2..ee7ac18935 100644 --- a/go/apps/api/routes/v2_keys_add_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_add_roles/404_test.go @@ -295,19 +295,20 @@ func TestNotFoundErrors(t *testing.T) { // Create one valid role validRoleID := uid.New(uid.TestPrefix) + validName := "admin_multiple_roles" err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ RoleID: validRoleID, WorkspaceID: workspace.ID, - Name: "admin_multiple_roles", + Name: validName, Description: sql.NullString{Valid: true, String: "Admin role"}, }) require.NoError(t, err) - invalidRoleId := "role_nonexistent123456789" + invalidName := "role_nonexistent123456789" req := handler.Request{ KeyId: keyID, - Roles: []string{validRoleID, invalidRoleId}, + Roles: []string{validName, invalidName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -320,7 +321,7 @@ func TestNotFoundErrors(t *testing.T) { require.Equal(t, 404, res.Status) require.NotNil(t, res.Body) require.NotNil(t, res.Body.Error) - require.Contains(t, res.Body.Error.Detail, invalidRoleId) + require.Contains(t, res.Body.Error.Detail, invalidName) require.Contains(t, res.Body.Error.Detail, "was not found") }) diff --git a/go/apps/api/routes/v2_keys_remove_roles/200_test.go b/go/apps/api/routes/v2_keys_remove_roles/200_test.go index 4d1b46c138..86ec55f1f8 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/200_test.go @@ -40,107 +40,7 @@ func TestSuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("remove single role by ID", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create roles - role1ID := uid.New(uid.TestPrefix) - err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: role1ID, - WorkspaceID: workspace.ID, - Name: "admin", - Description: sql.NullString{Valid: true, String: "Admin role"}, - }) - require.NoError(t, err) - - role2ID := uid.New(uid.TestPrefix) - err = db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: role2ID, - WorkspaceID: workspace.ID, - Name: "developer", - Description: sql.NullString{Valid: true, String: "Developer role"}, - }) - require.NoError(t, err) - - // Assign both roles to the key - err = db.Query.InsertKeyRole(ctx, h.DB.RW(), db.InsertKeyRoleParams{ - KeyID: keyID, - RoleID: role1ID, - WorkspaceID: workspace.ID, - CreatedAtM: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - err = db.Query.InsertKeyRole(ctx, h.DB.RW(), db.InsertKeyRoleParams{ - KeyID: keyID, - RoleID: role2ID, - WorkspaceID: workspace.ID, - CreatedAtM: time.Now().UnixMilli(), - }) - require.NoError(t, err) - - // Verify key has both roles initially - currentRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, currentRoles, 2) - - req := handler.Request{ - KeyId: keyID, - Roles: []string{role1ID}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 1) - require.Equal(t, role2ID, res.Body.Data[0].Id) - require.Equal(t, "developer", res.Body.Data[0].Name) - - // Verify role was removed from key - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, finalRoles, 1) - require.Equal(t, role2ID, finalRoles[0].ID) - - // Verify audit log was created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - foundDisconnectEvent := false - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.disconnect_role_and_key" { - foundDisconnectEvent = true - require.Contains(t, log.AuditLog.Display, "Removed role admin from key") - break - } - } - require.True(t, foundDisconnectEvent, "Should find a role disconnect audit log event") - }) - - t.Run("remove single role by name", func(t *testing.T) { + t.Run("remove single role", func(t *testing.T) { // Create API and key using testutil helpers defaultPrefix := "test" defaultBytes := int32(16) @@ -226,10 +126,11 @@ func TestSuccess(t *testing.T) { // Create a role but don't assign it to the key roleID := uid.New(uid.TestPrefix) + roleName := "unassigned_role" err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ RoleID: roleID, WorkspaceID: workspace.ID, - Name: "unassigned_role", + Name: roleName, Description: sql.NullString{Valid: true, String: "Unassigned role"}, }) require.NoError(t, err) @@ -241,7 +142,7 @@ func TestSuccess(t *testing.T) { req := handler.Request{ KeyId: keyID, - Roles: []string{roleID}, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( diff --git a/go/apps/api/routes/v2_keys_remove_roles/404_test.go b/go/apps/api/routes/v2_keys_remove_roles/404_test.go index 826d0621c0..91c7d50e8f 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_roles/404_test.go @@ -321,20 +321,21 @@ func TestNotFoundErrors(t *testing.T) { // Create one valid role validRoleID := uid.New(uid.TestPrefix) + validName := "valid_role" err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ RoleID: validRoleID, WorkspaceID: workspace.ID, - Name: "valid_role_multiple_" + uid.New(""), + Name: validName, Description: sql.NullString{Valid: true, String: "Valid role"}, }) require.NoError(t, err) // Generate a non-existent role ID - nonExistentRoleID := uid.New(uid.TestPrefix) + nonExistentRoleName := "someRole" req := handler.Request{ KeyId: keyID, - Roles: []string{validRoleID, nonExistentRoleID}, + Roles: []string{validName, nonExistentRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -349,6 +350,6 @@ func TestNotFoundErrors(t *testing.T) { require.NotNil(t, res.Body) require.Contains(t, res.Body.Error.Detail, "Role") require.Contains(t, res.Body.Error.Detail, "not found") - require.Contains(t, res.Body.Error.Detail, nonExistentRoleID) + require.Contains(t, res.Body.Error.Detail, nonExistentRoleName) }) } diff --git a/go/apps/api/routes/v2_keys_set_roles/200_test.go b/go/apps/api/routes/v2_keys_set_roles/200_test.go index 4275aebdaa..5ef4621526 100644 --- a/go/apps/api/routes/v2_keys_set_roles/200_test.go +++ b/go/apps/api/routes/v2_keys_set_roles/200_test.go @@ -40,75 +40,7 @@ func TestSuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, } - t.Run("set single role by ID", func(t *testing.T) { - // Create API with keyring using testutil helper - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - // Create a test key with role using testutil helper - roleID := h.CreateRole(seed.CreateRoleRequest{ - Name: "admin_set_single_id", - WorkspaceID: workspace.ID, - }) - - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - }) - keyID := keyResponse.KeyID - - // Verify key has no roles initially - currentRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Empty(t, currentRoles) - - req := handler.Request{ - KeyId: keyID, - Roles: []string{roleID}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 1) - require.Equal(t, roleID, res.Body.Data[0].Id) - require.Equal(t, "admin_set_single_id", res.Body.Data[0].Name) - - // Verify role was added to key - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, finalRoles, 1) - require.Equal(t, roleID, finalRoles[0].ID) - - // Verify audit log was created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - foundConnectEvent := false - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.connect_role_and_key" { - foundConnectEvent = true - require.Contains(t, log.AuditLog.Display, "Added role admin_set_single_id to key") - break - } - } - require.True(t, foundConnectEvent, "Should find a role connect audit log event") - }) - - t.Run("set single role by name", func(t *testing.T) { + t.Run("set single role", func(t *testing.T) { // Create API and key using testutil helpers defaultPrefix := "test" defaultBytes := int32(16) @@ -163,94 +95,6 @@ func TestSuccess(t *testing.T) { require.Equal(t, roleID, finalRoles[0].ID) }) - t.Run("set multiple roles mixed references", func(t *testing.T) { - // Create API and key using testutil helpers - defaultPrefix := "test" - defaultBytes := int32(16) - api := h.CreateApi(seed.CreateApiRequest{ - WorkspaceID: workspace.ID, - DefaultPrefix: &defaultPrefix, - DefaultBytes: &defaultBytes, - }) - - keyName := "Test Key" - keyResponse := h.CreateKey(seed.CreateKeyRequest{ - WorkspaceID: workspace.ID, - KeyAuthID: api.KeyAuthID.String, - Name: &keyName, - }) - keyID := keyResponse.KeyID - - // Create multiple roles - adminRoleID := uid.New(uid.TestPrefix) - err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: adminRoleID, - WorkspaceID: workspace.ID, - Name: "admin_set_multi", - Description: sql.NullString{Valid: true, String: "Admin role"}, - }) - require.NoError(t, err) - - editorRoleID := uid.New(uid.TestPrefix) - editorRoleName := "editor_set_multi" - err = db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: editorRoleID, - WorkspaceID: workspace.ID, - Name: editorRoleName, - Description: sql.NullString{Valid: true, String: "Editor role"}, - }) - require.NoError(t, err) - - viewerRoleID := uid.New(uid.TestPrefix) - err = db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ - RoleID: viewerRoleID, - WorkspaceID: workspace.ID, - Name: "viewer_set_multi", - Description: sql.NullString{Valid: true, String: "Viewer role"}, - }) - require.NoError(t, err) - - req := handler.Request{ - KeyId: keyID, - Roles: []string{adminRoleID, editorRoleName, viewerRoleID}, - } - - res := testutil.CallRoute[handler.Request, handler.Response]( - h, - route, - headers, - req, - ) - - require.Equal(t, 200, res.Status) - require.NotNil(t, res.Body) - require.NotNil(t, res.Body.Data) - require.Len(t, res.Body.Data, 3) - - // Verify all roles are present and sorted alphabetically - roleNames := []string{res.Body.Data[0].Name, res.Body.Data[1].Name, res.Body.Data[2].Name} - require.ElementsMatch(t, []string{"admin_set_multi", "editor_set_multi", "viewer_set_multi"}, roleNames) - - // Verify roles were added to key - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.Len(t, finalRoles, 3) - - // Verify audit logs were created (one for each role) - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), keyID) - require.NoError(t, err) - require.NotEmpty(t, auditLogs) - - connectEvents := 0 - for _, log := range auditLogs { - if log.AuditLog.Event == "authorization.connect_role_and_key" { - connectEvents++ - require.Contains(t, log.AuditLog.Display, "Added role") - } - } - require.Equal(t, 3, connectEvents, "Should find 3 role connect audit log events") - }) - t.Run("replace existing roles", func(t *testing.T) { // Create API and key using testutil helpers defaultPrefix := "test" @@ -280,10 +124,11 @@ func TestSuccess(t *testing.T) { require.NoError(t, err) newRoleID := uid.New(uid.TestPrefix) + roleName := "editor_replace_new" err = db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ RoleID: newRoleID, WorkspaceID: workspace.ID, - Name: "editor_replace_new", + Name: roleName, Description: sql.NullString{Valid: true, String: "New editor role"}, }) require.NoError(t, err) @@ -306,7 +151,7 @@ func TestSuccess(t *testing.T) { // Now set the key to have only the new role (should remove old, add new) req := handler.Request{ KeyId: keyID, - Roles: []string{newRoleID}, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -450,10 +295,11 @@ func TestSuccess(t *testing.T) { // Create a role and assign it to the key roleID := uid.New(uid.TestPrefix) + roleName := "admin_no_change" err := db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ RoleID: roleID, WorkspaceID: workspace.ID, - Name: "admin_no_change", + Name: roleName, Description: sql.NullString{Valid: true, String: "Admin role - no change"}, }) require.NoError(t, err) @@ -474,7 +320,7 @@ func TestSuccess(t *testing.T) { // Set roles to the same role (no change) req := handler.Request{ KeyId: keyID, - Roles: []string{roleID}, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( From ad005c7e60d3fcb2f5a59ddf22467efae94176d5 Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:03:05 +0200 Subject: [PATCH 18/20] fix comments --- go/apps/api/openapi/openapi-generated.yaml | 2 +- .../api/routes/v2_keys_remove_roles/handler.go | 4 ++-- go/apps/api/routes/v2_keys_set_roles/handler.go | 4 ++-- .../routes/v2_permissions_list_roles/handler.go | 4 ---- ...ete_many_by_key_and_role_ids.sql_generated.go | 16 ++++++++-------- go/pkg/db/querier_generated.go | 2 +- .../key_role_delete_many_by_key_and_role_ids.sql | 2 +- 7 files changed, 15 insertions(+), 19 deletions(-) diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index c354b4799f..f77e4a176c 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-28T09:57:47Z +# Generated at: 2025-07-28T09:58:09Z # Source: openapi-split.yaml components: diff --git a/go/apps/api/routes/v2_keys_remove_roles/handler.go b/go/apps/api/routes/v2_keys_remove_roles/handler.go index 49437df10e..90d9c36283 100644 --- a/go/apps/api/routes/v2_keys_remove_roles/handler.go +++ b/go/apps/api/routes/v2_keys_remove_roles/handler.go @@ -187,8 +187,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } err = db.Query.DeleteManyKeyRolesByKeyAndRoleIDs(ctx, tx, db.DeleteManyKeyRolesByKeyAndRoleIDsParams{ - KeyID: req.KeyId, - Ids: roleIds, + KeyID: req.KeyId, + RoleIds: roleIds, }) if err != nil { return fault.Wrap(err, diff --git a/go/apps/api/routes/v2_keys_set_roles/handler.go b/go/apps/api/routes/v2_keys_set_roles/handler.go index ebcc7cbdd9..082b56c7f8 100644 --- a/go/apps/api/routes/v2_keys_set_roles/handler.go +++ b/go/apps/api/routes/v2_keys_set_roles/handler.go @@ -196,8 +196,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } err = db.Query.DeleteManyKeyRolesByKeyAndRoleIDs(ctx, tx, db.DeleteManyKeyRolesByKeyAndRoleIDsParams{ - KeyID: req.KeyId, - Ids: roleIds, + KeyID: req.KeyId, + RoleIds: roleIds, }) if err != nil { return fault.Wrap(err, diff --git a/go/apps/api/routes/v2_permissions_list_roles/handler.go b/go/apps/api/routes/v2_permissions_list_roles/handler.go index 8f43e72927..56c966c830 100644 --- a/go/apps/api/routes/v2_permissions_list_roles/handler.go +++ b/go/apps/api/routes/v2_permissions_list_roles/handler.go @@ -3,7 +3,6 @@ package handler import ( "context" "encoding/json" - "log" "net/http" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -80,8 +79,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - log.Printf("roles: %#v", roles) - var nextCursor *string hasMore := len(roles) > 100 if hasMore { @@ -104,7 +101,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { rolePermissions := make([]db.Permission, 0) json.Unmarshal(role.Permissions.([]byte), &rolePermissions) - log.Printf("rolePermissions: %#v raw: %s", rolePermissions, string(role.Permissions.([]byte))) for _, perm := range rolePermissions { permission := openapi.Permission{ Id: perm.ID, diff --git a/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go b/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go index 77b8a80c90..09514ce053 100644 --- a/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go +++ b/go/pkg/db/key_role_delete_many_by_key_and_role_ids.sql_generated.go @@ -12,29 +12,29 @@ import ( const deleteManyKeyRolesByKeyAndRoleIDs = `-- name: DeleteManyKeyRolesByKeyAndRoleIDs :exec DELETE FROM keys_roles -WHERE key_id = ? AND role_id IN(/*SLICE:ids*/?) +WHERE key_id = ? AND role_id IN(/*SLICE:role_ids*/?) ` type DeleteManyKeyRolesByKeyAndRoleIDsParams struct { - KeyID string `db:"key_id"` - Ids []string `db:"ids"` + KeyID string `db:"key_id"` + RoleIds []string `db:"role_ids"` } // DeleteManyKeyRolesByKeyAndRoleIDs // // DELETE FROM keys_roles -// WHERE key_id = ? AND role_id IN(/*SLICE:ids*/?) +// WHERE key_id = ? AND role_id IN(/*SLICE:role_ids*/?) func (q *Queries) DeleteManyKeyRolesByKeyAndRoleIDs(ctx context.Context, db DBTX, arg DeleteManyKeyRolesByKeyAndRoleIDsParams) error { query := deleteManyKeyRolesByKeyAndRoleIDs var queryParams []interface{} queryParams = append(queryParams, arg.KeyID) - if len(arg.Ids) > 0 { - for _, v := range arg.Ids { + if len(arg.RoleIds) > 0 { + for _, v := range arg.RoleIds { queryParams = append(queryParams, v) } - query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1) + query = strings.Replace(query, "/*SLICE:role_ids*/?", strings.Repeat(",?", len(arg.RoleIds))[1:], 1) } else { - query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + query = strings.Replace(query, "/*SLICE:role_ids*/?", "NULL", 1) } _, err := db.ExecContext(ctx, query, queryParams...) return err diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index a29e6ab9eb..7fb021227a 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -52,7 +52,7 @@ type Querier interface { //DeleteManyKeyRolesByKeyAndRoleIDs // // DELETE FROM keys_roles - // WHERE key_id = ? AND role_id IN(/*SLICE:ids*/?) + // WHERE key_id = ? AND role_id IN(/*SLICE:role_ids*/?) DeleteManyKeyRolesByKeyAndRoleIDs(ctx context.Context, db DBTX, arg DeleteManyKeyRolesByKeyAndRoleIDsParams) error //DeleteManyKeyRolesByKeyID // diff --git a/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql b/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql index 7768a952b6..4aa44720a6 100644 --- a/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql +++ b/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql @@ -1,3 +1,3 @@ -- name: DeleteManyKeyRolesByKeyAndRoleIDs :exec DELETE FROM keys_roles -WHERE key_id = sqlc.arg('key_id') AND role_id IN(sqlc.slice('ids')); +WHERE key_id = sqlc.arg('key_id') AND role_id IN(sqlc.slice('role_ids')); From 1a31a5dcd0da65ef69fcfb25dbe36372c7c1284d Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:27:03 +0200 Subject: [PATCH 19/20] fix tests --- go/pkg/db/plugins/bulk-insert/template_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/go/pkg/db/plugins/bulk-insert/template_test.go b/go/pkg/db/plugins/bulk-insert/template_test.go index 44770a2f70..8a091a7140 100644 --- a/go/pkg/db/plugins/bulk-insert/template_test.go +++ b/go/pkg/db/plugins/bulk-insert/template_test.go @@ -25,6 +25,7 @@ func TestTemplateRenderer_Render(t *testing.T) { OnDuplicateKeyUpdate: "", EmitMethodsWithDBArgument: true, Fields: []string{"ID", "Name"}, + ValuesFields: []string{"ID", "Name"}, }, contains: []string{ "package db", @@ -47,6 +48,7 @@ func TestTemplateRenderer_Render(t *testing.T) { OnDuplicateKeyUpdate: "ON DUPLICATE KEY UPDATE name = VALUES(name)", EmitMethodsWithDBArgument: true, Fields: []string{"ID", "Name"}, + ValuesFields: []string{"ID", "Name"}, }, contains: []string{ "package db", @@ -66,6 +68,7 @@ func TestTemplateRenderer_Render(t *testing.T) { OnDuplicateKeyUpdate: "", EmitMethodsWithDBArgument: false, Fields: []string{"ID", "Name"}, + ValuesFields: []string{"ID", "Name"}, }, contains: []string{ "func (q *BulkQueries) BulkInsertUser(ctx context.Context, args []InsertUserParams) error", @@ -107,6 +110,7 @@ func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) { OnDuplicateKeyUpdate: "", EmitMethodsWithDBArgument: true, Fields: []string{"ID", "Name"}, + ValuesFields: []string{"ID", "Name"}, } _, err := renderer.Render(data) From f237276044a13fd0ba4b20094c4499c51a9cd0bb Mon Sep 17 00:00:00 2001 From: Flo <53355483+Flo4604@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:07:14 +0200 Subject: [PATCH 20/20] adjust for comments --- go/pkg/db/types/null_string.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/go/pkg/db/types/null_string.go b/go/pkg/db/types/null_string.go index 7c6f012e73..78d215d4b0 100644 --- a/go/pkg/db/types/null_string.go +++ b/go/pkg/db/types/null_string.go @@ -10,25 +10,27 @@ import ( type NullString sql.NullString // MarshalJSON implements the json.Marshaler interface. -func (x *NullString) MarshalJSON() ([]byte, error) { - if !x.Valid { +func (ns *NullString) MarshalJSON() ([]byte, error) { + if !ns.Valid { return []byte("null"), nil } - return json.Marshal(x.String) + return json.Marshal(ns.String) } // UnmarshalJSON implements the json.Unmarshaler interface. func (ns *NullString) UnmarshalJSON(data []byte) error { - val := string(data) - if val == "null" { + if string(data) == "null" { ns.Valid = false return nil } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } ns.Valid = true - ns.String = val - + ns.String = s return nil }