diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 8d711f50de..7159fe6e96 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -493,11 +493,6 @@ type RatelimitResponse struct { // Role defines model for Role. type Role struct { - // CreatedAt Unix timestamp in milliseconds indicating when this role was first created. - // Useful for auditing and understanding the evolution of your access control structure. - // Automatically set by the system and cannot be modified. - CreatedAt int64 `json:"createdAt"` - // Description Optional detailed explanation of what this role encompasses and what access it provides. // Helps team members understand the role's scope, intended use cases, and security implications. // Include information about what types of users should receive this role and what they can accomplish. @@ -860,25 +855,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. @@ -913,13 +893,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 { @@ -1131,24 +1105,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. @@ -1185,13 +1145,7 @@ type V2KeysRemoveRolesResponseBody struct { // - This only shows direct role assignments // - Role permissions are not expanded in this response - use keys.getKey for full details // - Changes take effect immediately for new verifications but cached sessions may retain old permissions briefly -type V2KeysRemoveRolesResponseData = []struct { - // Id The unique identifier of the role (begins with `role_`). This ID can be used in other API calls to reference this specific role. - Id string `json:"id"` - - // Name The name of the role. This is a human-readable identifier that's unique within your workspace. - Name string `json:"name"` -} +type V2KeysRemoveRolesResponseData = []Role // V2KeysSetPermissionsRequestBody defines model for V2KeysSetPermissionsRequestBody. type V2KeysSetPermissionsRequestBody struct { @@ -1254,26 +1208,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. @@ -1310,13 +1249,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 { @@ -1582,8 +1515,8 @@ type V2PermissionsDeletePermissionResponseBody struct { // V2PermissionsDeleteRoleRequestBody defines model for V2PermissionsDeleteRoleRequestBody. type V2PermissionsDeleteRoleRequestBody struct { - // RoleId Unique identifier of the role to permanently delete from your workspace. - // Must be a valid role ID that begins with 'role_' and exists within your workspace. + // Role Unique identifier of the role to permanently delete from your workspace. + // Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. // // WARNING: Deletion is immediate and irreversible with significant consequences: // - All API keys assigned this role will lose the associated permissions @@ -1592,11 +1525,10 @@ type V2PermissionsDeleteRoleRequestBody struct { // - Historical analytics referencing this role remain intact // // Before deletion, ensure: - // - You have the correct role ID (verify the role name and permissions) // - You've updated any dependent authorization logic or code // - You've migrated any keys to use alternative roles or direct permissions // - You've notified relevant team members of the access changes - RoleId string `json:"roleId"` + Role string `json:"role"` } // V2PermissionsDeleteRoleResponseBody defines model for V2PermissionsDeleteRoleResponseBody. @@ -1630,11 +1562,12 @@ type V2PermissionsGetPermissionResponseData struct { // V2PermissionsGetRoleRequestBody defines model for V2PermissionsGetRoleRequestBody. type V2PermissionsGetRoleRequestBody struct { - // RoleId Specifies which role to retrieve by its unique identifier. - // Must be a valid role ID that begins with 'role_' and exists within your workspace. + // Role Unique identifier of the role to permanently delete from your workspace. + // Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. + // // Use this endpoint to verify role details, check its current permissions, or retrieve metadata. // Returns complete role information including all assigned permissions for comprehensive access review. - RoleId string `json:"roleId"` + Role string `json:"role"` } // V2PermissionsGetRoleResponseBody defines model for V2PermissionsGetRoleResponseBody. diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 9b4ae41ed6..f77e4a176c 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1,5 +1,5 @@ # Code generated by generate_bundle.go; DO NOT EDIT. -# Generated at: 2025-07-25T16:04:33Z +# Generated at: 2025-07-28T09:58:09Z # Source: openapi-split.yaml components: @@ -567,9 +567,8 @@ components: Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false V2KeysAddPermissionsResponseBody: @@ -606,38 +605,15 @@ components: description: | Assigns additional roles to the key through direct assignment to existing workspace roles. Operations are idempotent - adding existing roles has no effect and causes no errors. - Use either ID for existing roles or name for human-readable references. All roles must already exist in the workspace - roles cannot be created automatically. Invalid roles cause the entire operation to fail atomically, ensuring consistent state. - Role assignments take effect immediately but cache propagation across regions may take up to 30 seconds. items: - type: 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 @@ -941,9 +917,8 @@ components: After removal, verification checks for these permissions will fail unless granted through roles. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false V2KeysRemovePermissionsResponseBody: @@ -980,37 +955,15 @@ components: description: | Removes direct role assignments from the key without affecting other role sources or permissions. Operations are idempotent - removing non-assigned roles has no effect and causes no errors. - Use either ID for existing roles or name for exact string matching. After removal, the key loses access to permissions that were only granted through these roles. - Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. items: - type: 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 @@ -1052,9 +1005,8 @@ components: Any permissions that do not exist will be auto created if the root key has permissions, otherwise this operation will fail with a 403 error. items: type: string - minLength: 1 - maxLength: 100 - pattern: "^[a-zA-Z][a-zA-Z0-9._-]*$" + minLength: 3 + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ description: Specify the permission by its slug. additionalProperties: false V2KeysSetPermissionsResponseBody: @@ -1090,39 +1042,16 @@ components: description: | Replaces all existing role assignments with this complete list of roles. This is a wholesale replacement operation, not an incremental update like add/remove operations. - Use either ID for existing roles or name for human-readable references. Providing an empty array removes all direct role assignments from the key. All roles must already exist in the workspace - roles cannot be created automatically. Invalid role references cause the entire operation to fail atomically, ensuring consistent state. - Role changes take effect immediately but cache propagation across regions may take up to 30 seconds. items: - type: 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 @@ -1300,9 +1229,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`. @@ -1555,16 +1484,16 @@ components: 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 @@ -1573,7 +1502,6 @@ components: - Historical analytics referencing this role remain intact Before deletion, ensure: - - You have the correct role ID (verify the role name and permissions) - You've updated any dependent authorization logic or code - You've migrated any keys to use alternative roles or direct permissions - You've notified relevant team members of the access changes @@ -1618,16 +1546,17 @@ components: V2PermissionsGetRoleRequestBody: type: object required: - - roleId + - role properties: - roleId: + role: type: string minLength: 3 maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" + pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$" description: | - Specifies which role to retrieve by its unique identifier. - Must be a valid role ID that begins with 'role_' and exists within your workspace. + Unique identifier of the role to permanently delete from your workspace. + Must either be a valid role ID that begins with 'role_' or the given role name and exists within your workspace. + Use this endpoint to verify role details, check its current permissions, or retrieve metadata. Returns complete role information including all assigned permissions for comprehensive access review. example: role_1234567890abcdef @@ -2502,6 +2431,7 @@ components: Names must be unique within your workspace to avoid confusion and conflicts. example: "users.read" slug: + pattern: ^[a-zA-Z0-9_:\-\.\*]+$ type: string minLength: 1 maxLength: 512 @@ -2536,19 +2466,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: @@ -2593,19 +2553,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 +2587,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 + "$ref": "#/components/schemas/role" KeysVerifyKeyCredits: type: object required: @@ -2932,9 +2868,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. @@ -2942,8 +2875,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. @@ -2952,23 +2883,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: @@ -2984,7 +2904,6 @@ components: - id - name - permissions - - createdAt additionalProperties: false V2PermissionsListPermissionsResponseData: type: array diff --git a/go/apps/api/openapi/spec/common/role.yaml b/go/apps/api/openapi/spec/common/role.yaml index a146dbd32b..b096c075ae 100644 --- a/go/apps/api/openapi/spec/common/role.yaml +++ b/go/apps/api/openapi/spec/common/role.yaml @@ -2,9 +2,6 @@ type: object properties: id: type: string - minLength: 3 - maxLength: 255 - pattern: "^[a-zA-Z0-9_]+$" description: | The unique identifier for this role within Unkey's system. Generated automatically when the role is created and used to reference this role in API operations. @@ -12,8 +9,6 @@ properties: example: role_1234567890abcdef name: type: string - minLength: 1 - maxLength: 512 description: | The human-readable name for this role that describes its function. Should be descriptive enough for administrators to understand what access this role provides. @@ -22,23 +17,12 @@ properties: example: "support.readonly" description: type: string - maxLength: 2048 description: | Optional detailed explanation of what this role encompasses and what access it provides. Helps team members understand the role's scope, intended use cases, and security implications. Include information about what types of users should receive this role and what they can accomplish. Not visible to end users - this is for internal documentation and access control audits. example: "Provides read-only access for customer support representatives to view user accounts and support tickets" - createdAt: - type: integer - format: int64 - minimum: 0 - maximum: 9223372036854775807 # Max int64 value for future-proofing - description: | - Unix timestamp in milliseconds indicating when this role was first created. - Useful for auditing and understanding the evolution of your access control structure. - Automatically set by the system and cannot be modified. - example: 1701425400000 permissions: type: array items: @@ -54,5 +38,4 @@ required: - id - name - permissions - - createdAt additionalProperties: false diff --git a/go/apps/api/openapi/spec/paths/v2/keys/addRoles/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/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/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/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/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/400_test.go b/go/apps/api/routes/v2_keys_add_permissions/400_test.go index c432728ffb..d79001402b 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/400_test.go @@ -12,6 +12,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" @@ -179,7 +180,7 @@ func TestValidationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.validation", Slug: "documents.read.validation", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -249,7 +250,7 @@ func TestValidationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.keynotfound", Slug: "documents.read.keynotfound", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/401_test.go b/go/apps/api/routes/v2_keys_add_permissions/401_test.go index 8621dd304b..2d1449cd75 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/401_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -58,7 +58,7 @@ func TestAuthenticationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.auth", Slug: "documents.read.auth", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/403_test.go b/go/apps/api/routes/v2_keys_add_permissions/403_test.go index 226c58c59e..89ae4e4da4 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -66,7 +66,7 @@ func TestAuthorizationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.auth403", Slug: "documents.read.auth403", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/404_test.go b/go/apps/api/routes/v2_keys_add_permissions/404_test.go index 1c9838fa66..ad7296b786 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_add_permissions/404_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_add_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -50,7 +50,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionSlug, Slug: permissionSlug, - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -109,7 +109,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.404keydifferentws", Slug: "documents.read.404keydifferentws", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_add_permissions/handler.go b/go/apps/api/routes/v2_keys_add_permissions/handler.go index 7e05480518..0b02fff491 100644 --- a/go/apps/api/routes/v2_keys_add_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_add_permissions/handler.go @@ -14,6 +14,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -173,7 +174,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) @@ -182,7 +183,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) } diff --git a/go/apps/api/routes/v2_keys_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 d1a3f61c04..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, @@ -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 8202094981..24b5157e1b 100644 --- a/go/apps/api/routes/v2_keys_create_key/handler.go +++ b/go/apps/api/routes/v2_keys_create_key/handler.go @@ -17,6 +17,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/ptr" @@ -384,7 +385,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: requestedSlug, Slug: requestedSlug, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) @@ -394,7 +395,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Slug: requestedSlug, CreatedAtM: now, WorkspaceID: auth.AuthorizedWorkspaceID, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, UpdatedAtM: sql.NullInt64{Int64: 0, Valid: false}, }) } diff --git a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go index 50b9ab80ad..285514853c 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -256,7 +256,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.remove.partial.keep", Slug: "documents.read.remove.partial.keep", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -267,7 +267,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: removePermissionName, Slug: removePermissionName, - Description: sql.NullString{Valid: true, String: "Write documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Write documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go index 343b3a8ac9..c81544246f 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/400_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/400_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -164,7 +164,7 @@ func TestValidationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.remove.keynotfound", Slug: "documents.read.remove.keynotfound", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go index 18c2078a5d..32a4d5b3e5 100644 --- a/go/apps/api/routes/v2_keys_remove_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_remove_permissions/404_test.go @@ -12,6 +12,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_remove_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" @@ -128,7 +129,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: otherWorkspaceID, Name: "other.workspace.permission.remove.404", Slug: "other.workspace.permission.remove.404", - Description: sql.NullString{Valid: true, String: "Permission in other workspace"}, + Description: dbtype.NullString{Valid: true, String: "Permission in other workspace"}, }) require.NoError(t, err) @@ -231,7 +232,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.remove.404keydifferentws", Slug: "documents.read.remove.404keydifferentws", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_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 fc193f90bd..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, @@ -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 634593baec..de0040d16f 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/200_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -67,7 +67,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permission1Slug, Slug: permission1Slug, - Description: sql.NullString{Valid: true, String: "Initial permission"}, + Description: dbtype.NullString{Valid: true, String: "Initial permission"}, }) require.NoError(t, err) @@ -78,7 +78,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permission2Slug, Slug: permission2Slug, - Description: sql.NullString{Valid: true, String: "Write permission"}, + Description: dbtype.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) @@ -89,7 +89,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permission3Slug, Slug: permission3Slug, - Description: sql.NullString{Valid: true, String: "Delete permission"}, + Description: dbtype.NullString{Valid: true, String: "Delete permission"}, }) require.NoError(t, err) @@ -177,7 +177,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.byname", Slug: "documents.read.byname", - Description: sql.NullString{Valid: true, String: "Read permission"}, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) @@ -187,7 +187,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.write.byname", Slug: "documents.write.byname", - Description: sql.NullString{Valid: true, String: "Write permission"}, + Description: dbtype.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) @@ -251,7 +251,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.empty", Slug: "documents.read.empty", - Description: sql.NullString{Valid: true, String: "Read permission"}, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) @@ -261,7 +261,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.write.empty", Slug: "documents.write.empty", - Description: sql.NullString{Valid: true, String: "Write permission"}, + Description: dbtype.NullString{Valid: true, String: "Write permission"}, }) require.NoError(t, err) @@ -335,7 +335,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionSlugAndName, Slug: permissionSlugAndName, - Description: sql.NullString{Valid: true, String: "Read permission"}, + Description: dbtype.NullString{Valid: true, String: "Read permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/401_test.go b/go/apps/api/routes/v2_keys_set_permissions/401_test.go index 355a3ecb08..bbc7bc2bde 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/401_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/401_test.go @@ -12,6 +12,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" @@ -71,7 +72,7 @@ func TestAuthenticationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.auth", Slug: "documents.read.auth", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/403_test.go b/go/apps/api/routes/v2_keys_set_permissions/403_test.go index 8a4b929795..e13c0acddd 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/403_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -66,7 +66,7 @@ func TestForbidden(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.forbidden", Slug: "documents.read.forbidden", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/404_test.go b/go/apps/api/routes/v2_keys_set_permissions/404_test.go index 5944dff6f8..c667b65764 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/404_test.go +++ b/go/apps/api/routes/v2_keys_set_permissions/404_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_keys_set_permissions" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" @@ -48,7 +48,7 @@ func TestNotFound(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.notfound", Slug: "documents.read.notfound", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) @@ -101,7 +101,7 @@ func TestNotFound(t *testing.T) { WorkspaceID: workspace.ID, Name: "documents.read.isolation", Slug: "documents.read.isolation", - Description: sql.NullString{Valid: true, String: "Read documents permission"}, + Description: dbtype.NullString{Valid: true, String: "Read documents permission"}, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_set_permissions/handler.go b/go/apps/api/routes/v2_keys_set_permissions/handler.go index b267ff5023..9d3e911551 100644 --- a/go/apps/api/routes/v2_keys_set_permissions/handler.go +++ b/go/apps/api/routes/v2_keys_set_permissions/handler.go @@ -14,6 +14,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -171,7 +172,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) @@ -180,7 +181,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Name: perm, WorkspaceID: auth.AuthorizedWorkspaceID, Slug: perm, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: now, }) } diff --git a/go/apps/api/routes/v2_keys_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 3de69ca5d9..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: auditlog.KeyResourceType, - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: auditlog.RoleResourceType, - ID: removedRole.ID, - Name: removedRole.Name, - DisplayName: removedRole.Name, - Meta: map[string]any{}, + if len(rolesToAdd) > 0 { + var keyRolesToInsert []db.InsertKeyRoleParams + + for _, role := range rolesToAdd { + keyRolesToInsert = append(keyRolesToInsert, db.InsertKeyRoleParams{ + KeyID: req.KeyId, + RoleID: role.ID, + WorkspaceID: auth.AuthorizedWorkspaceID, + CreatedAtM: time.Now().UnixMilli(), + }) + + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.AuthConnectRoleKeyEvent, + ActorType: auditlog.RootKeyActor, + ActorID: auth.Key.ID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("Added role %s to key %s", role.Name, req.KeyId), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.KeyResourceType, + ID: req.KeyId, + Name: key.Name.String, + DisplayName: key.Name.String, + Meta: map[string]any{}, + }, + { + Type: auditlog.RoleResourceType, + ID: role.ID, + Name: role.Name, + DisplayName: role.Name, + Meta: map[string]any{}, + }, }, - }, - }) - } + }) + } - // Add new roles - for _, role := range rolesToAdd { - err = db.Query.InsertKeyRole(ctx, tx, db.InsertKeyRoleParams{ - KeyID: req.KeyId, - RoleID: role.ID, - WorkspaceID: auth.AuthorizedWorkspaceID, - CreatedAtM: time.Now().UnixMilli(), - }) + err = db.BulkQuery.InsertKeyRoles(ctx, tx, keyRolesToInsert) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to add role assignment."), + fault.Internal("database error"), + fault.Public("Failed to add role assignment."), ) } - - auditLogs = append(auditLogs, auditlog.AuditLog{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Event: auditlog.AuthConnectRoleKeyEvent, - ActorType: auditlog.RootKeyActor, - ActorID: auth.Key.ID, - ActorName: "root key", - ActorMeta: map[string]any{}, - Display: fmt.Sprintf("Added role %s to key %s", role.Name, req.KeyId), - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - Type: auditlog.KeyResourceType, - ID: req.KeyId, - Name: key.Name.String, - DisplayName: key.Name.String, - Meta: map[string]any{}, - }, - { - Type: auditlog.RoleResourceType, - ID: role.ID, - Name: role.Name, - DisplayName: role.Name, - Meta: map[string]any{}, - }, - }, - }) } - // Insert audit logs if there are changes - if len(auditLogs) > 0 { - err = h.Auditlogs.Insert(ctx, tx, auditLogs) - if err != nil { - return err - } + err = h.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return err } return nil @@ -302,38 +271,40 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.KeyCache.Remove(ctx, key.Hash) - // 10. Get final state of roles and build response - finalRoles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), req.KeyId) - if err != nil { - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database error"), fault.Public("Failed to retrieve final role state."), - ) - } + responseData := make(openapi.V2KeysSetRolesResponseData, 0) + for _, role := range foundRoles { + r := openapi.Role{ + Id: role.ID, + Name: role.Name, + Description: nil, + Permissions: nil, + } - // Sort roles alphabetically by name for consistent response - slices.SortFunc(finalRoles, func(a, b db.Role) int { - if a.Name < b.Name { - return -1 - } else if a.Name > b.Name { - return 1 + if role.Description.Valid { + r.Description = &role.Description.String } - return 0 - }) - // Build response data - responseData := make(openapi.V2KeysSetRolesResponseData, len(finalRoles)) - for i, role := range finalRoles { - responseData[i] = struct { - Id string `json:"id"` - Name string `json:"name"` - }{ - Id: role.ID, - Name: role.Name, + rolePermissions := make([]db.Permission, 0) + json.Unmarshal(role.Permissions.([]byte), &rolePermissions) + + for _, permission := range rolePermissions { + perm := openapi.Permission{ + Id: permission.ID, + Name: permission.Name, + Slug: permission.Slug, + Description: nil, + } + + if permission.Description.Valid { + perm.Description = &permission.Description.String + } + + r.Permissions = append(r.Permissions, perm) } + + responseData = append(responseData, r) } - // 11. Return success response return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index 4c387bc427..f0fa5174e1 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -15,6 +15,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/rbac" @@ -401,7 +402,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: requestedSlug, Slug: requestedSlug, - Description: sql.NullString{String: fmt.Sprintf("Auto-created permission: %s", requestedSlug), Valid: true}, + Description: dbtype.NullString{String: fmt.Sprintf("Auto-created permission: %s", requestedSlug), Valid: true}, CreatedAtM: time.Now().UnixMilli(), }) diff --git a/go/apps/api/routes/v2_permissions_create_permission/handler.go b/go/apps/api/routes/v2_permissions_create_permission/handler.go index 16d4f5af80..e12ddb047c 100644 --- a/go/apps/api/routes/v2_permissions_create_permission/handler.go +++ b/go/apps/api/routes/v2_permissions_create_permission/handler.go @@ -2,7 +2,6 @@ package handler import ( "context" - "database/sql" "net/http" "time" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/otel/logging" "github.com/unkeyed/unkey/go/pkg/ptr" @@ -75,7 +75,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Name: req.Name, Slug: req.Slug, - Description: sql.NullString{Valid: description != "", String: description}, + Description: dbtype.NullString{Valid: description != "", String: description}, CreatedAtM: time.Now().UnixMilli(), }) if err != nil { diff --git a/go/apps/api/routes/v2_permissions_create_role/handler.go b/go/apps/api/routes/v2_permissions_create_role/handler.go index c31e09fb39..a69988bb34 100644 --- a/go/apps/api/routes/v2_permissions_create_role/handler.go +++ b/go/apps/api/routes/v2_permissions_create_role/handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "fmt" "net/http" "time" @@ -88,7 +89,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if db.IsDuplicateKeyError(err) { return fault.New("role already exists", fault.Code(codes.UnkeyDataErrorsIdentityDuplicate), - fault.Internal("role already exists"), fault.Public("A role with name \""+req.Name+"\" already exists in this workspace"), + fault.Internal("role already exists"), fault.Public(fmt.Sprintf("A role with name %q already exists in this workspace", req.Name)), ) } return fault.Wrap(err, diff --git a/go/apps/api/routes/v2_permissions_delete_permission/200_test.go b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go index b6bbed7cde..951350da9d 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -52,7 +52,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-delete-permission", - Description: sql.NullString{Valid: true, String: permissionDesc}, + Description: dbtype.NullString{Valid: true, String: permissionDesc}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -110,7 +110,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-delete-permission-with-description", - Description: sql.NullString{Valid: true, String: permissionDesc}, + Description: dbtype.NullString{Valid: true, String: permissionDesc}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_delete_permission/403_test.go b/go/apps/api/routes/v2_permissions_delete_permission/403_test.go index 9afb9f3ca1..078c0bf31b 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/403_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -41,7 +41,7 @@ func TestAuthorizationErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-permission-delete-auth", - Description: sql.NullString{Valid: true, String: "Test permission for authorization tests"}, + Description: dbtype.NullString{Valid: true, String: "Test permission for authorization tests"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_delete_permission/404_test.go b/go/apps/api/routes/v2_permissions_delete_permission/404_test.go index 8fb7df616d..0a4942f29f 100644 --- a/go/apps/api/routes/v2_permissions_delete_permission/404_test.go +++ b/go/apps/api/routes/v2_permissions_delete_permission/404_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_delete_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -90,7 +90,7 @@ func TestNotFoundErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: "test.permission.to.delete", Slug: "test-permission-to-delete", - Description: sql.NullString{Valid: false}, + Description: dbtype.NullString{Valid: false}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_delete_role/200_test.go b/go/apps/api/routes/v2_permissions_delete_role/200_test.go index 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 22dbd40747..954b5be8d1 100644 --- a/go/apps/api/routes/v2_permissions_delete_role/handler.go +++ b/go/apps/api/routes/v2_permissions_delete_role/handler.go @@ -42,19 +42,16 @@ func (h *Handler) Path() string { func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/permissions.deleteRole") - // 1. Authentication auth, err := h.Keys.GetRootKey(ctx, s) if err != nil { return err } - // 2. Request validation req, err := zen.BindBody[Request](s) if err != nil { return err } - // 3. Permission check err = auth.Verify(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Rbac, @@ -66,8 +63,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // 4. Get role by ID to verify existence and workspace ownership - role, err := db.Query.FindRoleByID(ctx, h.DB.RO(), req.RoleId) + role, err := db.Query.FindRoleByIdOrNameWithPerms(ctx, h.DB.RO(), db.FindRoleByIdOrNameWithPermsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Search: req.Role, + }) if err != nil { if db.IsNotFound(err) { return fault.New("role not found", @@ -81,18 +80,8 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // 5. Check if role belongs to authorized workspace - if role.WorkspaceID != auth.AuthorizedWorkspaceID { - return fault.New("role not found", - fault.Code(codes.Data.Role.NotFound.URN()), - fault.Internal("role not found"), fault.Public("The requested role does not exist."), - ) - } - - // 6. Delete the role in a transaction err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - // Delete role-permission relationships - err = db.Query.DeleteManyRolePermissionsByRoleID(ctx, tx, req.RoleId) + err = db.Query.DeleteManyRolePermissionsByRoleID(ctx, tx, role.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -100,8 +89,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Delete key-role relationships - err = db.Query.DeleteManyKeyRolesByRoleID(ctx, tx, req.RoleId) + err = db.Query.DeleteManyKeyRolesByRoleID(ctx, tx, role.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -109,8 +97,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Delete the role itself - err = db.Query.DeleteRoleByID(ctx, tx, req.RoleId) + err = db.Query.DeleteRoleByID(ctx, tx, role.ID) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -118,7 +105,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ) } - // Create audit log for role deletion err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, @@ -127,13 +113,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ActorID: auth.Key.ID, ActorName: "root key", ActorMeta: map[string]any{}, - Display: "Deleted " + req.RoleId, + Display: "Deleted " + role.ID, RemoteIP: s.Location(), UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { Type: auditlog.RoleResourceType, - ID: req.RoleId, + ID: role.ID, Name: role.Name, DisplayName: role.Name, Meta: map[string]interface{}{ diff --git a/go/apps/api/routes/v2_permissions_get_permission/200_test.go b/go/apps/api/routes/v2_permissions_get_permission/200_test.go index 88e9554767..6367aa56e3 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/200_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/200_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_get_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -50,7 +50,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: permissionSlug, - Description: sql.NullString{Valid: true, String: permissionDesc}, + Description: dbtype.NullString{Valid: true, String: permissionDesc}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) @@ -116,7 +116,7 @@ func TestSuccess(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-get-permission-no-desc", - Description: sql.NullString{}, // Empty description + Description: dbtype.NullString{}, // Empty description CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_get_permission/403_test.go b/go/apps/api/routes/v2_permissions_get_permission/403_test.go index b79a999550..df0dd6b589 100644 --- a/go/apps/api/routes/v2_permissions_get_permission/403_test.go +++ b/go/apps/api/routes/v2_permissions_get_permission/403_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "fmt" "net/http" "testing" @@ -12,6 +11,7 @@ import ( "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_permissions_get_permission" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -40,7 +40,7 @@ func TestPermissionErrors(t *testing.T) { WorkspaceID: workspace.ID, Name: permissionName, Slug: "test-permission-access", - Description: sql.NullString{Valid: true, String: "Test permission for authorization tests"}, + Description: dbtype.NullString{Valid: true, String: "Test permission for authorization tests"}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_permissions_get_role/200_test.go b/go/apps/api/routes/v2_permissions_get_role/200_test.go index 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 ec04653147..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,31 +72,26 @@ 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, @@ -110,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_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 bea0064be4..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,30 +79,28 @@ 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, @@ -115,32 +109,16 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { Description: nil, } - // Add description only if it's valid if perm.Description.Valid { permission.Description = &perm.Description.String } - 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/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_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/querier_generated.go b/go/pkg/db/querier_generated.go index f951692e17..7fb021227a 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -49,6 +49,11 @@ type Querier interface { // DELETE FROM keys_permissions // WHERE permission_id = ? DeleteManyKeyPermissionsByPermissionID(ctx context.Context, db DBTX, permissionID string) error + //DeleteManyKeyRolesByKeyAndRoleIDs + // + // DELETE FROM keys_roles + // WHERE key_id = ? AND role_id IN(/*SLICE:role_ids*/?) + DeleteManyKeyRolesByKeyAndRoleIDs(ctx context.Context, db DBTX, arg DeleteManyKeyRolesByKeyAndRoleIDsParams) error //DeleteManyKeyRolesByKeyID // // DELETE FROM keys_roles @@ -309,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 // @@ -430,6 +478,29 @@ type Querier interface { // WHERE id = ? // LIMIT 1 FindRoleByID(ctx context.Context, db DBTX, roleID string) (Role, error) + //FindRoleByIdOrNameWithPerms + // + // SELECT id, workspace_id, name, description, created_at_m, updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions + // FROM roles r + // WHERE r.workspace_id = ? AND ( + // r.id = ? + // OR r.name = ? + // ) + FindRoleByIdOrNameWithPerms(ctx context.Context, db DBTX, arg FindRoleByIdOrNameWithPermsParams) (FindRoleByIdOrNameWithPermsRow, error) // Finds a role record by its name within a specific workspace // Returns: The role record if found // @@ -1114,21 +1185,49 @@ type Querier interface { ListRatelimitsByKeyIDs(ctx context.Context, db DBTX, keyIds []sql.NullString) ([]ListRatelimitsByKeyIDsRow, error) //ListRoles // - // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m + // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions // FROM roles r // WHERE r.workspace_id = ? - // AND r.id > ? + // AND r.id > ? // ORDER BY r.id // LIMIT 101 - ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ([]Role, error) + ListRoles(ctx context.Context, db DBTX, arg ListRolesParams) ([]ListRolesRow, error) //ListRolesByKeyID // - // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m - // FROM roles r - // JOIN keys_roles kr ON r.id = kr.role_id + // SELECT r.id, r.workspace_id, r.name, r.description, r.created_at_m, r.updated_at_m, COALESCE( + // (SELECT JSON_ARRAYAGG( + // json_object( + // 'id', permission.id, + // 'name', permission.name, + // 'slug', permission.slug, + // 'description', permission.description + // ) + // ) + // FROM (SELECT name, id, slug, description + // FROM roles_permissions rp + // JOIN permissions p ON p.id = rp.permission_id + // WHERE rp.role_id = r.id) as permission), + // JSON_ARRAY() + // ) as permissions + // FROM keys_roles kr + // JOIN roles r ON kr.role_id = r.id // WHERE kr.key_id = ? // ORDER BY r.name - ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ([]Role, error) + ListRolesByKeyID(ctx context.Context, db DBTX, keyID string) ([]ListRolesByKeyIDRow, error) //ListWorkspaces // // SELECT diff --git a/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql b/go/pkg/db/queries/key_role_delete_many_by_key_and_role_ids.sql new file mode 100644 index 0000000000..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/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..7c6f012e73 --- /dev/null +++ b/go/pkg/db/types/null_string.go @@ -0,0 +1,43 @@ +package dbtype + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" +) + +// This is a custom type that represents a nullable string. +type NullString sql.NullString + +// MarshalJSON implements the json.Marshaler interface. +func (x *NullString) MarshalJSON() ([]byte, error) { + if !x.Valid { + return []byte("null"), nil + } + + return json.Marshal(x.String) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ns *NullString) UnmarshalJSON(data []byte) error { + val := string(data) + if val == "null" { + ns.Valid = false + return nil + } + + ns.Valid = true + ns.String = val + + return nil +} + +// Scan implements the sql.Scanner interface. +func (ns *NullString) Scan(value interface{}) error { + return (*sql.NullString)(ns).Scan(value) +} + +// Value implements the driver.Valuer interface. +func (ns NullString) Value() (driver.Value, error) { + return sql.NullString(ns).Value() +} diff --git a/go/pkg/testutil/seed/seed.go b/go/pkg/testutil/seed/seed.go index c414fbdcc2..e7f9e055ab 100644 --- a/go/pkg/testutil/seed/seed.go +++ b/go/pkg/testutil/seed/seed.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/pkg/assert" "github.com/unkeyed/unkey/go/pkg/db" + dbtype "github.com/unkeyed/unkey/go/pkg/db/types" "github.com/unkeyed/unkey/go/pkg/hash" "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/uid" @@ -148,7 +149,7 @@ func (s *Seeder) CreateRootKey(ctx context.Context, workspaceID string, permissi WorkspaceID: s.Resources.RootWorkspace.ID, Name: permission, Slug: permission, - Description: sql.NullString{String: "", Valid: false}, + Description: dbtype.NullString{String: "", Valid: false}, CreatedAtM: time.Now().UnixMilli(), }) @@ -411,7 +412,7 @@ func (s *Seeder) CreatePermission(ctx context.Context, req CreatePermissionReque WorkspaceID: req.WorkspaceID, Name: req.Name, Slug: req.Slug, - Description: sql.NullString{Valid: req.Description != nil, String: ptr.SafeDeref(req.Description, "")}, + Description: dbtype.NullString{Valid: req.Description != nil, String: ptr.SafeDeref(req.Description, "")}, CreatedAtM: time.Now().UnixMilli(), }) require.NoError(s.t, err)