diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index d0286247ff..f0c55f4c7e 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -120,6 +120,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 @@ -226,9 +229,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. @@ -290,11 +290,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. @@ -424,11 +419,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. @@ -510,6 +500,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"` } @@ -735,39 +728,15 @@ 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. // 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"` + // + // 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"` } // V2KeysAddPermissionsResponseBody defines model for V2KeysAddPermissionsResponseBody. @@ -800,16 +769,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 { @@ -821,25 +781,10 @@ 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 []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. @@ -874,13 +819,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 { - // 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 { @@ -997,8 +936,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"` @@ -1034,39 +973,21 @@ 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. 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 - // - // 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 @@ -1080,35 +1001,13 @@ 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 -// -// 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 -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 { @@ -1120,24 +1019,10 @@ 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 []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. @@ -1174,46 +1059,25 @@ 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 { - // 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"` + // 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"` } // V2KeysSetPermissionsResponseBody defines model for V2KeysSetPermissionsResponseBody. @@ -1246,13 +1110,7 @@ type V2KeysSetPermissionsResponseBody struct { // - This only shows direct permissions, not those granted through roles // - An empty array means the key has no direct permissions assigned // - For a complete permission picture including roles, use keys.getKey instead -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 { @@ -1264,26 +1122,11 @@ 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 []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. @@ -1320,13 +1163,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 { - // 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 { @@ -1408,16 +1245,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 { // Credits Controls credit consumption for usage-based billing and quota enforcement. @@ -1649,7 +1483,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 @@ -1661,20 +1497,22 @@ 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"` } // 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 @@ -1683,23 +1521,25 @@ 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"` } // 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"` } // V2PermissionsGetPermissionRequestBody defines model for V2PermissionsGetPermissionRequestBody. type V2PermissionsGetPermissionRequestBody struct { - // PermissionId The unique identifier of the permission to retrieve. Must be a valid permission ID that begins with 'perm_' and exists within your workspace. - PermissionId string `json:"permissionId"` + // Permission The unique identifier of the permission to retrieve. Must be a valid permission ID that begins with 'perm_' and exists within your workspace. + Permission string `json:"permission"` } // V2PermissionsGetPermissionResponseBody defines model for V2PermissionsGetPermissionResponseBody. @@ -1718,11 +1558,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 41edd5c7ca..5c6e74fd27 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -176,9 +176,12 @@ components: type: object required: - meta + - data properties: meta: $ref: "#/components/schemas/Meta" + data: + $ref: "#/components/schemas/EmptyResponse" additionalProperties: false NotFoundErrorResponse: type: object @@ -522,8 +525,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 @@ -532,48 +533,15 @@ 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. 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: 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: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ + description: Specify the permission by its slug. additionalProperties: false V2KeysAddPermissionsResponseBody: type: object @@ -609,38 +577,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: 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: Specify the role by name. additionalProperties: false V2KeysAddRolesResponseBody: type: object @@ -860,11 +805,12 @@ components: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" data: - "$ref": "#/components/schemas/KeysDeleteKeyResponseData" + "$ref": "#/components/schemas/EmptyResponse" additionalProperties: false V2KeysGetKeyRequestBody: type: object @@ -918,8 +864,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 @@ -927,35 +871,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: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ + description: Specify the permission by its slug. additionalProperties: false V2KeysRemovePermissionsResponseBody: type: object @@ -991,37 +915,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: 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: Specify the role by name. additionalProperties: false V2KeysRemoveRolesResponseBody: type: object @@ -1041,48 +943,31 @@ 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. + 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: 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: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ + description: Specify the permission by its slug. additionalProperties: false V2KeysSetPermissionsResponseBody: type: object @@ -1117,39 +1002,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: 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: Specify the role by name. additionalProperties: false V2KeysSetRolesResponseBody: type: object @@ -1327,9 +1189,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`. @@ -1343,11 +1205,12 @@ components: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" data: - "$ref": "#/components/schemas/V2KeysUpdateKeyResponseData" + "$ref": "#/components/schemas/EmptyResponse" additionalProperties: false V2KeysVerifyKeyRequestBody: type: object @@ -1558,16 +1421,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 @@ -1578,30 +1443,32 @@ 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 required: - - roleId + - role 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 @@ -1610,7 +1477,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 @@ -1620,20 +1486,23 @@ components: type: object required: - meta + - data properties: meta: "$ref": "#/components/schemas/Meta" + data: + "$ref": "#/components/schemas/EmptyResponse" additionalProperties: false V2PermissionsGetPermissionRequestBody: 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: | The unique identifier of the permission to retrieve. Must be a valid permission ID that begins with 'perm_' and exists within your workspace. example: perm_1234567890abcdef @@ -1652,16 +1521,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 @@ -2127,6 +1997,10 @@ components: required: - apiId additionalProperties: false + EmptyResponse: + type: object + additionalProperties: false + description: Empty response object by design. A successful response indicates this operation was successfully executed. V2ApisGetApiResponseData: type: object properties: @@ -2245,7 +2119,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 @@ -2484,25 +2358,50 @@ 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: + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ + 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: |- @@ -2519,19 +2418,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 + "$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: @@ -2546,54 +2475,19 @@ 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: |- 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 - - 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 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: |- @@ -2611,19 +2505,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: |- @@ -2639,19 +2521,7 @@ components: - An empty array means the key has no direct permissions assigned - For a complete permission picture including roles, use keys.getKey instead 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: |- @@ -2669,23 +2539,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 - V2KeysUpdateKeyResponseData: - type: object - additionalProperties: false - description: Empty response object by design. A successful response indicates the key was updated successfully. + "$ref": "#/components/schemas/role" KeysVerifyKeyCredits: type: object required: @@ -2953,60 +2807,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: @@ -3021,9 +2821,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. @@ -3031,8 +2828,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. @@ -3041,23 +2836,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: @@ -3073,7 +2857,6 @@ components: - id - name - permissions - - createdAt additionalProperties: false V2PermissionsListPermissionsResponseData: type: array 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..e315734956 100644 --- a/go/apps/api/openapi/spec/common/permission.yaml +++ b/go/apps/api/openapi/spec/common/permission.yaml @@ -21,11 +21,11 @@ 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 - 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 +36,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/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/apis/deleteApi/V2ApisDeleteApiResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/apis/deleteApi/V2ApisDeleteApiResponseBody.yaml index 68a5eb5005..febea0b86a 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,9 +1,12 @@ type: object required: - meta + - data properties: meta: $ref: "../../../../common/Meta.yaml" + data: + $ref: "../../../../common/EmptyResponse.yaml" additionalProperties: false examples: successfulDeletion: 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..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 @@ -11,56 +11,21 @@ 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. 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 + 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: 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/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/addRoles/V2KeysAddRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml index 196162b2e6..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,36 +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: 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: Specify the role by name. 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..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,24 +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 + "$ref": "../../../../common/role.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 84ed74a431..188314a439 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,11 +1,12 @@ type: object required: - meta + - data properties: meta: "$ref": "../../../../common/Meta.yaml" data: - "$ref": "./KeysDeleteKeyResponseData.yaml" + "$ref": "../../../../common/EmptyResponse.yaml" additionalProperties: false examples: userDeletionSuccess: 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..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 @@ -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: 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/V2KeysRemovePermissionsResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsResponseData.yaml index 6d2a8f3e69..6d8b12e17f 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,47 +2,11 @@ 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 - - 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 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/removeRoles/V2KeysRemoveRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml index 48fc67e54a..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 @@ -17,39 +17,17 @@ 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. - 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: 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: Specify the role by name. additionalProperties: false 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/setPermissions/V2KeysSetPermissionsRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml index cab2154e21..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 @@ -5,58 +5,29 @@ 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. + 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: 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: 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/V2KeysSetPermissionsResponseData.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsResponseData.yaml index cca946b1de..d072337a9c 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 @@ -12,16 +12,4 @@ description: |- - An empty array means the key has no direct permissions assigned - For a complete permission picture including roles, use keys.getKey instead 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/setRoles/V2KeysSetRolesRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml index 7146ef9871..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,37 +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: 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: Specify the role by name. 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..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,24 +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 + "$ref": "../../../../common/role.yaml" 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/updateKey/V2KeysUpdateKeyResponseBody.yaml b/go/apps/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml index 2ab7ea24b3..5038b683b5 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,11 +1,12 @@ type: object required: - meta + - data properties: meta: "$ref": "../../../../common/Meta.yaml" data: - "$ref": "./V2KeysUpdateKeyResponseData.yaml" + "$ref": "../../../../common/EmptyResponse.yaml" additionalProperties: false examples: basicUpdateSuccess: 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/V2PermissionsDeleteRoleRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/deleteRole/V2PermissionsDeleteRoleRequestBody.yaml index f431e64687..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,15 +1,15 @@ type: object required: - - roleId + - role 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/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/openapi/spec/paths/v2/permissions/getPermission/V2PermissionsGetPermissionRequestBody.yaml b/go/apps/api/openapi/spec/paths/v2/permissions/getPermission/V2PermissionsGetPermissionRequestBody.yaml index 02ed43e1ac..46d154a0c7 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,12 +1,12 @@ 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: | The unique identifier of the permission to retrieve. Must be a valid permission ID that begins with 'perm_' and exists within your workspace. example: perm_1234567890abcdef 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_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_permissions/200_test.go b/go/apps/api/routes/v2_keys_add_permissions/200_test.go index 6367fb921e..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 @@ -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", "rbac.*.create_permission") // Set up request headers headers := http.Header{ @@ -37,86 +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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &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" @@ -152,14 +72,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 +136,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{permission1Name, permission2Slug}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -245,11 +152,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) @@ -278,22 +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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permissionName}, } // Add permission first time @@ -341,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, }) @@ -366,14 +276,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{newPermissionSlug}, } 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..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" @@ -33,7 +34,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{ @@ -179,19 +180,13 @@ 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) 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]( @@ -204,21 +199,15 @@ 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) { 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 +224,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]( @@ -269,21 +250,15 @@ 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) 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..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,18 +2,17 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" - "time" "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/hash" + 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" ) @@ -34,56 +33,38 @@ 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", 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) 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: key.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..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,18 +2,17 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" - "time" "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/hash" + 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" ) @@ -34,56 +33,46 @@ 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", 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) 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: key.KeyID, + Permissions: []string{permissionID}, } t.Run("root key without required permissions", func(t *testing.T) { @@ -129,60 +118,41 @@ 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, - Permissions: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: diffKey.KeyID, + Permissions: []string{permissionID}, } headers := http.Header{ @@ -202,59 +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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &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 d7d8f11f91..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" @@ -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{ @@ -43,13 +43,14 @@ 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", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Name: permissionSlug, + Slug: permissionSlug, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -57,14 +58,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{permissionSlug}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -79,155 +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.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}, - }, - } - - 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("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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &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) @@ -263,19 +109,13 @@ 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) 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..0b02fff491 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -2,9 +2,9 @@ package handler import ( "context" + "database/sql" "fmt" "net/http" - "slices" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -14,9 +14,11 @@ 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" + "github.com/unkeyed/unkey/go/pkg/uid" "github.com/unkeyed/unkey/go/pkg/zen" ) @@ -43,32 +45,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 +75,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 +82,33 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 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.AddPermissionToKey, + }), + ), + ), + )) + if err != nil { + return err + } + currentPermissions, err := db.Query.ListDirectPermissionsByKeyID(ctx, h.DB.RO(), req.KeyId) if err != nil { return fault.Wrap(err, @@ -99,94 +117,128 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Convert current permissions to a map for efficient lookup - currentPermissionIDs := make(map[string]bool) + foundPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Slugs: 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."), + ) + } + + 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] = 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 + for _, permission := range req.Permissions { + missingPermissions[permission] = struct{}{} + } - 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'."), - ) - } + for _, permission := range foundPermissions { + delete(missingPermissions, permission.ID) + delete(missingPermissions, permission.Slug) + } - // 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)), - ) + for _, permission := range foundPermissions { + _, ok := currentPermissionIDs[permission.ID] + if ok { + continue } - requestedPermissions = append(requestedPermissions, permission) + permissionsToSet = append(permissionsToSet, permission) } - // 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) + for perm := range missingPermissions { + 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) + now := time.Now().UnixMilli() + permissionsToInsert = append(permissionsToInsert, db.InsertPermissionParams{ + PermissionID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: dbtype.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) + + permissionsToSet = append(permissionsToSet, db.Permission{ + ID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: dbtype.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) } - // 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 + 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."), + ) + } + } + + 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{ @@ -201,16 +253,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{}, }, @@ -218,57 +270,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_add_roles/200_test.go b/go/apps/api/routes/v2_keys_add_roles/200_test.go index 776ebd887a..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 @@ -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{ @@ -39,75 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &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, @@ -129,12 +60,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]( @@ -158,81 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &adminRole}, // By ID - {Name: &editorRoleName}, // By name - {Id: &viewerMultiRole}, // By ID - }, - } - - 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, @@ -243,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"), }) @@ -267,13 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &adminId}, // Already exists - {Id: &editorId}, // New role - }, + Roles: []string{adminName, editorName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -312,55 +160,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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - { - Id: &adminID, - Name: &editorRoleName, // This should be ignored, ID takes precedence - }, - }, - } - - 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 b498325444..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{ @@ -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..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 @@ -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{ @@ -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]( @@ -151,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") }) @@ -177,12 +161,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]( @@ -195,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") }) @@ -231,12 +209,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 +258,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]( @@ -327,25 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &validRoleID}, // Valid role - {Id: &invalidRoleId}, // Invalid role - should cause 404 - }, + Roles: []string{validName, invalidName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( @@ -358,8 +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, "Role with ID") - 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") }) @@ -400,12 +362,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]( @@ -455,12 +412,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]( @@ -473,7 +425,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 1ff2a5f2c2..f1c39fef37 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.FindManyRolesByNamesWithPerms(ctx, h.DB.RO(), db.FindManyRolesByNamesWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Names: req.Roles, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to 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.FindManyRolesByNamesWithPermsRow) + 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,25 @@ 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.FindManyRolesByNamesWithPermsRow, 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 + rolesToInsert := make([]db.InsertKeyRoleParams, 0) - // Add new roles 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, @@ -209,14 +184,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, @@ -226,12 +201,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 +224,53 @@ 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_create_key/handler.go b/go/apps/api/routes/v2_keys_create_key/handler.go index 59e55afe2e..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" @@ -363,13 +364,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 +385,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: dbtype.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: dbtype.NullString{String: "", Valid: false}, + UpdatedAtM: sql.NullInt64{Int64: 0, Valid: false}, }) } @@ -426,14 +432,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 +519,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 +561,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_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..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" @@ -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{ @@ -40,83 +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: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &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" @@ -147,13 +70,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]( @@ -195,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, }) @@ -229,14 +148,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{permission1Name, permission2Name}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -277,21 +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: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, - }, + KeyId: keyID, + Permissions: []string{permission1Name}, } // Remove permission (which isn't assigned) @@ -347,17 +256,18 @@ 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) 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", - Description: sql.NullString{Valid: true, String: "Write documents permission"}, + Name: removePermissionName, + Slug: removePermissionName, + Description: dbtype.NullString{Valid: true, String: "Write documents permission"}, }) require.NoError(t, err) @@ -379,13 +289,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{removePermissionName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -427,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, }) @@ -482,13 +390,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{ + permission1Name, + permission2Name, + permission3Name, }, } 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..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" @@ -156,92 +156,6 @@ 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}, - }, - } - - 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: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &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, "was not found") - }) - t.Run("key not found", func(t *testing.T) { // Create a permission that exists permissionID := uid.New(uid.TestPrefix) @@ -250,20 +164,15 @@ 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) 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..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" @@ -62,13 +61,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 +139,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{ @@ -171,58 +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: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &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 75e77c0270..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" @@ -34,7 +35,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{ @@ -56,13 +57,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 +95,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]( @@ -120,49 +111,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: []struct { - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &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) @@ -181,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) @@ -218,13 +166,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]( @@ -289,18 +232,13 @@ 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) 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..ab918d8e6d 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" @@ -46,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.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 } - // 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,107 +80,98 @@ 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, 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 } - // 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 + foundPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Slugs: 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 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'."), - ) - } + existingPermissions := make(map[string]db.Permission) + for _, permission := range foundPermissions { + existingPermissions[permission.ID] = permission + existingPermissions[permission.Slug] = permission + } - // Validate permission belongs to the same workspace - if permission.WorkspaceID != auth.AuthorizedWorkspaceID { + for _, toRemove := range req.Permissions { + _, exists := existingPermissions[toRemove] + + if !exists { 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 %q was not found.", toRemove)), ) } - - requestedPermissions = append(requestedPermissions, permission) } - // 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 + var idsToRemove []string - // 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, @@ -202,16 +184,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{}, }, @@ -219,12 +201,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 +227,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_remove_roles/200_test.go b/go/apps/api/routes/v2_keys_remove_roles/200_test.go index 83a6e2dc5c..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 @@ -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{ @@ -40,112 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &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) @@ -190,12 +85,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]( @@ -236,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) @@ -251,12 +142,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{roleName}, } 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..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{ @@ -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..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 @@ -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{ @@ -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]( @@ -351,83 +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) - - 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 - }, - } - - 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, 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" + nonExistentRoleName := "someRole" 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 - }, + Roles: []string{validName, nonExistentRoleName}, } res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse]( 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..90d9c36283 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,10 @@ package handler import ( "context" + "database/sql" + "encoding/json" "fmt" "net/http" - "slices" "github.com/unkeyed/unkey/go/apps/api/openapi" "github.com/unkeyed/unkey/go/internal/services/auditlogs" @@ -45,20 +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.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 +76,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 +91,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 +99,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 +107,56 @@ 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.FindManyRolesByNamesWithPerms(ctx, h.DB.RO(), db.FindManyRolesByNamesWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Names: req.Roles, + }) + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), fault.Public("Failed to 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{}{} + } - // 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 { - if _, exists := currentRoleIDs[role.ID]; exists { - rolesToRemove = append(rolesToRemove, role) + rolesToRemove := make([]db.FindManyRolesByNamesWithPermsRow, 0) + for _, role := range foundRoles { + _, exists := currentRoleIDs[role.ID] + if !exists { + continue } + + rolesToRemove = append(rolesToRemove, role) + delete(currentRoleIDs, role.ID) } - // 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, @@ -206,14 +169,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, @@ -223,12 +186,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, + RoleIds: 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,38 +212,36 @@ 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 + responseData := make(openapi.V2KeysRemoveRolesResponseData, 0) + for _, role := range currentRoleIDs { + r := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, + Permissions: nil, } - 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, + 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_permissions/200_test.go b/go/apps/api/routes/v2_keys_set_permissions/200_test.go index 5024879782..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" @@ -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", "rbac.*.create_permission") // Set up request headers headers := http.Header{ @@ -61,32 +61,35 @@ 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", - Description: sql.NullString{Valid: true, String: "Initial permission"}, + Name: permission1Slug, + Slug: permission1Slug, + Description: dbtype.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", - Description: sql.NullString{Valid: true, String: "Write permission"}, + Name: permission2Slug, + Slug: permission2Slug, + Description: dbtype.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", - Description: sql.NullString{Valid: true, String: "Delete permission"}, + Name: permission3Slug, + Slug: permission3Slug, + Description: dbtype.NullString{Valid: true, String: "Delete permission"}, }) require.NoError(t, err) @@ -106,15 +109,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{permission2Slug, permission3Slug}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -129,11 +125,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) @@ -174,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) @@ -184,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) @@ -198,14 +201,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]( @@ -254,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) @@ -264,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) @@ -290,12 +287,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]( @@ -335,13 +328,14 @@ 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", - Description: sql.NullString{Valid: true, String: "Read permission"}, + Name: permissionSlugAndName, + Slug: permissionSlugAndName, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) @@ -355,14 +349,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{permissionSlugAndName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -405,20 +393,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..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 @@ -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]( @@ -214,51 +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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - { - Id: &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/401_test.go b/go/apps/api/routes/v2_keys_set_permissions/401_test.go index 53a9ed80df..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,19 +72,13 @@ 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) 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..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,18 +2,17 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" - "time" "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/hash" + 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" ) @@ -34,56 +33,46 @@ 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", 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) 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: key.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 63a82c32a7..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" @@ -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{ @@ -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) @@ -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]( @@ -79,95 +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.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}, - }, - } - - 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 '%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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Slug: &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() @@ -196,19 +101,13 @@ 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) 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]( @@ -223,117 +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("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: []struct { - Create *bool `json:"create,omitempty"` - Id *string `json:"id,omitempty"` - Slug *string `json:"slug,omitempty"` - }{ - {Id: &permissionID}, // Permission from different workspace - }, - } - - 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" - 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.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 - }, - } - - 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 '%s' 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 b50f091019..9d3e911551 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" - "slices" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" @@ -15,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" @@ -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,38 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 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.And( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.AddPermissionToKey, + }), + 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, @@ -104,108 +125,79 @@ 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 + foundPermissions, err := db.Query.FindPermissionsBySlugs(ctx, h.DB.RO(), db.FindPermissionsBySlugsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Slugs: 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."), + ) + } - // 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{}) + permissionsToSet := make([]db.Permission, 0) + permissionsToInsert := make([]db.InsertPermissionParams, 0) - // 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.Slug)), - ) + for _, permission := range req.Permissions { + missingPermissions[permission] = struct{}{} + } + + for _, permission := range foundPermissions { + delete(missingPermissions, permission.ID) + delete(missingPermissions, permission.Slug) + } + + for _, permission := range foundPermissions { + permissionsToSet = append(permissionsToSet, permission) + } + + for perm := range missingPermissions { + err = auth.Verify(ctx, keys.WithPermissions( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Rbac, + ResourceID: "*", + Action: rbac.CreatePermission, + }), + )) + if err != nil { + return err } - 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: dbtype.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) + + permissionsToSet = append(permissionsToSet, db.Permission{ + ID: permissionID, + Name: perm, + WorkspaceID: auth.AuthorizedWorkspaceID, + Slug: perm, + Description: dbtype.NullString{String: "", Valid: false}, + CreatedAtM: now, + }) } - // 7. Calculate differential update - // Create maps for efficient lookup - 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) 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] { @@ -214,119 +206,156 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } permissionsToAdd := make([]db.Permission, 0) - for _, permission := range requestedPermissions { - if !currentPermissionIDs[permission.ID] { + for _, permission := range permissionsToSet { + _, ok := currentPermissionMap[permission.ID] + if !ok { 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 - // 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 _, permissionID := range permissionsToRemove { + perm := currentPermissionMap[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: auditlog.KeyResourceType, + ID: req.KeyId, + Name: key.Name.String, + DisplayName: key.Name.String, + Meta: map[string]any{}, + }, + { + Type: auditlog.PermissionResourceType, + ID: permissionID, + Name: perm.Slug, + DisplayName: perm.Name, + 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{}, + 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, + }, + }, }, - { - Type: "permission", - ID: permissionID, - Name: permissionName, - DisplayName: permissionName, - 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.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 add permission assignment."), + fault.Internal("database error"), + fault.Public("Failed to insert permissions."), ) } + } - 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{}, + 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: now, + UpdatedAt: sql.NullInt64{Valid: true, Int64: now}, + } + + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.AuthConnectPermissionKeyEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Added permission %s to key %s", permission.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.PermissionResourceType, + ID: permission.ID, + Name: permission.Slug, + DisplayName: permission.Name, + Meta: map[string]any{}, + }, }, - }, - }) - } + }) + } - // Insert audit logs - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) + err = db.BulkQuery.InsertKeyPermissions(ctx, tx, toAdd) if err != nil { - return err + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to add permissions to key."), + ) } } + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err + } + return nil }) if err != nil { @@ -335,38 +364,22 @@ 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 + responseData := make(openapi.V2KeysSetPermissionsResponseData, 0) + for _, permission := range permissionsToSet { + perm := openapi.Permission{ + Description: nil, + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, } - 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, + 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(), 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..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,80 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &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) @@ -144,12 +71,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]( @@ -173,101 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &adminRoleID}, // By ID - {Name: &editorRoleName}, // By name - {Id: &viewerRoleID}, // By ID - }, - } - - 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_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" @@ -297,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) @@ -323,12 +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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - {Id: &newRoleID}, // Replace old with new - }, + Roles: []string{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -416,10 +239,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]( @@ -475,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) @@ -499,12 +320,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{roleName}, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -533,78 +349,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: []struct { - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - }{ - { - Id: &role1ID, - Name: &role2Name, // This should be ignored, ID takes precedence - }, - }, - } - - 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/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..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 @@ -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]( @@ -126,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 %q was not found", nonExistentRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -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]( @@ -155,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 %q was not found", nonExistentRoleName)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -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]( @@ -273,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) }) @@ -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]( @@ -300,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 %q was not found", otherRoleName)) require.Equal(t, 404, res.Body.Error.Status) }) }) @@ -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]( @@ -331,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 %q was not found", nonExistentRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) @@ -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]( @@ -391,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 %q was not found", validFormattedRoleID)) require.Equal(t, 404, res.Body.Error.Status) }) } 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..082b56c7f8 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.FindManyRolesByNamesWithPerms(ctx, h.DB.RO(), db.FindManyRolesByNamesWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Names: 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.FindManyRolesByNamesWithPermsRow) + 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.FindManyRolesByNamesWithPermsRow, 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, + RoleIds: 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: "key", - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: "role", - 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: "key", - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: "role", - 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_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 9304762520..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" @@ -88,14 +89,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, @@ -112,9 +106,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()}, @@ -387,13 +381,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] @@ -408,17 +402,43 @@ 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(), }) - requestedPermissions = append(requestedPermissions, db.FindPermissionsBySlugsRow{ + requestedPermissions = append(requestedPermissions, db.Permission{ ID: newPermID, Slug: requestedSlug, }) } 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 +449,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 +458,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}, }) } @@ -537,14 +558,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, @@ -569,6 +590,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_create_permission/handler.go b/go/apps/api/routes/v2_permissions_create_permission/handler.go index a4d169b7ac..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 { @@ -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..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, @@ -106,7 +107,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 +117,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/200_test.go b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go index b21aaf989f..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) @@ -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]( @@ -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) @@ -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..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 @@ -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]( @@ -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{ - PermissionId: "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_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..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) @@ -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..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" ) @@ -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]( @@ -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) @@ -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..55a902fbb6 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,27 @@ 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, + Search: 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 +89,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 +97,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,26 +105,26 @@ 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, - Event: "permission.delete", + Event: auditlog.PermissionDeleteEvent, ActorType: auditlog.RootKeyActor, 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, - Name: permission.Name, + Type: auditlog.PermissionResourceType, + ID: permission.ID, + Name: permission.Slug, DisplayName: permission.Name, Meta: map[string]interface{}{ "name": permission.Name, + "slug": permission.Slug, "description": permission.Description.String, }, }, @@ -152,7 +141,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_delete_role/200_test.go b/go/apps/api/routes/v2_permissions_delete_role/200_test.go index b19b8e43dc..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) @@ -108,7 +109,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 +168,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 ac6a1872d5..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,22 +105,21 @@ 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, - Event: "role.delete", + Event: auditlog.RoleDeleteEvent, ActorType: auditlog.RootKeyActor, 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: "role", - ID: req.RoleId, + Type: auditlog.RoleResourceType, + ID: role.ID, Name: role.Name, DisplayName: role.Name, Meta: map[string]interface{}{ 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..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" ) @@ -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: dbtype.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]( @@ -79,7 +81,28 @@ 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) + }) + + 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 @@ -93,14 +116,14 @@ 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) // 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..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) @@ -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 f677d44f54..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,24 +77,13 @@ 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, Slug: permission.Slug, Description: nil, - CreatedAt: permission.CreatedAtM, } - // Add description only if it's valid if permission.Description.Valid { permissionResponse.Description = &permission.Description.String } 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..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) @@ -80,7 +81,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 +103,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) @@ -124,7 +124,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{ @@ -137,7 +137,7 @@ func TestSuccess(t *testing.T) { // Now retrieve the role req := handler.Request{ - RoleId: roleID, + Role: roleName, } res := testutil.CallRoute[handler.Request, handler.Response]( @@ -160,7 +160,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_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 80589d8e40..a9cf7d0959 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", @@ -72,37 +72,31 @@ 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."), ) } - // 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."), - ) + roleResponse := openapi.Role{ + Id: role.ID, + Name: role.Name, + Permissions: nil, + Description: nil, } - // 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."), - ) + if role.Description.Valid { + roleResponse.Description = &role.Description.String } - // 7. Transform permissions to the response format - permissions := make([]openapi.Permission, 0, len(rolePermissions)) + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) for _, perm := range rolePermissions { permission := openapi.Permission{ Id: perm.ID, Name: perm.Name, Slug: perm.Slug, - CreatedAt: perm.CreatedAtM, Description: nil, } @@ -111,21 +105,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { permission.Description = &perm.Description.String } - 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 + roleResponse.Permissions = append(roleResponse.Permissions, permission) } return s.JSON(http.StatusOK, Response{ 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_permissions/handler.go b/go/apps/api/routes/v2_permissions_list_permissions/handler.go index cef8e67edc..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(), @@ -101,7 +96,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/200_test.go b/go/apps/api/routes/v2_permissions_list_roles/200_test.go index 70f4f35b66..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) @@ -129,7 +130,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 4d546b0a98..56c966c830 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,65 +79,46 @@ 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."), - ) + roleResponse := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, + Permissions: nil, + } + + if role.Description.Valid { + roleResponse.Description = &role.Description.String } - // Transform permissions - permissions := make([]openapi.Permission, 0, len(rolePermissions)) + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) for _, perm := range rolePermissions { permission := openapi.Permission{ Id: perm.ID, Name: perm.Name, - CreatedAt: perm.CreatedAtM, Slug: perm.Slug, Description: nil, } - // Add description only if it's valid if perm.Description.Valid { permission.Description = &perm.Description.String } - 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 + roleResponse.Permissions = append(roleResponse.Permissions, permission) } 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/bulk_key_permission_insert.sql.go b/go/pkg/db/bulk_key_permission_insert.sql.go index f796f78dc7..f12060f1ea 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 { @@ -35,6 +35,11 @@ func (q *BulkQueries) InsertKeyPermissions(ctx context.Context, db DBTX, args [] allArgs = append(allArgs, arg.CreatedAt) } + // 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 _, err := db.ExecContext(ctx, bulkQuery, allArgs...) return err 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/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/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/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..09514ce053 --- /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:role_ids*/?) +` + +type DeleteManyKeyRolesByKeyAndRoleIDsParams struct { + KeyID string `db:"key_id"` + RoleIds []string `db:"role_ids"` +} + +// DeleteManyKeyRolesByKeyAndRoleIDs +// +// DELETE FROM keys_roles +// 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.RoleIds) > 0 { + for _, v := range arg.RoleIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:role_ids*/?", strings.Repeat(",?", len(arg.RoleIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:role_ids*/?", "NULL", 1) + } + _, err := db.ExecContext(ctx, query, queryParams...) + return 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_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..e4b0edf06b --- /dev/null +++ b/go/pkg/db/permission_find_by_id_or_slug.sql_generated.go @@ -0,0 +1,41 @@ +// 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"` + Search string `db:"search"` +} + +// 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.Search, arg.Search) + 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_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_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/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. 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) diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index fee284154f..33bdb503f9 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -39,11 +39,21 @@ 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 // 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:role_ids*/?) + DeleteManyKeyRolesByKeyAndRoleIDs(ctx context.Context, db DBTX, arg DeleteManyKeyRolesByKeyAndRoleIDsParams) error //DeleteManyKeyRolesByKeyID // // DELETE FROM keys_roles @@ -304,6 +314,49 @@ 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) + //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 // @@ -312,6 +365,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 @@ -330,8 +389,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 @@ -417,6 +476,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 // @@ -724,7 +806,7 @@ type Querier interface { // ?, // ?, // ? - // ) + // ) ON DUPLICATE KEY UPDATE updated_at_m = ? InsertKeyPermission(ctx context.Context, db DBTX, arg InsertKeyPermissionParams) error //InsertKeyRatelimit // @@ -1101,21 +1183,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_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/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); 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..4aa44720a6 --- /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('role_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..6cc3941d24 --- /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 = sqlc.arg('search') OR slug = sqlc.arg('search')); 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/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_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/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_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 +} 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 } 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..78d215d4b0 --- /dev/null +++ b/go/pkg/db/types/null_string.go @@ -0,0 +1,45 @@ +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 (ns *NullString) MarshalJSON() ([]byte, error) { + if !ns.Valid { + return []byte("null"), nil + } + + return json.Marshal(ns.String) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ns *NullString) UnmarshalJSON(data []byte) error { + 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 = s + 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) 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 } }